diff options
Diffstat (limited to 'Hutch/Views/Tickets')
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailView.swift | 51 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailViewModel.swift | 80 |
2 files changed, 127 insertions, 4 deletions
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index f553bd8..5ca8618 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -132,7 +132,7 @@ struct TicketDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 0) { // Header - ticketHeader(ticket) + ticketHeader(ticket, viewModel: viewModel) Divider() .padding(.vertical, 12) @@ -186,10 +186,16 @@ struct TicketDetailView: View { // MARK: - Header @ViewBuilder - private func ticketHeader(_ ticket: TicketDetail) -> some View { + private func ticketHeader(_ ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View { VStack(alignment: .leading, spacing: 8) { - Text(ticket.title) - .font(.title3.weight(.semibold)) + HStack(alignment: .top, spacing: 12) { + Text(ticket.title) + .font(.title3.weight(.semibold)) + + Spacer(minLength: 12) + + assignToMeButton(ticket: ticket, viewModel: viewModel) + } HStack(spacing: 8) { TicketStatusIcon(status: ticket.status) @@ -235,6 +241,43 @@ struct TicketDetailView: View { .padding() } + @ViewBuilder + private func assignToMeButton(ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View { + if let currentUser = appState.currentUser { + let isAssignedToCurrentUser = ticket.assignees.contains { + TicketDetailViewModel.matchesAssignee($0, user: currentUser) + } + + if isAssignedToCurrentUser { + Label("Assigned to you", systemImage: "checkmark.circle.fill") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(.secondarySystemFill), in: Capsule()) + } else { + Button { + Task { + await viewModel.assignToCurrentUser(currentUser) + } + } label: { + if viewModel.isPerformingAction { + ProgressView() + .controlSize(.small) + .frame(minWidth: 88) + } else { + Text("Assign to Me") + .font(.caption.weight(.semibold)) + .frame(minWidth: 88) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(viewModel.isPerformingAction) + } + } + } + // MARK: - Comment Input @ViewBuilder diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift index 90e33aa..660715b 100644 --- a/Hutch/Views/Tickets/TicketDetailViewModel.swift +++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift @@ -433,6 +433,64 @@ final class TicketDetailViewModel { isPerformingAction = false } + func assignToCurrentUser(_ user: User) async { + guard !isPerformingAction, let currentTicket = ticket else { return } + + let currentAssignees = currentTicket.assignees + let currentEntity = Entity(canonicalName: user.canonicalName) + guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else { + return + } + + isPerformingAction = true + error = nil + + ticket = TicketDetail( + id: currentTicket.id, + created: currentTicket.created, + updated: currentTicket.updated, + title: currentTicket.title, + description: currentTicket.description, + status: currentTicket.status, + resolution: currentTicket.resolution, + authenticity: currentTicket.authenticity, + submitter: currentTicket.submitter, + assignees: currentAssignees + [currentEntity], + labels: currentTicket.labels + ) + + do { + _ = try await client.execute( + service: .todo, + query: Self.assignUserMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "userId": user.id + ], + responseType: AssignUserResponse.self + ) + await loadTicket() + } catch { + ticket = TicketDetail( + id: currentTicket.id, + created: currentTicket.created, + updated: currentTicket.updated, + title: currentTicket.title, + description: currentTicket.description, + status: currentTicket.status, + resolution: currentTicket.resolution, + authenticity: currentTicket.authenticity, + submitter: currentTicket.submitter, + assignees: currentAssignees, + labels: currentTicket.labels + ) + self.error = error.localizedDescription + } + + isPerformingAction = false + } + func unassignUser(username: String) async { guard !isPerformingAction else { return } isPerformingAction = true @@ -558,4 +616,26 @@ final class TicketDetailViewModel { isPerformingAction = false } + static func matchesAssignee(_ entity: Entity, user: User) -> Bool { + let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) + let userCanonical = normalizedCanonicalName(user.canonicalName) + if assigneeCanonical == userCanonical { + return true + } + return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username) + } + + private static func normalizedCanonicalName(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("~") { + return trimmed + } + return "~\(trimmed)" + } + + private static func normalizedUsername(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + } + } |
