Add Cancel Order feature for cash tasks

- Add cancelOrder parameter to completeTask API method
- Add Cancel Order button below Collect Cash in task detail
- Show confirmation alert before canceling
- On confirm, call complete endpoint with CancelOrder: true

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Pinkyfloyd 2026-02-17 09:29:56 -08:00
parent d69270a4af
commit 4a2f984c94
2 changed files with 149 additions and 29 deletions

View file

@ -472,7 +472,7 @@ actor APIService {
return arr.map { WorkTask(json: $0) }
}
func completeTask(taskId: Int, cashReceivedCents: Int? = nil) async throws {
func completeTask(taskId: Int, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws {
var payload: [String: Any] = [
"TaskID": taskId,
"UserID": userId ?? 0
@ -480,6 +480,9 @@ actor APIService {
if let cents = cashReceivedCents {
payload["CashReceivedCents"] = cents
}
if cancelOrder {
payload["CancelOrder"] = true
}
let json = try await postJSON("/tasks/complete.cfm", payload: payload)
guard ok(json) else {
throw APIError.serverError("Failed to complete task: \(err(json))")
@ -768,6 +771,50 @@ actor APIService {
return String(raw.prefix(2000))
}
// MARK: - About Info
func getAboutInfo() async throws -> AboutInfo {
let json = try await getJSON("app/about.cfm")
guard ok(json) else {
throw APIError.serverError(err(json))
}
let description = parseString(json["DESCRIPTION"] ?? json["description"])
let copyright = parseString(json["COPYRIGHT"] ?? json["copyright"])
// Parse features
var features: [AboutFeature] = []
if let featuresArray = (json["FEATURES"] ?? json["features"]) as? [[String: Any]] {
for f in featuresArray {
features.append(AboutFeature(
icon: parseString(f["ICON"] ?? f["icon"]),
title: parseString(f["TITLE"] ?? f["title"]),
description: parseString(f["DESCRIPTION"] ?? f["description"])
))
}
}
// Parse contacts
var contacts: [AboutContact] = []
if let contactsArray = (json["CONTACTS"] ?? json["contacts"]) as? [[String: Any]] {
for c in contactsArray {
contacts.append(AboutContact(
icon: parseString(c["ICON"] ?? c["icon"]),
label: parseString(c["LABEL"] ?? c["label"]),
url: parseString(c["URL"] ?? c["url"])
))
}
}
return AboutInfo(
description: description,
features: features,
contacts: contacts,
copyright: copyright
)
}
// MARK: - URL Helpers
/// Resolve a photo URL if it starts with "/" prepend the base domain
@ -855,4 +902,37 @@ actor APIService {
}
// MARK: - String Parsing Helper
/// Parse any value to a string safely
private func parseString(_ value: Any?) -> String {
guard let v = value else { return "" }
if let s = v as? String { return s }
if let n = v as? NSNumber { return n.stringValue }
if let i = v as? Int { return String(i) }
if let d = v as? Double { return String(d) }
return String(describing: v)
}
// MARK: - About Info Models
struct AboutInfo {
let description: String
let features: [AboutFeature]
let contacts: [AboutContact]
let copyright: String
}
struct AboutFeature {
let icon: String
let title: String
let description: String
}
struct AboutContact {
let icon: String
let label: String
let url: String
}

View file

@ -25,6 +25,8 @@ struct TaskDetailScreen: View {
@State private var showCashDialog = false
@State private var cashReceived = ""
@State private var cashError: String?
@State private var showCancelOrderAlert = false
@State private var isCancelingOrder = false
@State private var taskAccepted = false // Track if task was just accepted
@State private var customerAvatarUrl: String? // Fetched separately if not in task details
@ -76,6 +78,12 @@ struct TaskDetailScreen: View {
} message: {
Text("Mark this task as completed?")
}
.alert("Cancel Order?", isPresented: $showCancelOrderAlert) {
Button("Go Back", role: .cancel) { }
Button("Cancel Order", role: .destructive) { cancelOrder() }
} message: {
Text("The customer will not be charged. This cannot be undone.")
}
.sheet(isPresented: $showAutoCompleteDialog) {
AutoCompleteCountdownView(taskId: task.taskId) { result in
showAutoCompleteDialog = false
@ -182,21 +190,21 @@ struct TaskDetailScreen: View {
HStack(spacing: 12) {
Image(systemName: "dollarsign.circle.fill")
.font(.title2)
.foregroundColor(.white)
.foregroundColor(.black)
VStack(alignment: .leading, spacing: 2) {
Text("Cash Payment Due")
.font(.caption.weight(.medium))
.foregroundColor(.white.opacity(0.9))
.foregroundColor(.black.opacity(0.7))
Text(String(format: "$%.2f", Double(orderTotalCents) / 100))
.font(.title2.bold())
.foregroundColor(.white)
.foregroundColor(.black)
}
Spacer()
}
.padding(16)
.background(Color(red: 0.13, green: 0.55, blue: 0.13))
.background(Color.payfritGreen)
.cornerRadius(12)
}
@ -541,35 +549,54 @@ struct TaskDetailScreen: View {
// MARK: - Bottom Bar
private var bottomBar: some View {
HStack {
if effectiveShowAcceptButton {
Button { showAcceptAlert = true } label: {
Label("Accept Task", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
VStack(spacing: 12) {
HStack {
if effectiveShowAcceptButton {
Button { showAcceptAlert = true } label: {
Label("Accept Task", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.payfritGreen)
}
if effectiveShowCompleteButton {
if isCashTask && orderTotalCents > 0 {
Button { showCashDialog = true } label: {
Label("Collect Cash", systemImage: "dollarsign.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(Color(red: 0.13, green: 0.55, blue: 0.13))
} else {
Button { showCompleteAlert = true } label: {
Label("Complete Task", systemImage: "checkmark.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.green)
}
}
.buttonStyle(.borderedProminent)
.tint(.payfritGreen)
}
if effectiveShowCompleteButton {
if isCashTask && orderTotalCents > 0 {
Button { showCashDialog = true } label: {
Label("Collect Cash", systemImage: "dollarsign.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
// Cancel Order button for cash tasks
if effectiveShowCompleteButton && isCashTask && orderTotalCents > 0 {
Button {
showCancelOrderAlert = true
} label: {
if isCancelingOrder {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .red))
} else {
Label("Cancel Order", systemImage: "xmark.circle")
.font(.subheadline)
}
.buttonStyle(.borderedProminent)
.tint(Color(red: 0.13, green: 0.55, blue: 0.13))
} else {
Button { showCompleteAlert = true } label: {
Label("Complete Task", systemImage: "checkmark.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.green)
}
.foregroundColor(.red)
.disabled(isCancelingOrder)
}
}
.padding(.horizontal)
@ -683,6 +710,19 @@ struct TaskDetailScreen: View {
}
}
private func cancelOrder() {
isCancelingOrder = true
Task {
do {
try await APIService.shared.completeTask(taskId: task.taskId, cancelOrder: true)
appState.popToRoot()
} catch {
self.error = error.localizedDescription
isCancelingOrder = false
}
}
}
private func callCustomer(_ phone: String) {
guard let url = URL(string: "tel:\(phone)") else { return }
#if os(iOS)