forked from sagnik/Project_Velocity
1684 lines
67 KiB
Swift
1684 lines
67 KiB
Swift
import SwiftUI
|
||
|
||
private struct CalendarAgendaItem: Identifiable {
|
||
let id: String
|
||
let title: String
|
||
let slot: String
|
||
let owner: String
|
||
let location: String
|
||
let type: String
|
||
let color: Color
|
||
let pendingSync: Bool
|
||
let sortDate: Date?
|
||
let event: VelocityCalendarEventDTO?
|
||
let task: VelocityTaskDTO?
|
||
}
|
||
|
||
private struct CalendarQuickMetric: Identifiable {
|
||
let id: String
|
||
let label: String
|
||
let value: String
|
||
let color: Color
|
||
}
|
||
|
||
private struct CalendarEventDraft {
|
||
var title = ""
|
||
var clientPersonId = ""
|
||
var description = ""
|
||
var location = ""
|
||
var startDate: Date
|
||
var endDate: Date
|
||
var allDay = false
|
||
var status = "tentative"
|
||
var reminderMinutes = 15
|
||
|
||
init(startDate: Date = CalendarEventDraft.defaultStartDate()) {
|
||
self.startDate = startDate
|
||
self.endDate = startDate.addingTimeInterval(60 * 60)
|
||
}
|
||
|
||
var isValid: Bool {
|
||
guard title.trimmedNonEmpty != nil else {
|
||
return false
|
||
}
|
||
return allDay || endDate > startDate
|
||
}
|
||
|
||
static func defaultStartDate() -> Date {
|
||
let calendar = Calendar.current
|
||
let now = Date()
|
||
let nextHour = calendar.dateInterval(of: .hour, for: now)?.end ?? now.addingTimeInterval(60 * 60)
|
||
return nextHour
|
||
}
|
||
}
|
||
|
||
struct CalendarView: View {
|
||
@State private var store = AppStore.shared
|
||
@State private var selectedDay = Self.currentWeekdayName()
|
||
@State private var actionError: String?
|
||
@State private var actionMessage: String?
|
||
@State private var actionMessageDismissTask: Task<Void, Never>?
|
||
@State private var activeDashboardFocus: VelocityDashboardCalendarFocus?
|
||
@State private var activeTaskMutationID: String?
|
||
@State private var activeEventMutationID: String?
|
||
@State private var undoCancelledTask: VelocityTaskDTO?
|
||
@State private var undoCancelledEvent: VelocityCalendarEventDTO?
|
||
@State private var isCreateEventPresented = false
|
||
@State private var editingEvent: VelocityCalendarEventDTO?
|
||
@State private var eventDraft = CalendarEventDraft()
|
||
@State private var isCreatingEvent = false
|
||
@State private var isSavingEvent = false
|
||
@State private var createEventError: String?
|
||
@State private var schedulingClientPersonID: String?
|
||
@State private var targetedDropDay: String?
|
||
private let visibleWeekdays = Calendar.current.weekdaySymbols
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
header
|
||
if let errorMessage = store.errorMessage {
|
||
errorBanner(errorMessage)
|
||
}
|
||
if let actionError {
|
||
errorBanner(actionError)
|
||
}
|
||
if let actionMessage {
|
||
successBanner(actionMessage)
|
||
}
|
||
if let activeDashboardFocus {
|
||
dashboardFocusBanner(activeDashboardFocus)
|
||
}
|
||
if store.isLoading && store.lastRefreshAt == nil {
|
||
loadingPanel
|
||
} else {
|
||
metricsRow
|
||
clientSchedulingStrip
|
||
HStack(alignment: .top, spacing: 18) {
|
||
scheduleRail
|
||
agendaPanel
|
||
}
|
||
}
|
||
}
|
||
.padding(20)
|
||
}
|
||
.background(VelocityTheme.background)
|
||
.scrollContentBackground(.hidden)
|
||
.task {
|
||
await store.refresh()
|
||
consumeRequestedCalendarFocus()
|
||
}
|
||
.onChange(of: store.requestedCalendarFocus) { _, _ in
|
||
consumeRequestedCalendarFocus()
|
||
}
|
||
.refreshable { await store.refresh() }
|
||
.onDisappear {
|
||
actionMessageDismissTask?.cancel()
|
||
actionMessageDismissTask = nil
|
||
}
|
||
.sheet(isPresented: $isCreateEventPresented) {
|
||
createEventSheet
|
||
}
|
||
.sheet(item: $editingEvent) { event in
|
||
editEventSheet(event)
|
||
}
|
||
}
|
||
|
||
private var header: some View {
|
||
HStack(alignment: .top) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Calendar")
|
||
.font(.system(size: 28, weight: .bold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Text("Operator scheduling edge for confirmed calendar events and canonical CRM follow-up tasks.")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
Spacer()
|
||
HStack(spacing: 10) {
|
||
Button {
|
||
presentCreateEvent()
|
||
} label: {
|
||
Label("Create Event", systemImage: "plus")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(Color.white)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 8)
|
||
.background(
|
||
Capsule()
|
||
.fill(VelocityTheme.accent)
|
||
.overlay(Capsule().stroke(VelocityTheme.accentDim, lineWidth: 1))
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Button {
|
||
selectedDay = Self.currentWeekdayName()
|
||
actionError = nil
|
||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||
activeDashboardFocus = nil
|
||
}
|
||
} label: {
|
||
Text("This week")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.accent)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.background(
|
||
Capsule()
|
||
.fill(VelocityTheme.accent.opacity(0.12))
|
||
.overlay(Capsule().stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var metricsRow: some View {
|
||
HStack(spacing: 12) {
|
||
ForEach(calendarMetrics) { metric in
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(metric.label.uppercased())
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.tracking(1)
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
Text(metric.value)
|
||
.font(.system(size: 20, weight: .bold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
RoundedRectangle(cornerRadius: 4)
|
||
.fill(metric.color)
|
||
.frame(width: 48, height: 4)
|
||
}
|
||
.padding(16)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.glassCard(cornerRadius: 16)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var scheduleRail: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Week Grid")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
|
||
ForEach(visibleWeekdays, id: \.self) { day in
|
||
Button {
|
||
selectedDay = day
|
||
} label: {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(day)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Text(daySubtitle(day))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
Spacer()
|
||
Circle()
|
||
.fill(selectedDay == day ? VelocityTheme.accent : VelocityTheme.borderSubtle)
|
||
.frame(width: 10, height: 10)
|
||
}
|
||
.padding(14)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.fill(targetedDropDay == day ? VelocityTheme.accent.opacity(0.22) : (selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.stroke(targetedDropDay == day || selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
|
||
)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.dropDestination(for: String.self) { personIDs, _ in
|
||
guard let personID = personIDs.first?.trimmedNonEmpty else {
|
||
return false
|
||
}
|
||
Task { await scheduleSiteVisit(forPersonID: personID, on: day) }
|
||
return true
|
||
} isTargeted: { isTargeted in
|
||
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.82)) {
|
||
targetedDropDay = isTargeted ? day : nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(18)
|
||
.frame(maxWidth: 300, alignment: .topLeading)
|
||
.glassCard(cornerRadius: 20)
|
||
}
|
||
|
||
private var clientSchedulingStrip: some View {
|
||
Group {
|
||
if !store.contacts.isEmpty {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 10) {
|
||
ForEach(store.contacts.prefix(12)) { contact in
|
||
HStack(spacing: 8) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(VelocityTheme.accent.opacity(0.16))
|
||
.frame(width: 32, height: 32)
|
||
Text(initials(for: contact.fullName))
|
||
.font(.system(size: 10, weight: .bold))
|
||
.foregroundStyle(VelocityTheme.accent)
|
||
}
|
||
Text(contact.fullName)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.lineLimit(1)
|
||
}
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 8)
|
||
.background(
|
||
Capsule()
|
||
.fill(VelocityTheme.surface)
|
||
.overlay(Capsule().stroke(VelocityTheme.borderSubtle, lineWidth: 1))
|
||
)
|
||
.opacity(schedulingClientPersonID == contact.personId ? 0.55 : 1)
|
||
.scaleEffect(schedulingClientPersonID == contact.personId ? 0.97 : 1)
|
||
.draggable(contact.personId)
|
||
.animation(.interactiveSpring(response: 0.32, dampingFraction: 0.86), value: schedulingClientPersonID)
|
||
}
|
||
}
|
||
.padding(.vertical, 2)
|
||
}
|
||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||
}
|
||
}
|
||
}
|
||
|
||
private var agendaPanel: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(selectedDay)
|
||
.font(.system(size: 22, weight: .bold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Text("Recommended schedule blended from confirmed calendar events and canonical CRM reminders.")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
Spacer()
|
||
}
|
||
|
||
if agendaItems.isEmpty {
|
||
Text("No live calendar events or canonical CRM tasks are scheduled yet for this operator.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
} else if filteredAgendaItems.isEmpty {
|
||
Text("No live calendar events or canonical CRM tasks are scheduled for \(selectedDay).")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
|
||
ForEach(filteredAgendaItems) { item in
|
||
HStack(alignment: .top, spacing: 14) {
|
||
VStack(spacing: 6) {
|
||
Circle()
|
||
.fill(item.color)
|
||
.frame(width: 12, height: 12)
|
||
Rectangle()
|
||
.fill(item.color.opacity(0.22))
|
||
.frame(width: 2, height: 44)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Text(item.title)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Spacer()
|
||
if item.pendingSync {
|
||
Circle()
|
||
.fill(VelocityTheme.warning)
|
||
.frame(width: 8, height: 8)
|
||
.shadow(color: VelocityTheme.warning.opacity(0.75), radius: 6)
|
||
}
|
||
Text(item.type)
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundStyle(item.color)
|
||
}
|
||
Text(item.slot)
|
||
.font(.system(size: 12, weight: .medium))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Text("Owner: \(item.owner) · \(item.location)")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
if let task = item.task {
|
||
if activeTaskMutationID == task.reminderId {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||
} else {
|
||
taskActionsMenu(task)
|
||
}
|
||
} else if let event = item.event {
|
||
if activeEventMutationID == event.calendarEventId {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||
} else {
|
||
eventActionsMenu(event)
|
||
}
|
||
} else {
|
||
Spacer()
|
||
}
|
||
}
|
||
.padding(16)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 16)
|
||
.fill(VelocityTheme.surface)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16)
|
||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||
)
|
||
)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("Calendar synthesis")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Text(calendarSynthesis)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
.padding(16)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 16)
|
||
.fill(Color(red: 0.09, green: 0.15, blue: 0.33).opacity(0.5))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16)
|
||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
||
)
|
||
)
|
||
}
|
||
.padding(22)
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.glassCard(cornerRadius: 20)
|
||
}
|
||
|
||
private var createEventSheet: some View {
|
||
NavigationStack {
|
||
ZStack {
|
||
VelocityTheme.background.ignoresSafeArea()
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Create Event")
|
||
.font(.system(size: 24, weight: .bold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Text("Add a confirmed operator calendar slot with client context, reminders, and meeting details.")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
|
||
if let createEventError {
|
||
errorBanner(createEventError)
|
||
}
|
||
|
||
ScrollViewReader { proxy in
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
Color.clear
|
||
.frame(height: 0)
|
||
.id("create-event-form-top")
|
||
|
||
formLabel("Title")
|
||
eventTextField("Site visit with client", text: $eventDraft.title)
|
||
|
||
formLabel("Client")
|
||
Picker("Client", selection: $eventDraft.clientPersonId) {
|
||
Text("No linked client").tag("")
|
||
ForEach(store.contacts) { contact in
|
||
Text(contact.fullName).tag(contact.personId)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
.tint(VelocityTheme.accent)
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(fieldBackground)
|
||
|
||
HStack(spacing: 12) {
|
||
Toggle("All-day", isOn: $eventDraft.allDay)
|
||
.toggleStyle(.switch)
|
||
.tint(VelocityTheme.accent)
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
|
||
Spacer()
|
||
|
||
Picker("Status", selection: $eventDraft.status) {
|
||
Text("Normal Task").tag("tentative")
|
||
Text("Confirmed Task").tag("confirmed")
|
||
}
|
||
.pickerStyle(.menu)
|
||
.tint(VelocityTheme.accent)
|
||
|
||
Picker("Reminder", selection: $eventDraft.reminderMinutes) {
|
||
Text("None").tag(0)
|
||
Text("5 min").tag(5)
|
||
Text("15 min").tag(15)
|
||
Text("30 min").tag(30)
|
||
Text("1 hour").tag(60)
|
||
Text("1 day").tag(1_440)
|
||
}
|
||
.pickerStyle(.menu)
|
||
.tint(VelocityTheme.accent)
|
||
}
|
||
.padding(12)
|
||
.background(fieldBackground)
|
||
|
||
DatePicker(
|
||
"Starts",
|
||
selection: eventStartBinding,
|
||
displayedComponents: eventDatePickerComponents
|
||
)
|
||
.tint(VelocityTheme.accent)
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.padding(12)
|
||
.background(fieldBackground)
|
||
|
||
if !eventDraft.allDay {
|
||
DatePicker(
|
||
"Ends",
|
||
selection: $eventDraft.endDate,
|
||
in: eventDraft.startDate.addingTimeInterval(60)...,
|
||
displayedComponents: [.date, .hourAndMinute]
|
||
)
|
||
.tint(VelocityTheme.accent)
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.padding(12)
|
||
.background(fieldBackground)
|
||
}
|
||
|
||
formLabel("Location")
|
||
eventTextField("Project site, sales lounge, video call", text: $eventDraft.location)
|
||
|
||
formLabel("Description")
|
||
TextEditor(text: $eventDraft.description)
|
||
.font(.system(size: 14))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.scrollContentBackground(.hidden)
|
||
.frame(minHeight: 110)
|
||
.padding(10)
|
||
.background(fieldBackground)
|
||
}
|
||
.padding(18)
|
||
.glassCard(cornerRadius: 18)
|
||
}
|
||
.frame(height: 436)
|
||
.onAppear {
|
||
DispatchQueue.main.async {
|
||
proxy.scrollTo("create-event-form-top", anchor: .top)
|
||
}
|
||
}
|
||
}
|
||
|
||
HStack(spacing: 12) {
|
||
Button {
|
||
isCreateEventPresented = false
|
||
} label: {
|
||
Text("Cancel")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 13)
|
||
.background(fieldBackground)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Button {
|
||
createEvent()
|
||
} label: {
|
||
HStack(spacing: 8) {
|
||
if isCreatingEvent {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||
}
|
||
Text(isCreatingEvent ? "Creating..." : "Create Event")
|
||
}
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 13)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.fill(eventDraft.isValid && !isCreatingEvent ? VelocityTheme.accent : VelocityTheme.surface3)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!eventDraft.isValid || isCreatingEvent)
|
||
}
|
||
}
|
||
.padding(24)
|
||
.frame(maxWidth: 620)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.toolbar {
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button {
|
||
isCreateEventPresented = false
|
||
} label: {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.font(.system(size: 22, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.height(690)])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
|
||
private func editEventSheet(_ event: VelocityCalendarEventDTO) -> some View {
|
||
NavigationStack {
|
||
ZStack {
|
||
VelocityTheme.background.ignoresSafeArea()
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Edit Event")
|
||
.font(.system(size: 24, weight: .bold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Text("Update the backend-owned calendar slot details.")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
|
||
if let createEventError {
|
||
errorBanner(createEventError)
|
||
}
|
||
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
formLabel("Title")
|
||
eventTextField("Site visit with client", text: $eventDraft.title)
|
||
|
||
HStack(spacing: 12) {
|
||
Picker("Status", selection: $eventDraft.status) {
|
||
Text("Normal Task").tag("tentative")
|
||
Text("Confirmed Task").tag("confirmed")
|
||
Text("Done").tag("done")
|
||
}
|
||
.pickerStyle(.menu)
|
||
.tint(VelocityTheme.accent)
|
||
|
||
Picker("Reminder", selection: $eventDraft.reminderMinutes) {
|
||
Text("None").tag(0)
|
||
Text("5 min").tag(5)
|
||
Text("15 min").tag(15)
|
||
Text("30 min").tag(30)
|
||
Text("1 hour").tag(60)
|
||
Text("1 day").tag(1_440)
|
||
}
|
||
.pickerStyle(.menu)
|
||
.tint(VelocityTheme.accent)
|
||
}
|
||
.padding(12)
|
||
.background(fieldBackground)
|
||
|
||
DatePicker(
|
||
"Starts",
|
||
selection: eventStartBinding,
|
||
displayedComponents: eventDatePickerComponents
|
||
)
|
||
.tint(VelocityTheme.accent)
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.padding(12)
|
||
.background(fieldBackground)
|
||
|
||
if !eventDraft.allDay {
|
||
DatePicker(
|
||
"Ends",
|
||
selection: $eventDraft.endDate,
|
||
in: eventDraft.startDate.addingTimeInterval(60)...,
|
||
displayedComponents: [.date, .hourAndMinute]
|
||
)
|
||
.tint(VelocityTheme.accent)
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.padding(12)
|
||
.background(fieldBackground)
|
||
}
|
||
|
||
formLabel("Location")
|
||
eventTextField("Project site, sales lounge, video call", text: $eventDraft.location)
|
||
|
||
formLabel("Description")
|
||
TextEditor(text: $eventDraft.description)
|
||
.font(.system(size: 14))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.scrollContentBackground(.hidden)
|
||
.frame(minHeight: 110)
|
||
.padding(10)
|
||
.background(fieldBackground)
|
||
}
|
||
.padding(18)
|
||
.glassCard(cornerRadius: 18)
|
||
}
|
||
.frame(height: 436)
|
||
|
||
HStack(spacing: 12) {
|
||
Button {
|
||
editingEvent = nil
|
||
} label: {
|
||
Text("Cancel")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 13)
|
||
.background(fieldBackground)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Button {
|
||
saveEventEdits(event)
|
||
} label: {
|
||
HStack(spacing: 8) {
|
||
if isSavingEvent {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||
}
|
||
Text(isSavingEvent ? "Saving..." : "Save Event")
|
||
}
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 13)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.fill(eventDraft.isValid && !isSavingEvent ? VelocityTheme.accent : VelocityTheme.surface3)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!eventDraft.isValid || isSavingEvent)
|
||
}
|
||
}
|
||
.padding(24)
|
||
.frame(maxWidth: 620)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
.presentationDetents([.height(690)])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
|
||
private var filteredAgendaItems: [CalendarAgendaItem] {
|
||
if let activeDashboardFocus {
|
||
switch activeDashboardFocus {
|
||
case .today:
|
||
let weekday = Self.currentWeekdayName().lowercased()
|
||
return agendaItems.filter {
|
||
$0.slot.lowercased().contains(weekday) && !isInactiveAgendaItem($0)
|
||
}
|
||
case .pendingTasks:
|
||
return agendaItems.filter { item in
|
||
guard let task = item.task else { return false }
|
||
return ["pending", "snoozed", "confirmed"].contains(task.status.lowercased())
|
||
}
|
||
case .urgentTasks:
|
||
return agendaItems.filter { item in
|
||
guard let task = item.task else { return false }
|
||
return ["urgent", "high"].contains(task.priority.lowercased()) && !isInactiveAgendaItem(item)
|
||
}
|
||
}
|
||
}
|
||
let weekday = selectedDay.lowercased()
|
||
return agendaItems.filter { $0.slot.lowercased().contains(weekday) }
|
||
}
|
||
|
||
private var agendaItems: [CalendarAgendaItem] {
|
||
let eventItems = store.calendarEvents.filter { $0.status.lowercased() != "cancelled" }.map { event in
|
||
CalendarAgendaItem(
|
||
id: event.calendarEventId,
|
||
title: event.title,
|
||
slot: formattedSlot(startAt: event.startAt),
|
||
owner: event.createdBy.replacingOccurrences(of: "_", with: " ").capitalized,
|
||
location: event.location ?? "No location",
|
||
type: eventStatusLabel(event.status),
|
||
color: color(for: event.status),
|
||
pendingSync: store.pendingSyncCalendarEventIDs.contains(event.calendarEventId) || event.calendarEventId.hasPrefix("local-"),
|
||
sortDate: event.startDate,
|
||
event: event,
|
||
task: nil
|
||
)
|
||
}
|
||
let taskItems = store.prioritizedTasks.filter { $0.status.lowercased() != "cancelled" }.map { task in
|
||
CalendarAgendaItem(
|
||
id: task.reminderId,
|
||
title: task.title,
|
||
slot: formattedTaskSlot(task),
|
||
owner: task.ownerLabel,
|
||
location: task.clientPhone ?? "Canonical CRM task",
|
||
type: taskStatusLabel(task),
|
||
color: taskColor(for: task),
|
||
pendingSync: store.pendingSyncTaskIDs.contains(task.reminderId),
|
||
sortDate: task.dueDate,
|
||
event: nil,
|
||
task: task
|
||
)
|
||
}
|
||
|
||
return (eventItems + taskItems).sorted { lhs, rhs in
|
||
switch (lhs.sortDate, rhs.sortDate) {
|
||
case let (left?, right?):
|
||
return left < right
|
||
case (_?, nil):
|
||
return true
|
||
case (nil, _?):
|
||
return false
|
||
default:
|
||
return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending
|
||
}
|
||
}
|
||
}
|
||
|
||
private var calendarMetrics: [CalendarQuickMetric] {
|
||
buildMetrics(
|
||
events: store.calendarEvents,
|
||
tasks: store.prioritizedTasks,
|
||
pendingTaskCount: store.pendingTaskMetricCount,
|
||
agendaItems: agendaItems
|
||
)
|
||
}
|
||
|
||
private var calendarSynthesis: String {
|
||
if agendaItems.isEmpty {
|
||
return "Velocity has not received any live calendar events or canonical CRM reminder tasks yet. Once reminders and confirmed follow-ups are written, they will appear here automatically."
|
||
}
|
||
return "Live mobile-edge calendar events are now blended with canonical CRM reminder tasks so the iPad operator schedule reflects both confirmed meetings and pending follow-up work."
|
||
}
|
||
|
||
private var loadingPanel: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||
Text("Loading live calendar events...")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Text("This surface reads confirmed mobile-edge calendar records and canonical CRM reminder tasks for the authenticated Velocity user.")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
.padding(20)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.glassCard(cornerRadius: 20)
|
||
}
|
||
|
||
private var eventStartBinding: Binding<Date> {
|
||
Binding(
|
||
get: { eventDraft.startDate },
|
||
set: { newValue in
|
||
let previousDuration = max(eventDraft.endDate.timeIntervalSince(eventDraft.startDate), 60 * 60)
|
||
eventDraft.startDate = newValue
|
||
if eventDraft.endDate <= newValue {
|
||
eventDraft.endDate = newValue.addingTimeInterval(previousDuration)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
private var eventDatePickerComponents: DatePickerComponents {
|
||
eventDraft.allDay ? [.date] : [.date, .hourAndMinute]
|
||
}
|
||
|
||
private var fieldBackground: some View {
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.fill(VelocityTheme.surface2)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||
)
|
||
}
|
||
|
||
private func formLabel(_ text: String) -> some View {
|
||
Text(text.uppercased())
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.tracking(1)
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
|
||
private func eventTextField(_ prompt: String, text: Binding<String>) -> some View {
|
||
TextField(prompt, text: text)
|
||
.font(.system(size: 14))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.padding(12)
|
||
.background(fieldBackground)
|
||
}
|
||
|
||
private func errorBanner(_ message: String) -> some View {
|
||
Text(message)
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundStyle(VelocityTheme.danger)
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.fill(VelocityTheme.danger.opacity(0.10))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
|
||
)
|
||
)
|
||
}
|
||
|
||
private func successBanner(_ message: String) -> some View {
|
||
HStack(spacing: 12) {
|
||
Text(message)
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundStyle(VelocityTheme.success)
|
||
Spacer()
|
||
if undoCancelledTask != nil || undoCancelledEvent != nil {
|
||
Button {
|
||
undoCancellation()
|
||
} label: {
|
||
Label("Undo", systemImage: "arrow.uturn.backward")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.success)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 7)
|
||
.background(
|
||
Capsule()
|
||
.fill(VelocityTheme.success.opacity(0.14))
|
||
.overlay(Capsule().stroke(VelocityTheme.success.opacity(0.28), lineWidth: 1))
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.fill(VelocityTheme.success.opacity(0.10))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.stroke(VelocityTheme.success.opacity(0.22), lineWidth: 1)
|
||
)
|
||
)
|
||
}
|
||
|
||
private func dashboardFocusBanner(_ focus: VelocityDashboardCalendarFocus) -> some View {
|
||
HStack(spacing: 10) {
|
||
Label(calendarFocusLabel(focus), systemImage: "line.3.horizontal.decrease.circle")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.warning)
|
||
Text(calendarFocusDescription(focus))
|
||
.font(.system(size: 12, weight: .medium))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
Spacer()
|
||
Button("Clear") {
|
||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||
activeDashboardFocus = nil
|
||
}
|
||
}
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.accent)
|
||
}
|
||
.padding(14)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.fill(VelocityTheme.warning.opacity(0.10))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.stroke(VelocityTheme.warning.opacity(0.22), lineWidth: 1)
|
||
)
|
||
)
|
||
}
|
||
|
||
private func buildMetrics(
|
||
events: [VelocityCalendarEventDTO],
|
||
tasks: [VelocityTaskDTO],
|
||
pendingTaskCount: Int,
|
||
agendaItems: [CalendarAgendaItem]
|
||
) -> [CalendarQuickMetric] {
|
||
let currentWeekday = Self.currentWeekdayName().lowercased()
|
||
let today = agendaItems.filter {
|
||
$0.slot.lowercased().contains(currentWeekday) && !isInactiveAgendaItem($0)
|
||
}.count
|
||
let confirmed = events.filter { $0.status.lowercased() == "confirmed" }.count
|
||
+ tasks.filter { $0.status.lowercased() == "confirmed" }.count
|
||
return [
|
||
CalendarQuickMetric(id: "today", label: "Today", value: "\(today) items", color: VelocityTheme.accent),
|
||
CalendarQuickMetric(id: "priority", label: "Confirmed", value: "\(confirmed)", color: VelocityTheme.success),
|
||
CalendarQuickMetric(id: "pending", label: "Pending tasks", value: "\(pendingTaskCount)", color: VelocityTheme.warning),
|
||
]
|
||
}
|
||
|
||
private func daySubtitle(_ day: String) -> String {
|
||
let count = agendaItems.filter {
|
||
$0.slot.lowercased().contains(day.lowercased()) && !isInactiveAgendaItem($0)
|
||
}.count
|
||
return count == 1 ? "1 scheduled item" : "\(count) scheduled items"
|
||
}
|
||
|
||
private func isInactiveAgendaItem(_ item: CalendarAgendaItem) -> Bool {
|
||
if let task = item.task {
|
||
return ["done", "cancelled"].contains(task.status.lowercased())
|
||
}
|
||
if let event = item.event {
|
||
return ["done", "cancelled"].contains(event.status.lowercased())
|
||
}
|
||
return false
|
||
}
|
||
|
||
private func formattedSlot(startAt: String) -> String {
|
||
guard let date = ISO8601DateFormatter().date(from: startAt) else {
|
||
return startAt
|
||
}
|
||
let dayFormatter = DateFormatter()
|
||
dayFormatter.dateFormat = "EEEE"
|
||
let timeFormatter = DateFormatter()
|
||
timeFormatter.dateFormat = "h:mm a"
|
||
return "\(dayFormatter.string(from: date)) · \(timeFormatter.string(from: date))"
|
||
}
|
||
|
||
private func formattedTaskSlot(_ task: VelocityTaskDTO) -> String {
|
||
guard let dueDate = task.dueDate else {
|
||
return "Unscheduled"
|
||
}
|
||
let dayFormatter = DateFormatter()
|
||
dayFormatter.dateFormat = "EEEE"
|
||
let timeFormatter = DateFormatter()
|
||
timeFormatter.dateFormat = "h:mm a"
|
||
return "\(dayFormatter.string(from: dueDate)) · \(timeFormatter.string(from: dueDate))"
|
||
}
|
||
|
||
private func isToday(_ startAt: String) -> Bool {
|
||
guard let date = ISO8601DateFormatter().date(from: startAt) else {
|
||
return false
|
||
}
|
||
return Calendar.current.isDateInToday(date)
|
||
}
|
||
|
||
private func isToday(_ date: Date?) -> Bool {
|
||
guard let date else {
|
||
return false
|
||
}
|
||
return Calendar.current.isDateInToday(date)
|
||
}
|
||
|
||
private func color(for status: String) -> Color {
|
||
switch status.lowercased() {
|
||
case "confirmed":
|
||
return VelocityTheme.success
|
||
case "done":
|
||
return VelocityTheme.mutedFg
|
||
case "tentative":
|
||
return VelocityTheme.accent
|
||
case "cancelled":
|
||
return VelocityTheme.danger
|
||
default:
|
||
return VelocityTheme.mutedFg
|
||
}
|
||
}
|
||
|
||
private func eventStatusLabel(_ status: String) -> String {
|
||
switch status.lowercased() {
|
||
case "tentative":
|
||
return "Normal Task"
|
||
case "confirmed":
|
||
return "Confirmed Task"
|
||
case "done":
|
||
return "Done"
|
||
case "cancelled":
|
||
return "Cancelled"
|
||
default:
|
||
return status.capitalized
|
||
}
|
||
}
|
||
|
||
private func taskStatusLabel(_ task: VelocityTaskDTO) -> String {
|
||
switch task.status.lowercased() {
|
||
case "confirmed":
|
||
return "Confirmed Task"
|
||
case "done":
|
||
return "Done"
|
||
case "snoozed":
|
||
return "Snoozed"
|
||
case "cancelled":
|
||
return "Cancelled"
|
||
default:
|
||
return "Normal Task"
|
||
}
|
||
}
|
||
|
||
private func taskColor(for task: VelocityTaskDTO) -> Color {
|
||
switch task.status.lowercased() {
|
||
case "confirmed":
|
||
return VelocityTheme.success
|
||
case "done":
|
||
return VelocityTheme.mutedFg
|
||
case "cancelled":
|
||
return VelocityTheme.danger
|
||
default:
|
||
break
|
||
}
|
||
|
||
return VelocityTheme.accent
|
||
}
|
||
|
||
private func taskActionsMenu(_ task: VelocityTaskDTO) -> some View {
|
||
Menu {
|
||
let status = task.status.lowercased()
|
||
if status == "done" {
|
||
Button(role: .destructive) {
|
||
mutateTask(
|
||
task,
|
||
status: "cancelled",
|
||
dueAt: nil,
|
||
notes: "Removed after completion from the iPad calendar.",
|
||
successMessage: "Task removed.",
|
||
supportsUndo: false
|
||
)
|
||
} label: {
|
||
Label("Remove Task", systemImage: "trash")
|
||
}
|
||
} else if status == "confirmed" {
|
||
Button {
|
||
mutateTask(task, status: "pending", dueAt: task.dueAt, notes: "Set normal from the iPad calendar.")
|
||
} label: {
|
||
Label("Set Normal", systemImage: "circle")
|
||
}
|
||
} else if status != "done" && status != "cancelled" {
|
||
Button {
|
||
mutateTask(task, status: "confirmed", dueAt: task.dueAt, notes: "Marked confirmed from the iPad calendar.")
|
||
} label: {
|
||
Label("Mark Confirmed", systemImage: "checkmark.seal")
|
||
}
|
||
}
|
||
|
||
if status != "done" && status != "cancelled" {
|
||
Button {
|
||
mutateTask(task, status: "done", dueAt: task.dueAt, notes: "Marked done from the iPad calendar.")
|
||
} label: {
|
||
Label("Mark Done", systemImage: "checkmark.circle")
|
||
}
|
||
}
|
||
|
||
if !["done", "cancelled"].contains(status) {
|
||
Button {
|
||
let snoozeDate = task.nextSnoozeDate(adding: 2 * 60 * 60)
|
||
mutateTask(
|
||
task,
|
||
status: "snoozed",
|
||
dueAt: iso8601Timestamp(snoozeDate),
|
||
notes: "Snoozed by 2 hours from the iPad calendar."
|
||
)
|
||
} label: {
|
||
Label("Snooze 2h", systemImage: "clock.badge")
|
||
}
|
||
|
||
Button {
|
||
let snoozeDate = task.nextSnoozeDate(adding: 24 * 60 * 60)
|
||
mutateTask(
|
||
task,
|
||
status: "snoozed",
|
||
dueAt: iso8601Timestamp(snoozeDate),
|
||
notes: "Snoozed by 1 day from the iPad calendar."
|
||
)
|
||
} label: {
|
||
Label("Snooze 1 Day", systemImage: "moon")
|
||
}
|
||
}
|
||
|
||
if status != "done" && status != "cancelled" {
|
||
Button(role: .destructive) {
|
||
mutateTask(task, status: "cancelled", dueAt: nil, notes: "Cancelled from the iPad calendar.")
|
||
} label: {
|
||
Label("Cancel Task", systemImage: "xmark.circle")
|
||
}
|
||
}
|
||
} label: {
|
||
menuIcon("ellipsis.circle")
|
||
}
|
||
}
|
||
|
||
private func eventActionsMenu(_ event: VelocityCalendarEventDTO) -> some View {
|
||
Menu {
|
||
let status = event.status.lowercased()
|
||
if status != "cancelled" {
|
||
Button {
|
||
presentEditEvent(event)
|
||
} label: {
|
||
Label("Edit Event", systemImage: "square.and.pencil")
|
||
}
|
||
}
|
||
|
||
if status == "done" {
|
||
Button(role: .destructive) {
|
||
cancelEvent(event, message: "Task removed.", supportsUndo: false)
|
||
} label: {
|
||
Label("Remove Task", systemImage: "trash")
|
||
}
|
||
} else if status == "confirmed" {
|
||
Button {
|
||
mutateEvent(event, status: "tentative", message: "Task set to normal.")
|
||
} label: {
|
||
Label("Set Normal", systemImage: "circle")
|
||
}
|
||
} else if status != "done" && status != "cancelled" {
|
||
Button {
|
||
mutateEvent(event, status: "confirmed", message: "Task marked confirmed.")
|
||
} label: {
|
||
Label("Mark Confirmed", systemImage: "checkmark.seal")
|
||
}
|
||
}
|
||
|
||
if status != "done" && status != "cancelled" {
|
||
Button {
|
||
mutateEvent(event, status: "done", message: "Task marked done.")
|
||
} label: {
|
||
Label("Mark Done", systemImage: "checkmark.circle")
|
||
}
|
||
}
|
||
|
||
if !["done", "cancelled"].contains(status) {
|
||
Button {
|
||
shiftEvent(event, adding: 2 * 60 * 60, message: "Task snoozed by 2 hours.")
|
||
} label: {
|
||
Label("Snooze 2h", systemImage: "clock.badge")
|
||
}
|
||
|
||
Button {
|
||
shiftEvent(event, adding: 24 * 60 * 60, message: "Task snoozed by 1 day.")
|
||
} label: {
|
||
Label("Snooze 1 Day", systemImage: "moon")
|
||
}
|
||
}
|
||
|
||
if status != "done" && status != "cancelled" {
|
||
Button(role: .destructive) {
|
||
cancelEvent(event)
|
||
} label: {
|
||
Label("Cancel Task", systemImage: "xmark.circle")
|
||
}
|
||
}
|
||
} label: {
|
||
menuIcon("ellipsis.circle")
|
||
}
|
||
}
|
||
|
||
private func presentCreateEvent() {
|
||
eventDraft = CalendarEventDraft(startDate: defaultEventStartDate(for: selectedDay))
|
||
createEventError = nil
|
||
actionError = nil
|
||
clearActionMessage()
|
||
isCreateEventPresented = true
|
||
}
|
||
|
||
private func presentEditEvent(_ event: VelocityCalendarEventDTO) {
|
||
let startDate = event.startDate ?? CalendarEventDraft.defaultStartDate()
|
||
var draft = CalendarEventDraft(startDate: startDate)
|
||
draft.title = event.title
|
||
draft.description = event.description ?? ""
|
||
draft.location = event.location ?? ""
|
||
draft.endDate = event.endDate ?? startDate.addingTimeInterval(60 * 60)
|
||
draft.allDay = event.allDay
|
||
draft.status = event.status.lowercased()
|
||
draft.reminderMinutes = event.reminderMinutes.first ?? 0
|
||
eventDraft = draft
|
||
createEventError = nil
|
||
actionError = nil
|
||
clearActionMessage()
|
||
editingEvent = event
|
||
}
|
||
|
||
@MainActor
|
||
private func scheduleSiteVisit(forPersonID personID: String, on weekday: String) async {
|
||
guard schedulingClientPersonID == nil else {
|
||
return
|
||
}
|
||
guard let contact = store.contacts.first(where: { $0.personId == personID }) else {
|
||
actionError = "Unable to schedule: this client is not present in the canonical CRM payload."
|
||
return
|
||
}
|
||
guard let leadId = contact.leadId?.trimmedNonEmpty else {
|
||
actionError = "Unable to schedule \(contact.fullName): no canonical lead is attached."
|
||
return
|
||
}
|
||
|
||
let startDate = defaultEventStartDate(for: weekday)
|
||
let endDate = startDate.addingTimeInterval(60 * 60)
|
||
var metadata = [
|
||
"created_from": "ipad_calendar_drag_drop",
|
||
"surface": "velocity_ipad",
|
||
"person_id": contact.personId,
|
||
"client_name": contact.fullName,
|
||
]
|
||
if let phone = contact.primaryPhone?.trimmedNonEmpty {
|
||
metadata["client_phone"] = phone
|
||
}
|
||
|
||
schedulingClientPersonID = personID
|
||
actionError = nil
|
||
clearActionMessage()
|
||
|
||
do {
|
||
_ = try await store.createCalendarEvent(
|
||
leadId: leadId,
|
||
title: "Site visit with \(contact.fullName)",
|
||
description: contact.primaryInterest.flatMap { "Property focus: \($0)".trimmedNonEmpty },
|
||
startAt: iso8601Timestamp(startDate),
|
||
endAt: iso8601Timestamp(endDate),
|
||
allDay: false,
|
||
status: "confirmed",
|
||
reminderMinutes: [60, 15],
|
||
location: contact.primaryInterest?.trimmedNonEmpty ?? "Project site",
|
||
metadata: metadata
|
||
)
|
||
withAnimation(.interactiveSpring(response: 0.42, dampingFraction: 0.86)) {
|
||
selectedDay = weekday
|
||
schedulingClientPersonID = nil
|
||
targetedDropDay = nil
|
||
}
|
||
showActionMessage("Site visit scheduled for \(contact.fullName).")
|
||
} catch {
|
||
schedulingClientPersonID = nil
|
||
targetedDropDay = nil
|
||
actionError = calendarActionErrorMessage(error)
|
||
}
|
||
}
|
||
|
||
private func createEvent() {
|
||
guard eventDraft.isValid else {
|
||
createEventError = "Add an event title and make sure the end time is after the start time."
|
||
return
|
||
}
|
||
|
||
let selectedContact = store.contacts.first { $0.personId == eventDraft.clientPersonId }
|
||
let calendar = Calendar.current
|
||
let startDate = eventDraft.allDay ? calendar.startOfDay(for: eventDraft.startDate) : eventDraft.startDate
|
||
let endDate = eventDraft.allDay
|
||
? (calendar.date(byAdding: .day, value: 1, to: startDate) ?? startDate.addingTimeInterval(24 * 60 * 60))
|
||
: eventDraft.endDate
|
||
let reminderMinutes = eventDraft.reminderMinutes > 0 ? [eventDraft.reminderMinutes] : []
|
||
var metadata = [
|
||
"created_from": "ipad_calendar",
|
||
"surface": "velocity_ipad",
|
||
]
|
||
if let selectedContact {
|
||
metadata["person_id"] = selectedContact.personId
|
||
metadata["client_name"] = selectedContact.fullName
|
||
if let phone = selectedContact.primaryPhone?.trimmedNonEmpty {
|
||
metadata["client_phone"] = phone
|
||
}
|
||
}
|
||
|
||
createEventError = nil
|
||
isCreatingEvent = true
|
||
|
||
Task {
|
||
do {
|
||
_ = try await store.createCalendarEvent(
|
||
leadId: selectedContact?.leadId,
|
||
title: eventDraft.title.trimmedNonEmpty ?? "Calendar event",
|
||
description: eventDraft.description.trimmedNonEmpty,
|
||
startAt: iso8601Timestamp(startDate),
|
||
endAt: iso8601Timestamp(endDate),
|
||
allDay: eventDraft.allDay,
|
||
status: eventDraft.status,
|
||
reminderMinutes: reminderMinutes,
|
||
location: eventDraft.location.trimmedNonEmpty,
|
||
metadata: metadata
|
||
)
|
||
await MainActor.run {
|
||
selectedDay = weekdayName(for: startDate)
|
||
isCreatingEvent = false
|
||
isCreateEventPresented = false
|
||
showActionMessage("Event created.")
|
||
actionError = nil
|
||
}
|
||
} catch {
|
||
await MainActor.run {
|
||
isCreatingEvent = false
|
||
createEventError = calendarActionErrorMessage(error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func saveEventEdits(_ event: VelocityCalendarEventDTO) {
|
||
guard eventDraft.isValid else {
|
||
createEventError = "Add an event title and make sure the end time is after the start time."
|
||
return
|
||
}
|
||
|
||
let calendar = Calendar.current
|
||
let startDate = eventDraft.allDay ? calendar.startOfDay(for: eventDraft.startDate) : eventDraft.startDate
|
||
let endDate = eventDraft.allDay
|
||
? (calendar.date(byAdding: .day, value: 1, to: startDate) ?? startDate.addingTimeInterval(24 * 60 * 60))
|
||
: eventDraft.endDate
|
||
let reminderMinutes = eventDraft.reminderMinutes > 0 ? [eventDraft.reminderMinutes] : []
|
||
|
||
createEventError = nil
|
||
isSavingEvent = true
|
||
|
||
Task {
|
||
do {
|
||
_ = try await store.updateCalendarEvent(
|
||
event,
|
||
title: eventDraft.title.trimmedNonEmpty ?? event.title,
|
||
description: eventDraft.description,
|
||
status: eventDraft.status,
|
||
startAt: iso8601Timestamp(startDate),
|
||
endAt: iso8601Timestamp(endDate),
|
||
reminderMinutes: reminderMinutes,
|
||
location: eventDraft.location
|
||
)
|
||
await MainActor.run {
|
||
selectedDay = weekdayName(for: startDate)
|
||
isSavingEvent = false
|
||
editingEvent = nil
|
||
showActionMessage("Event updated.")
|
||
actionError = nil
|
||
}
|
||
} catch {
|
||
await MainActor.run {
|
||
isSavingEvent = false
|
||
createEventError = calendarActionErrorMessage(error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func mutateEvent(
|
||
_ event: VelocityCalendarEventDTO,
|
||
status: String,
|
||
message: String
|
||
) {
|
||
actionError = nil
|
||
clearActionMessage()
|
||
activeEventMutationID = event.calendarEventId
|
||
|
||
Task {
|
||
do {
|
||
_ = try await store.updateCalendarEvent(event, status: status)
|
||
await MainActor.run {
|
||
activeEventMutationID = nil
|
||
showActionMessage(message)
|
||
}
|
||
} catch {
|
||
await MainActor.run {
|
||
activeEventMutationID = nil
|
||
actionError = calendarActionErrorMessage(error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func shiftEvent(
|
||
_ event: VelocityCalendarEventDTO,
|
||
adding interval: TimeInterval,
|
||
message: String
|
||
) {
|
||
guard let startDate = event.startDate, let endDate = event.endDate else {
|
||
actionError = "This event cannot be rescheduled because its date was not readable."
|
||
return
|
||
}
|
||
|
||
actionError = nil
|
||
clearActionMessage()
|
||
activeEventMutationID = event.calendarEventId
|
||
|
||
Task {
|
||
do {
|
||
_ = try await store.updateCalendarEvent(
|
||
event,
|
||
startAt: iso8601Timestamp(startDate.addingTimeInterval(interval)),
|
||
endAt: iso8601Timestamp(endDate.addingTimeInterval(interval))
|
||
)
|
||
await MainActor.run {
|
||
activeEventMutationID = nil
|
||
showActionMessage(message)
|
||
}
|
||
} catch {
|
||
await MainActor.run {
|
||
activeEventMutationID = nil
|
||
actionError = calendarActionErrorMessage(error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func cancelEvent(
|
||
_ event: VelocityCalendarEventDTO,
|
||
message: String = "Event cancelled.",
|
||
supportsUndo: Bool = true
|
||
) {
|
||
actionError = nil
|
||
clearActionMessage()
|
||
activeEventMutationID = event.calendarEventId
|
||
|
||
Task {
|
||
do {
|
||
try await store.cancelCalendarEvent(event)
|
||
await MainActor.run {
|
||
activeEventMutationID = nil
|
||
if supportsUndo {
|
||
undoCancelledEvent = event
|
||
}
|
||
showActionMessage(message)
|
||
}
|
||
} catch {
|
||
await MainActor.run {
|
||
activeEventMutationID = nil
|
||
actionError = calendarActionErrorMessage(error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func mutateTask(
|
||
_ task: VelocityTaskDTO,
|
||
status: String,
|
||
dueAt: String?,
|
||
notes: String,
|
||
successMessage: String? = nil,
|
||
supportsUndo: Bool = true
|
||
) {
|
||
actionError = nil
|
||
clearActionMessage()
|
||
activeTaskMutationID = task.reminderId
|
||
|
||
Task {
|
||
do {
|
||
_ = try await store.updateTaskStatus(
|
||
reminderId: task.reminderId,
|
||
status: status,
|
||
dueAt: dueAt,
|
||
notes: notes
|
||
)
|
||
await MainActor.run {
|
||
activeTaskMutationID = nil
|
||
if status.lowercased() == "cancelled" && supportsUndo {
|
||
undoCancelledTask = task
|
||
}
|
||
showActionMessage(successMessage ?? taskActionMessage(status: status))
|
||
}
|
||
} catch {
|
||
if let apiError = error as? VelocityAPIError, apiError.statusCode == 404 {
|
||
await store.refresh(silent: true)
|
||
}
|
||
await MainActor.run {
|
||
activeTaskMutationID = nil
|
||
actionError = calendarActionErrorMessage(error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func calendarActionErrorMessage(_ error: Error) -> String {
|
||
if let apiError = error as? VelocityAPIError, apiError.statusCode == 404 {
|
||
return "The backend returned Not Found for this calendar request. I refreshed the live schedule so stale items are cleared."
|
||
}
|
||
return error.localizedDescription
|
||
}
|
||
|
||
private func taskActionMessage(status: String) -> String {
|
||
switch status {
|
||
case "confirmed":
|
||
return "Task marked confirmed."
|
||
case "pending":
|
||
return "Task set to normal."
|
||
case "done":
|
||
return "Task marked done."
|
||
case "snoozed":
|
||
return "Task snoozed."
|
||
case "cancelled":
|
||
return "Task cancelled."
|
||
default:
|
||
return "Task updated."
|
||
}
|
||
}
|
||
|
||
private func showActionMessage(_ message: String) {
|
||
actionMessageDismissTask?.cancel()
|
||
actionMessage = message
|
||
actionMessageDismissTask = Task {
|
||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||
guard !Task.isCancelled else {
|
||
return
|
||
}
|
||
await MainActor.run {
|
||
actionMessage = nil
|
||
clearCancellationUndo()
|
||
actionMessageDismissTask = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
private func consumeRequestedCalendarFocus() {
|
||
guard let focus = store.requestedCalendarFocus else {
|
||
return
|
||
}
|
||
store.requestedCalendarFocus = nil
|
||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||
activeDashboardFocus = focus
|
||
if focus == .today {
|
||
selectedDay = Self.currentWeekdayName()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func calendarFocusLabel(_ focus: VelocityDashboardCalendarFocus) -> String {
|
||
switch focus {
|
||
case .today:
|
||
return "Today"
|
||
case .pendingTasks:
|
||
return "Pending tasks"
|
||
case .urgentTasks:
|
||
return "Urgent tasks"
|
||
}
|
||
}
|
||
|
||
private func calendarFocusDescription(_ focus: VelocityDashboardCalendarFocus) -> String {
|
||
switch focus {
|
||
case .today:
|
||
return "Showing today’s confirmed events and CRM reminders."
|
||
case .pendingTasks:
|
||
return "Showing actionable CRM reminders across the week."
|
||
case .urgentTasks:
|
||
return "Showing high-priority and urgent CRM reminders."
|
||
}
|
||
}
|
||
|
||
private func clearActionMessage(clearUndo: Bool = true) {
|
||
actionMessageDismissTask?.cancel()
|
||
actionMessageDismissTask = nil
|
||
actionMessage = nil
|
||
if clearUndo {
|
||
clearCancellationUndo()
|
||
}
|
||
}
|
||
|
||
private func undoCancellation() {
|
||
if let task = undoCancelledTask {
|
||
let restoreStatus = task.status.lowercased() == "cancelled" ? "pending" : task.status
|
||
mutateTask(
|
||
task,
|
||
status: restoreStatus,
|
||
dueAt: task.dueAt,
|
||
notes: "Cancellation undone from the iPad calendar."
|
||
)
|
||
return
|
||
}
|
||
|
||
if let event = undoCancelledEvent {
|
||
let restoreStatus = event.status.lowercased() == "cancelled" ? "tentative" : event.status
|
||
mutateEvent(event, status: restoreStatus, message: "Event restored.")
|
||
}
|
||
}
|
||
|
||
private func clearCancellationUndo() {
|
||
undoCancelledTask = nil
|
||
undoCancelledEvent = nil
|
||
}
|
||
|
||
private func iso8601Timestamp(_ date: Date) -> String {
|
||
ISO8601DateFormatter().string(from: date)
|
||
}
|
||
|
||
private func defaultEventStartDate(for weekdayName: String) -> Date {
|
||
let calendar = Calendar.current
|
||
let now = Date()
|
||
let todayIndex = calendar.component(.weekday, from: now)
|
||
let targetIndex = (visibleWeekdays.firstIndex(of: weekdayName) ?? (todayIndex - 1)) + 1
|
||
let dayDelta = targetIndex - todayIndex
|
||
let targetDate = calendar.date(byAdding: .day, value: dayDelta, to: now) ?? now
|
||
let startOfTargetDay = calendar.startOfDay(for: targetDate)
|
||
let nextHourToday = CalendarEventDraft.defaultStartDate()
|
||
|
||
if calendar.isDateInToday(startOfTargetDay) {
|
||
return nextHourToday
|
||
}
|
||
|
||
var components = DateComponents()
|
||
components.hour = 10
|
||
components.minute = 0
|
||
let tenAM = calendar.date(byAdding: components, to: startOfTargetDay) ?? startOfTargetDay.addingTimeInterval(10 * 60 * 60)
|
||
return tenAM > now ? tenAM : calendar.date(byAdding: .day, value: 7, to: tenAM) ?? tenAM
|
||
}
|
||
|
||
private func weekdayName(for date: Date) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "EEEE"
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
private func initials(for name: String) -> String {
|
||
let pieces = name
|
||
.split(separator: " ")
|
||
.prefix(2)
|
||
.compactMap { $0.first }
|
||
let initials = String(pieces).uppercased()
|
||
return initials.isEmpty ? "CL" : initials
|
||
}
|
||
|
||
private func menuIcon(_ systemName: String) -> some View {
|
||
Image(systemName: systemName)
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
.frame(width: 44, height: 44)
|
||
.contentShape(Rectangle())
|
||
}
|
||
|
||
private static func currentWeekdayName() -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "EEEE"
|
||
return formatter.string(from: Date())
|
||
}
|
||
}
|
||
|
||
private extension String {
|
||
var trimmedNonEmpty: String? {
|
||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return value.isEmpty ? nil : value
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
CalendarView()
|
||
}
|