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) } 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] = [ var payload: [String: Any] = [
"TaskID": taskId, "TaskID": taskId,
"UserID": userId ?? 0 "UserID": userId ?? 0
@ -480,6 +480,9 @@ actor APIService {
if let cents = cashReceivedCents { if let cents = cashReceivedCents {
payload["CashReceivedCents"] = cents payload["CashReceivedCents"] = cents
} }
if cancelOrder {
payload["CancelOrder"] = true
}
let json = try await postJSON("/tasks/complete.cfm", payload: payload) let json = try await postJSON("/tasks/complete.cfm", payload: payload)
guard ok(json) else { guard ok(json) else {
throw APIError.serverError("Failed to complete task: \(err(json))") throw APIError.serverError("Failed to complete task: \(err(json))")
@ -768,6 +771,50 @@ actor APIService {
return String(raw.prefix(2000)) 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 // MARK: - URL Helpers
/// Resolve a photo URL if it starts with "/" prepend the base domain /// 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 showCashDialog = false
@State private var cashReceived = "" @State private var cashReceived = ""
@State private var cashError: String? @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 taskAccepted = false // Track if task was just accepted
@State private var customerAvatarUrl: String? // Fetched separately if not in task details @State private var customerAvatarUrl: String? // Fetched separately if not in task details
@ -76,6 +78,12 @@ struct TaskDetailScreen: View {
} message: { } message: {
Text("Mark this task as completed?") 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) { .sheet(isPresented: $showAutoCompleteDialog) {
AutoCompleteCountdownView(taskId: task.taskId) { result in AutoCompleteCountdownView(taskId: task.taskId) { result in
showAutoCompleteDialog = false showAutoCompleteDialog = false
@ -182,21 +190,21 @@ struct TaskDetailScreen: View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "dollarsign.circle.fill") Image(systemName: "dollarsign.circle.fill")
.font(.title2) .font(.title2)
.foregroundColor(.white) .foregroundColor(.black)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Cash Payment Due") Text("Cash Payment Due")
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(.white.opacity(0.9)) .foregroundColor(.black.opacity(0.7))
Text(String(format: "$%.2f", Double(orderTotalCents) / 100)) Text(String(format: "$%.2f", Double(orderTotalCents) / 100))
.font(.title2.bold()) .font(.title2.bold())
.foregroundColor(.white) .foregroundColor(.black)
} }
Spacer() Spacer()
} }
.padding(16) .padding(16)
.background(Color(red: 0.13, green: 0.55, blue: 0.13)) .background(Color.payfritGreen)
.cornerRadius(12) .cornerRadius(12)
} }
@ -541,35 +549,54 @@ struct TaskDetailScreen: View {
// MARK: - Bottom Bar // MARK: - Bottom Bar
private var bottomBar: some View { private var bottomBar: some View {
HStack { VStack(spacing: 12) {
if effectiveShowAcceptButton { HStack {
Button { showAcceptAlert = true } label: { if effectiveShowAcceptButton {
Label("Accept Task", systemImage: "plus.circle.fill") Button { showAcceptAlert = true } label: {
.frame(maxWidth: .infinity) Label("Accept Task", systemImage: "plus.circle.fill")
.padding(.vertical, 14) .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 { // Cancel Order button for cash tasks
if isCashTask && orderTotalCents > 0 { if effectiveShowCompleteButton && isCashTask && orderTotalCents > 0 {
Button { showCashDialog = true } label: { Button {
Label("Collect Cash", systemImage: "dollarsign.circle.fill") showCancelOrderAlert = true
.frame(maxWidth: .infinity) } label: {
.padding(.vertical, 14) 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) .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) { private func callCustomer(_ phone: String) {
guard let url = URL(string: "tel:\(phone)") else { return } guard let url = URL(string: "tel:\(phone)") else { return }
#if os(iOS) #if os(iOS)