Files
timetracker/ios/TimeTracker/TimeTracker/Features/Settings/ClientsListView.swift
Simon Franken ba4765b8a2 Rebuild iOS app: calendar entries, overtime dashboard, settings tab, full CRUD
- Replace 5-tab layout with 4 tabs: Dashboard, Timer, Entries, Settings
- Dashboard: add Work Time Balance section using /client-targets API, showing
  per-client weekly progress bar, overtime/undertime label and expandable week breakdown
- Time Entries: replace flat list with UICalendarView month grid; tap a day to see
  that day's entries; add filter sheet (date range, project, client); new
  TimeEntryDetailSheet for creating and editing entries; duration shown as Xh Ymin
- Settings tab: user info header, navigation to Clients and Projects, logout button
- ClientsListView: list with NavigationLink to ClientDetailView
- ClientDetailView: inline client editing + full work time target CRUD (create,
  edit, delete target; add/delete balance corrections with date, hours, description)
- ProjectsListView: grouped by client, NavigationLink to ProjectDetailView
- ProjectDetailView: edit name, description, colour, client assignment
- Add ClientTarget, WeekBalance, BalanceCorrection models and APIEndpoints for
  /client-targets routes
- Update TimeInterval formatter: add formattedShortDuration (Xh Ymin / Xmin / <1min)
  used throughout app; keep formattedDuration for live timer display
2026-02-21 13:51:41 +01:00

609 lines
22 KiB
Swift

import SwiftUI
// MARK: - Clients List
struct ClientsListView: View {
@State private var clients: [Client] = []
@State private var isLoading = false
@State private var error: String?
@State private var showAddClient = false
@State private var clientToDelete: Client?
@State private var showDeleteConfirmation = false
private let apiClient = APIClient()
var body: some View {
Group {
if isLoading && clients.isEmpty {
LoadingView()
} else if let err = error, clients.isEmpty {
ErrorView(message: err) { Task { await loadClients() } }
} else if clients.isEmpty {
EmptyView(icon: "person.2", title: "No Clients",
message: "Create a client to organise your projects.")
} else {
List {
ForEach(clients) { client in
NavigationLink {
ClientDetailView(client: client, onUpdate: { Task { await loadClients() } })
} label: {
ClientRow(client: client)
}
}
.onDelete { indexSet in
if let i = indexSet.first {
clientToDelete = clients[i]
showDeleteConfirmation = true
}
}
}
.refreshable { await loadClients() }
}
}
.navigationTitle("Clients")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button { showAddClient = true } label: { Image(systemName: "plus") }
}
}
.task { await loadClients() }
.sheet(isPresented: $showAddClient) {
ClientFormSheet(mode: .create) { Task { await loadClients() } }
}
.alert("Delete Client?", isPresented: $showDeleteConfirmation, presenting: clientToDelete) { client in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) { Task { await deleteClient(client) } }
} message: { client in
Text("Deleting '\(client.name)' will also delete all its projects and time entries. This cannot be undone.")
}
}
private func loadClients() async {
isLoading = true; error = nil
do {
clients = try await apiClient.request(endpoint: APIEndpoint.clients, authenticated: true)
} catch { self.error = error.localizedDescription }
isLoading = false
}
private func deleteClient(_ client: Client) async {
do {
try await apiClient.requestVoid(endpoint: APIEndpoint.client(id: client.id),
method: .delete, authenticated: true)
clients.removeAll { $0.id == client.id }
} catch { self.error = error.localizedDescription }
}
}
struct ClientRow: View {
let client: Client
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text(client.name).font(.headline)
if let desc = client.description {
Text(desc).font(.subheadline).foregroundStyle(.secondary).lineLimit(2)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Client Detail / Edit + Target Management
struct ClientDetailView: View {
let client: Client
let onUpdate: () -> Void
// Edit client fields
@State private var name: String
@State private var clientDescription: String
@State private var isSavingClient = false
@State private var clientSaveError: String?
@State private var clientSaveSuccess = false
// Client targets
@State private var target: ClientTarget?
@State private var isLoadingTarget = false
@State private var targetError: String?
// New target form
@State private var showNewTargetForm = false
@State private var newWeeklyHours = 40.0
@State private var newStartDate = Date().nextMonday()
@State private var isSavingTarget = false
// Edit target inline
@State private var editingWeeklyHours: Double?
@State private var editingStartDate: Date?
@State private var isEditingTarget = false
// Balance correction
@State private var showAddCorrection = false
@State private var correctionDate = Date()
@State private var correctionHours = 0.0
@State private var correctionDescription = ""
@State private var isSavingCorrection = false
@State private var correctionToDelete: BalanceCorrection?
@State private var showDeleteCorrection = false
private let apiClient = APIClient()
init(client: Client, onUpdate: @escaping () -> Void) {
self.client = client
self.onUpdate = onUpdate
_name = State(initialValue: client.name)
_clientDescription = State(initialValue: client.description ?? "")
}
var body: some View {
Form {
clientEditSection
targetSection
if let target { correctionsSection(target) }
}
.navigationTitle(client.name)
.navigationBarTitleDisplayMode(.inline)
.task { await loadTarget() }
.sheet(isPresented: $showAddCorrection) {
addCorrectionSheet
}
}
// MARK: - Client edit
private var clientEditSection: some View {
Section("Client Details") {
TextField("Name", text: $name)
TextField("Description (optional)", text: $clientDescription, axis: .vertical)
.lineLimit(2...4)
if let err = clientSaveError {
Text(err).font(.caption).foregroundStyle(.red)
}
if clientSaveSuccess {
Label("Saved", systemImage: "checkmark.circle").foregroundStyle(.green).font(.caption)
}
Button(isSavingClient ? "Saving…" : "Save Client Details") {
Task { await saveClient() }
}
.disabled(name.isEmpty || isSavingClient)
}
}
private func saveClient() async {
isSavingClient = true; clientSaveError = nil; clientSaveSuccess = false
do {
let input = UpdateClientInput(
name: name,
description: clientDescription.isEmpty ? nil : clientDescription
)
let _: Client = try await apiClient.request(
endpoint: APIEndpoint.client(id: client.id),
method: .put,
body: input,
authenticated: true
)
clientSaveSuccess = true
onUpdate()
} catch { clientSaveError = error.localizedDescription }
isSavingClient = false
}
// MARK: - Target section
private var targetSection: some View {
Section {
if isLoadingTarget {
HStack { Spacer(); ProgressView(); Spacer() }
} else if let err = targetError {
Text(err).font(.caption).foregroundStyle(.red)
} else if let target {
// Show existing target + balance
targetSummaryRows(target)
if isEditingTarget {
targetEditRows(target)
} else {
Button("Edit Target") { startEditingTarget(target) }
}
} else {
// No target yet
if showNewTargetForm {
newTargetFormRows
} else {
Button("Set Up Work Time Target") { showNewTargetForm = true }
}
}
} header: {
Text("Work Time Target")
}
}
private func targetSummaryRows(_ t: ClientTarget) -> some View {
Group {
HStack {
Text("Weekly hours")
Spacer()
Text("\(t.weeklyHours, specifier: "%.1f") h/week")
.foregroundStyle(.secondary)
}
HStack {
Text("Tracking since")
Spacer()
Text(formatDate(t.startDate))
.foregroundStyle(.secondary)
}
HStack {
Text("This week")
Spacer()
Text("\(TimeInterval(t.currentWeekTrackedSeconds).formattedShortDuration) / \(TimeInterval(t.currentWeekTargetSeconds).formattedShortDuration)")
.foregroundStyle(.secondary)
}
HStack {
Text("Total balance")
Spacer()
let balance = TimeInterval(abs(t.totalBalanceSeconds))
Text(t.totalBalanceSeconds >= 0 ? "+\(balance.formattedShortDuration)" : "-\(balance.formattedShortDuration)")
.fontWeight(.medium)
.foregroundStyle(t.totalBalanceSeconds >= 0 ? .green : .red)
}
}
}
private func targetEditRows(_ t: ClientTarget) -> some View {
Group {
HStack {
Text("Weekly hours")
Spacer()
TextField("Hours", value: Binding(
get: { editingWeeklyHours ?? t.weeklyHours },
set: { editingWeeklyHours = $0 }
), format: .number)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
}
DatePicker("Start date (Monday)",
selection: Binding(
get: { editingStartDate ?? parseDate(t.startDate) ?? Date() },
set: { editingStartDate = $0 }
),
displayedComponents: .date)
HStack {
Button("Cancel") { isEditingTarget = false; editingWeeklyHours = nil; editingStartDate = nil }
.foregroundStyle(.secondary)
Spacer()
Button(isSavingTarget ? "Saving…" : "Save Target") {
Task { await saveTarget(existingId: t.id) }
}
.disabled(isSavingTarget)
}
}
}
private var newTargetFormRows: some View {
Group {
HStack {
Text("Weekly hours")
Spacer()
TextField("Hours", value: $newWeeklyHours, format: .number)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
}
DatePicker("Start date (Monday)", selection: $newStartDate, displayedComponents: .date)
HStack {
Button("Cancel") { showNewTargetForm = false }
.foregroundStyle(.secondary)
Spacer()
Button(isSavingTarget ? "Saving…" : "Create Target") {
Task { await createTarget() }
}
.disabled(newWeeklyHours <= 0 || isSavingTarget)
}
}
}
private func startEditingTarget(_ t: ClientTarget) {
editingWeeklyHours = t.weeklyHours
editingStartDate = parseDate(t.startDate)
isEditingTarget = true
}
// MARK: - Corrections section
private func correctionsSection(_ t: ClientTarget) -> some View {
Section {
if t.corrections.isEmpty {
Text("No corrections")
.foregroundStyle(.secondary)
.font(.subheadline)
} else {
ForEach(t.corrections) { correction in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(formatDate(correction.date))
.font(.subheadline)
if let desc = correction.description {
Text(desc).font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
Text(correction.hours >= 0 ? "+\(correction.hours, specifier: "%.1f")h" : "\(correction.hours, specifier: "%.1f")h")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(correction.hours >= 0 ? .green : .red)
}
}
.onDelete { indexSet in
if let i = indexSet.first {
correctionToDelete = t.corrections[i]
showDeleteCorrection = true
}
}
}
Button("Add Correction") { showAddCorrection = true }
} header: {
Text("Balance Corrections")
}
.alert("Delete Correction?", isPresented: $showDeleteCorrection, presenting: correctionToDelete) { correction in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task { await deleteCorrection(correction) }
}
} message: { correction in
Text("Remove the \(correction.hours >= 0 ? "+" : "")\(correction.hours, specifier: "%.1f")h correction on \(formatDate(correction.date))?")
}
}
// MARK: - Add correction sheet
private var addCorrectionSheet: some View {
NavigationStack {
Form {
Section("Date") {
DatePicker("Date", selection: $correctionDate, displayedComponents: .date)
}
Section("Hours adjustment") {
HStack {
TextField("Hours (positive = bonus, negative = penalty)",
value: $correctionHours, format: .number)
.keyboardType(.numbersAndPunctuation)
Text("h").foregroundStyle(.secondary)
}
Text("Positive values reduce the weekly target; negative values increase it.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Description (optional)") {
TextField("Note", text: $correctionDescription)
}
}
.navigationTitle("Add Correction")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showAddCorrection = false }
}
ToolbarItem(placement: .confirmationAction) {
Button(isSavingCorrection ? "Saving…" : "Add") {
Task { await addCorrection() }
}
.disabled(correctionHours == 0 || isSavingCorrection)
}
}
}
}
// MARK: - API calls
private func loadTarget() async {
isLoadingTarget = true; targetError = nil
do {
let allTargets: [ClientTarget] = try await apiClient.request(
endpoint: APIEndpoint.clientTargets, authenticated: true
)
target = allTargets.first { $0.clientId == client.id }
} catch { targetError = error.localizedDescription }
isLoadingTarget = false
}
private func createTarget() async {
isSavingTarget = true
do {
let input = CreateClientTargetInput(
clientId: client.id,
weeklyHours: newWeeklyHours,
startDate: newStartDate.iso8601FullDate
)
let created: ClientTarget = try await apiClient.request(
endpoint: APIEndpoint.clientTargets,
method: .post,
body: input,
authenticated: true
)
target = created
showNewTargetForm = false
} catch { targetError = error.localizedDescription }
isSavingTarget = false
}
private func saveTarget(existingId: String) async {
isSavingTarget = true
do {
let input = UpdateClientTargetInput(
weeklyHours: editingWeeklyHours,
startDate: editingStartDate?.iso8601FullDate
)
let _: ClientTarget = try await apiClient.request(
endpoint: APIEndpoint.clientTarget(id: existingId),
method: .put,
body: input,
authenticated: true
)
isEditingTarget = false
editingWeeklyHours = nil
editingStartDate = nil
await loadTarget() // reload to get fresh balance
} catch { targetError = error.localizedDescription }
isSavingTarget = false
}
private func addCorrection() async {
guard let t = target else { return }
isSavingCorrection = true
do {
let input = CreateBalanceCorrectionInput(
date: correctionDate.iso8601FullDate,
hours: correctionHours,
description: correctionDescription.isEmpty ? nil : correctionDescription
)
try await apiClient.requestVoid(
endpoint: APIEndpoint.clientTargetCorrections(targetId: t.id),
method: .post,
body: input,
authenticated: true
)
correctionHours = 0
correctionDescription = ""
showAddCorrection = false
await loadTarget()
} catch { targetError = error.localizedDescription }
isSavingCorrection = false
}
private func deleteCorrection(_ correction: BalanceCorrection) async {
guard let t = target else { return }
do {
try await apiClient.requestVoid(
endpoint: APIEndpoint.clientTargetCorrection(targetId: t.id, correctionId: correction.id),
method: .delete,
authenticated: true
)
await loadTarget()
} catch { targetError = error.localizedDescription }
}
// MARK: - Helpers
private func formatDate(_ string: String) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
guard let d = f.date(from: string) else { return string }
let out = DateFormatter()
out.dateStyle = .medium
return out.string(from: d)
}
private func parseDate(_ string: String) -> Date? {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f.date(from: string)
}
}
// MARK: - Client Form Sheet (create / edit)
struct ClientFormSheet: View {
enum Mode {
case create
case edit(Client)
}
@Environment(\.dismiss) private var dismiss
let mode: Mode
let onSave: () -> Void
@State private var name = ""
@State private var description = ""
@State private var isSaving = false
@State private var error: String?
private let apiClient = APIClient()
init(mode: Mode, onSave: @escaping () -> Void) {
self.mode = mode
self.onSave = onSave
if case .edit(let client) = mode {
_name = State(initialValue: client.name)
_description = State(initialValue: client.description ?? "")
}
}
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Client name", text: $name)
}
Section("Description (optional)") {
TextField("Description", text: $description, axis: .vertical)
.lineLimit(3...6)
}
if let error {
Section { Text(error).font(.caption).foregroundStyle(.red) }
}
}
.navigationTitle(isEditing ? "Edit Client" : "New Client")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(isSaving ? "Saving…" : "Save") {
Task { await save() }
}
.disabled(name.isEmpty || isSaving)
}
}
}
}
private var isEditing: Bool {
if case .edit = mode { return true }
return false
}
private func save() async {
isSaving = true; error = nil
do {
switch mode {
case .create:
let input = CreateClientInput(name: name, description: description.isEmpty ? nil : description)
let _: Client = try await apiClient.request(
endpoint: APIEndpoint.clients, method: .post, body: input, authenticated: true
)
case .edit(let client):
let input = UpdateClientInput(name: name, description: description.isEmpty ? nil : description)
let _: Client = try await apiClient.request(
endpoint: APIEndpoint.client(id: client.id), method: .put, body: input, authenticated: true
)
}
isSaving = false
dismiss()
onSave()
} catch {
isSaving = false
self.error = error.localizedDescription
}
}
}
// MARK: - Date extension
private extension Date {
func nextMonday() -> Date {
let cal = Calendar.current
var comps = DateComponents()
comps.weekday = 2 // Monday
return cal.nextDate(after: self, matching: comps, matchingPolicy: .nextTime) ?? self
}
}