From 41a72996fed649cc20673667131817b02387bae5 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sun, 10 May 2026 10:24:39 +0200 Subject: [PATCH 1/4] Add native macOS SwiftUI app with embedded Python backend Introduces TuttleMac, a native macOS app built with SwiftUI and SPM that reuses the existing Python business logic via PythonKit. Implements Dashboard, Timeline, Projects, Contracts, Clients, Contacts, and Invoicing views with a native look and feel. Adds a Flet-free bridge.py serialization layer and fixes the Flet import chain so core intents can be used without Flet installed. Co-authored-by: Cursor --- .gitignore | 4 + TuttleMac/Makefile | 13 + TuttleMac/Package.resolved | 15 + TuttleMac/Package.swift | 20 + TuttleMac/Sources/TuttleMac/ContentView.swift | 47 ++ .../Sources/TuttleMac/Models/AppModels.swift | 643 ++++++++++++++++++ .../TuttleMac/Python/PythonBridge.swift | 168 +++++ .../Sources/TuttleMac/TuttleMacApp.swift | 20 + .../ViewModels/BusinessViewModel.swift | 121 ++++ .../ViewModels/DashboardViewModel.swift | 97 +++ .../ViewModels/InvoicingViewModel.swift | 59 ++ .../ViewModels/TimelineViewModel.swift | 74 ++ .../Views/Business/ClientsView.swift | 181 +++++ .../Views/Business/ContactsView.swift | 174 +++++ .../Views/Business/ContractsView.swift | 198 ++++++ .../Views/Business/ProjectsView.swift | 199 ++++++ .../Views/Business/SharedComponents.swift | 142 ++++ .../Views/Dashboard/DashboardView.swift | 344 ++++++++++ .../Views/Invoicing/InvoicingView.swift | 407 +++++++++++ .../Sources/TuttleMac/Views/Sidebar.swift | 20 + .../Views/Timeline/TimelineView.swift | 270 ++++++++ tuttle/__init__.py | 6 +- tuttle/app/core/abstractions.py | 33 +- tuttle/app/core/formatting.py | 16 + tuttle/app/core/utils.py | 15 +- tuttle/app/timeline/intent.py | 37 +- tuttle/bridge.py | 463 +++++++++++++ 27 files changed, 3751 insertions(+), 35 deletions(-) create mode 100644 TuttleMac/Makefile create mode 100644 TuttleMac/Package.resolved create mode 100644 TuttleMac/Package.swift create mode 100644 TuttleMac/Sources/TuttleMac/ContentView.swift create mode 100644 TuttleMac/Sources/TuttleMac/Models/AppModels.swift create mode 100644 TuttleMac/Sources/TuttleMac/Python/PythonBridge.swift create mode 100644 TuttleMac/Sources/TuttleMac/TuttleMacApp.swift create mode 100644 TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift create mode 100644 TuttleMac/Sources/TuttleMac/ViewModels/DashboardViewModel.swift create mode 100644 TuttleMac/Sources/TuttleMac/ViewModels/InvoicingViewModel.swift create mode 100644 TuttleMac/Sources/TuttleMac/ViewModels/TimelineViewModel.swift create mode 100644 TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift create mode 100644 TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift create mode 100644 TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift create mode 100644 TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift create mode 100644 TuttleMac/Sources/TuttleMac/Views/Business/SharedComponents.swift create mode 100644 TuttleMac/Sources/TuttleMac/Views/Dashboard/DashboardView.swift create mode 100644 TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift create mode 100644 TuttleMac/Sources/TuttleMac/Views/Sidebar.swift create mode 100644 TuttleMac/Sources/TuttleMac/Views/Timeline/TimelineView.swift create mode 100644 tuttle/app/core/formatting.py create mode 100644 tuttle/bridge.py diff --git a/.gitignore b/.gitignore index 2fd0f6d9..b8c4df39 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,7 @@ app/assets/uploads # visual studio workspaces *.code-workspace assets/uploads/* + +# Swift Package Manager +TuttleMac/.build/ +TuttleMac/.swiftpm/ diff --git a/TuttleMac/Makefile b/TuttleMac/Makefile new file mode 100644 index 00000000..619bede4 --- /dev/null +++ b/TuttleMac/Makefile @@ -0,0 +1,13 @@ +PROJECT_ROOT := $(shell cd .. && pwd) +BINARY := .build/debug/TuttleMac + +.PHONY: build run clean + +build: + swift build + +run: build + cd $(PROJECT_ROOT) && TuttleMac/$(BINARY) + +clean: + swift package clean diff --git a/TuttleMac/Package.resolved b/TuttleMac/Package.resolved new file mode 100644 index 00000000..6f2a89f4 --- /dev/null +++ b/TuttleMac/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "7574d3538ebb1eb24e651c3a42ae0fd2a6acde374439c30d7c015925102bc37e", + "pins" : [ + { + "identity" : "pythonkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pvieito/PythonKit.git", + "state" : { + "branch" : "master", + "revision" : "d030b5193d1e4e770156deb27865773b64c5695f" + } + } + ], + "version" : 3 +} diff --git a/TuttleMac/Package.swift b/TuttleMac/Package.swift new file mode 100644 index 00000000..d20bfb14 --- /dev/null +++ b/TuttleMac/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "TuttleMac", + platforms: [.macOS(.v14)], + dependencies: [ + .package(url: "https://github.com/pvieito/PythonKit.git", branch: "master"), + ], + targets: [ + .executableTarget( + name: "TuttleMac", + dependencies: ["PythonKit"], + path: "Sources/TuttleMac", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency=minimal"), + ] + ), + ] +) diff --git a/TuttleMac/Sources/TuttleMac/ContentView.swift b/TuttleMac/Sources/TuttleMac/ContentView.swift new file mode 100644 index 00000000..fcdac9b4 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/ContentView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct ContentView: View { + @State private var selectedItem: SidebarItem? = .dashboard + + var body: some View { + NavigationSplitView { + Sidebar(selection: $selectedItem) + .frame(minWidth: 180) + } detail: { + detailView + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private var detailView: some View { + switch selectedItem { + case .dashboard: + DashboardView() + case .timeline: + TimelineView() + case .projects: + ProjectsView() + case .contracts: + ContractsView() + case .clients: + ClientsView() + case .contacts: + ContactsView() + case .invoicing: + InvoicingView() + case .none: + ContentUnavailableView( + "Select an Item", + systemImage: "sidebar.left", + description: Text("Choose a section from the sidebar.") + ) + default: + ContentUnavailableView( + selectedItem?.rawValue ?? "Coming Soon", + systemImage: selectedItem?.systemImage ?? "hammer", + description: Text("This section is not yet implemented.") + ) + } + } +} diff --git a/TuttleMac/Sources/TuttleMac/Models/AppModels.swift b/TuttleMac/Sources/TuttleMac/Models/AppModels.swift new file mode 100644 index 00000000..0382f1e1 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Models/AppModels.swift @@ -0,0 +1,643 @@ +import Foundation +import SwiftUI +import PythonKit + +// MARK: - KPI Summary + +struct KPISummary { + let totalRevenueYTD: Double + let totalRevenueYTDFormatted: String + let outstandingAmount: Double + let outstandingAmountFormatted: String + let overdueAmount: Double + let overdueAmountFormatted: String + let effectiveHourlyRate: Double? + let effectiveHourlyRateFormatted: String + let utilizationRate: Double? + let utilizationRateFormatted: String + let activeProjects: Int + let activeContracts: Int + let unpaidInvoices: Int + let vatReserve: Double + let vatReserveFormatted: String + let incomeTaxReserve: Double + let incomeTaxReserveFormatted: String + let spendableIncome: Double + let spendableIncomeFormatted: String + let taxCurrency: String + + static func from(_ d: PythonObject) -> KPISummary { + KPISummary( + totalRevenueYTD: PythonBridge.double(d, key: "total_revenue_ytd"), + totalRevenueYTDFormatted: PythonBridge.string(d, key: "total_revenue_ytd_fmt"), + outstandingAmount: PythonBridge.double(d, key: "outstanding_amount"), + outstandingAmountFormatted: PythonBridge.string(d, key: "outstanding_amount_fmt"), + overdueAmount: PythonBridge.double(d, key: "overdue_amount"), + overdueAmountFormatted: PythonBridge.string(d, key: "overdue_amount_fmt"), + effectiveHourlyRate: { + let v = d["effective_hourly_rate"] + return v == Python.None ? nil : Double(v) + }(), + effectiveHourlyRateFormatted: PythonBridge.string(d, key: "effective_hourly_rate_fmt"), + utilizationRate: { + let v = d["utilization_rate"] + return v == Python.None ? nil : Double(v) + }(), + utilizationRateFormatted: PythonBridge.string(d, key: "utilization_rate_fmt"), + activeProjects: PythonBridge.int(d, key: "active_projects"), + activeContracts: PythonBridge.int(d, key: "active_contracts"), + unpaidInvoices: PythonBridge.int(d, key: "unpaid_invoices"), + vatReserve: PythonBridge.double(d, key: "vat_reserve"), + vatReserveFormatted: PythonBridge.string(d, key: "vat_reserve_fmt"), + incomeTaxReserve: PythonBridge.double(d, key: "income_tax_reserve"), + incomeTaxReserveFormatted: PythonBridge.string(d, key: "income_tax_reserve_fmt"), + spendableIncome: PythonBridge.double(d, key: "spendable_income"), + spendableIncomeFormatted: PythonBridge.string(d, key: "spendable_income_fmt"), + taxCurrency: PythonBridge.string(d, key: "tax_currency", fallback: "EUR") + ) + } +} + +// MARK: - Monthly Chart Data + +struct MonthlyDataPoint: Identifiable { + let id = UUID() + let month: String + let label: String // short label like "05/25" + let value: Double +} + +// MARK: - Project Budget + +struct ProjectBudget: Identifiable { + let id = UUID() + let project: String + let hoursTracked: Double + let hoursBudget: Double + let progress: Double + + static func from(_ d: PythonObject) -> ProjectBudget { + ProjectBudget( + project: PythonBridge.string(d, key: "project", fallback: "Project"), + hoursTracked: PythonBridge.double(d, key: "hours_tracked"), + hoursBudget: PythonBridge.double(d, key: "hours_budget"), + progress: PythonBridge.double(d, key: "progress") + ) + } +} + +// MARK: - Financial Goal + +struct FinancialGoalModel: Identifiable { + let id: Int + let title: String + let targetAmount: Double + let targetAmountFormatted: String + let targetDate: String + let targetDateFormatted: String + let isReached: Bool + let progress: Double + let ytdRevenueFormatted: String + + static func from(_ d: PythonObject) -> FinancialGoalModel { + FinancialGoalModel( + id: PythonBridge.int(d, key: "id"), + title: PythonBridge.string(d, key: "title"), + targetAmount: PythonBridge.double(d, key: "target_amount"), + targetAmountFormatted: PythonBridge.string(d, key: "target_amount_fmt"), + targetDate: PythonBridge.string(d, key: "target_date"), + targetDateFormatted: PythonBridge.string(d, key: "target_date_fmt"), + isReached: PythonBridge.bool(d, key: "is_reached"), + progress: PythonBridge.double(d, key: "progress"), + ytdRevenueFormatted: PythonBridge.string(d, key: "ytd_revenue_fmt") + ) + } +} + +// MARK: - Timeline Event + +enum TimelineCategory: String, CaseIterable, Identifiable { + case all = "all" + case invoice = "invoice" + case contract = "contract" + case project = "project" + case goal = "goal" + + var id: String { rawValue } + + var label: String { + switch self { + case .all: "All" + case .invoice: "Invoices" + case .contract: "Contracts" + case .project: "Projects" + case .goal: "Goals" + } + } + + var systemImage: String { + switch self { + case .all: "list.bullet" + case .invoice: "doc.text" + case .contract: "signature" + case .project: "folder" + case .goal: "flag" + } + } +} + +struct TimelineEvent: Identifiable { + let id = UUID() + let date: Date + let dateFormatted: String + let title: String + let description: String + let category: TimelineCategory + let status: String + let isFuture: Bool + let entityId: Int? + + var monthKey: String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + return formatter.string(from: date) + } + + var monthLabel: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM yyyy" + return formatter.string(from: date) + } + + static func from(_ d: PythonObject) -> TimelineEvent? { + let dateStr = PythonBridge.string(d, key: "date") + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + guard let date = formatter.date(from: dateStr) else { return nil } + + let catStr = PythonBridge.string(d, key: "category") + let category = TimelineCategory(rawValue: catStr) ?? .invoice + + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "MMM d, yyyy" + + return TimelineEvent( + date: date, + dateFormatted: displayFormatter.string(from: date), + title: PythonBridge.string(d, key: "title"), + description: PythonBridge.string(d, key: "description", fallback: ""), + category: category, + status: PythonBridge.string(d, key: "status", fallback: "default"), + isFuture: PythonBridge.bool(d, key: "is_future"), + entityId: { + let v = d["entity_id"] + return v == Python.None ? nil : Int(v) + }() + ) + } +} + +// MARK: - Contact + +struct ContactModel: Identifiable { + let id: Int + let firstName: String + let lastName: String + let company: String + let email: String + let city: String + let country: String + + var fullName: String { + [firstName, lastName].filter { !$0.isEmpty }.joined(separator: " ") + } + + var displayName: String { + let name = fullName + if !name.isEmpty { return name } + if !company.isEmpty { return company } + return "—" + } + + var location: String { + [city, country].filter { !$0.isEmpty }.joined(separator: ", ") + } + + var initials: String { + let parts = [firstName, lastName].filter { !$0.isEmpty } + if parts.isEmpty { return "?" } + return parts.map { String($0.prefix(1)).uppercased() }.joined() + } + + static func from(_ d: PythonObject) -> ContactModel { + ContactModel( + id: PythonBridge.int(d, key: "id"), + firstName: PythonBridge.string(d, key: "first_name", fallback: ""), + lastName: PythonBridge.string(d, key: "last_name", fallback: ""), + company: PythonBridge.string(d, key: "company", fallback: ""), + email: PythonBridge.string(d, key: "email", fallback: ""), + city: PythonBridge.string(d, key: "city", fallback: ""), + country: PythonBridge.string(d, key: "country", fallback: "") + ) + } +} + +// MARK: - Client + +struct ClientModel: Identifiable { + let id: Int + let name: String + let contactName: String + let contactEmail: String + let contactCompany: String + let location: String + let numContracts: Int + + var initials: String { + let words = name.split(separator: " ") + if words.isEmpty { return "?" } + return words.prefix(2).map { String($0.prefix(1)).uppercased() }.joined() + } + + static func from(_ d: PythonObject) -> ClientModel { + let city = PythonBridge.string(d, key: "contact_city", fallback: "") + let country = PythonBridge.string(d, key: "contact_country", fallback: "") + let loc = [city, country].filter { !$0.isEmpty }.joined(separator: ", ") + return ClientModel( + id: PythonBridge.int(d, key: "id"), + name: PythonBridge.string(d, key: "name"), + contactName: PythonBridge.string(d, key: "contact_name", fallback: ""), + contactEmail: PythonBridge.string(d, key: "contact_email", fallback: ""), + contactCompany: PythonBridge.string(d, key: "contact_company", fallback: ""), + location: loc, + numContracts: PythonBridge.int(d, key: "num_contracts") + ) + } +} + +// MARK: - Contract + +enum EntityStatus: String { + case active = "Active" + case upcoming = "Upcoming" + case completed = "Completed" + case all = "All" + + var color: Color { + switch self { + case .active: .green + case .upcoming: .blue + case .completed: .secondary + case .all: .primary + } + } + + var icon: String { + switch self { + case .active: "circle.fill" + case .upcoming: "clock" + case .completed: "checkmark.circle.fill" + case .all: "list.bullet" + } + } +} + +struct ContractModel: Identifiable { + let id: Int + let title: String + let clientName: String + let status: EntityStatus + let startDate: String + let endDate: String? + let rate: Double + let rateFormatted: String + let currency: String + let unit: String + let volume: Int? + let billingCycle: String + let isCompleted: Bool + let vatRate: Double + let numProjects: Int + let numInvoices: Int + + var dateRange: String { + if let end = endDate { + return "\(Self.formatDate(startDate)) – \(Self.formatDate(end))" + } + return "From \(Self.formatDate(startDate))" + } + + private static func formatDate(_ iso: String) -> String { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + guard let d = fmt.date(from: iso) else { return iso } + fmt.dateFormat = "MMM d, yyyy" + return fmt.string(from: d) + } + + static func from(_ d: PythonObject) -> ContractModel { + let statusStr = PythonBridge.string(d, key: "status", fallback: "All") + let status = EntityStatus(rawValue: statusStr) ?? .all + let endVal = d.checking["end_date"] + let endDate: String? = (endVal != nil && endVal != Python.None) + ? String(endVal!) : nil + return ContractModel( + id: PythonBridge.int(d, key: "id"), + title: PythonBridge.string(d, key: "title"), + clientName: PythonBridge.string(d, key: "client_name"), + status: status, + startDate: PythonBridge.string(d, key: "start_date"), + endDate: endDate, + rate: PythonBridge.double(d, key: "rate"), + rateFormatted: PythonBridge.string(d, key: "rate_fmt"), + currency: PythonBridge.string(d, key: "currency", fallback: "EUR"), + unit: PythonBridge.string(d, key: "unit", fallback: "hour"), + volume: { + guard let v = d.checking["volume"], v != Python.None else { return nil } + return Int(v) + }(), + billingCycle: PythonBridge.string(d, key: "billing_cycle", fallback: ""), + isCompleted: PythonBridge.bool(d, key: "is_completed"), + vatRate: PythonBridge.double(d, key: "vat_rate"), + numProjects: PythonBridge.int(d, key: "num_projects"), + numInvoices: PythonBridge.int(d, key: "num_invoices") + ) + } +} + +// MARK: - Project + +struct ProjectModel: Identifiable { + let id: Int + let title: String + let tag: String + let description: String + let clientName: String + let contractTitle: String + let status: EntityStatus + let startDate: String + let endDate: String? + let isCompleted: Bool + let numInvoices: Int + let numTimesheets: Int + + var dateRange: String { + if let end = endDate { + return "\(Self.formatDate(startDate)) – \(Self.formatDate(end))" + } + return "From \(Self.formatDate(startDate))" + } + + private static func formatDate(_ iso: String) -> String { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + guard let d = fmt.date(from: iso) else { return iso } + fmt.dateFormat = "MMM d, yyyy" + return fmt.string(from: d) + } + + static func from(_ d: PythonObject) -> ProjectModel { + let statusStr = PythonBridge.string(d, key: "status", fallback: "") + let status = EntityStatus(rawValue: statusStr) ?? .all + let endVal = d.checking["end_date"] + let endDate: String? = (endVal != nil && endVal != Python.None) + ? String(endVal!) : nil + return ProjectModel( + id: PythonBridge.int(d, key: "id"), + title: PythonBridge.string(d, key: "title"), + tag: PythonBridge.string(d, key: "tag", fallback: ""), + description: PythonBridge.string(d, key: "description", fallback: ""), + clientName: PythonBridge.string(d, key: "client_name", fallback: ""), + contractTitle: PythonBridge.string(d, key: "contract_title", fallback: ""), + status: status, + startDate: PythonBridge.string(d, key: "start_date"), + endDate: endDate, + isCompleted: PythonBridge.bool(d, key: "is_completed"), + numInvoices: PythonBridge.int(d, key: "num_invoices"), + numTimesheets: PythonBridge.int(d, key: "num_timesheets") + ) + } +} + +// MARK: - Invoice Status + +enum InvoiceStatus: String, CaseIterable { + case all = "All" + case draft = "Draft" + case sent = "Sent" + case paid = "Paid" + case overdue = "Overdue" + case cancelled = "Cancelled" + + var color: Color { + switch self { + case .all: .primary + case .draft: .secondary + case .sent: .blue + case .paid: .green + case .overdue: .red + case .cancelled: .orange + } + } + + var icon: String { + switch self { + case .all: "list.bullet" + case .draft: "doc" + case .sent: "paperplane" + case .paid: "checkmark.circle.fill" + case .overdue: "exclamationmark.triangle" + case .cancelled: "xmark.circle" + } + } +} + +// MARK: - Invoice Item + +struct InvoiceItemModel: Identifiable { + let id: Int + let description: String + let quantity: Double + let unit: String + let unitPrice: Double + let unitPriceFormatted: String + let vatRate: Double + let subtotal: Double + let subtotalFormatted: String + let startDate: String + let endDate: String? + + var vatPercent: String { + String(format: "%.0f%%", vatRate * 100) + } + + var dateRange: String { + if let end = endDate { + return "\(Self.fmtDate(startDate)) – \(Self.fmtDate(end))" + } + return Self.fmtDate(startDate) + } + + private static func fmtDate(_ iso: String) -> String { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + guard let d = fmt.date(from: iso) else { return iso } + fmt.dateFormat = "MMM d, yyyy" + return fmt.string(from: d) + } + + static func from(_ d: PythonObject) -> InvoiceItemModel { + let endVal = d.checking["end_date"] + let endDate: String? = (endVal != nil && endVal != Python.None) + ? String(endVal!) : nil + return InvoiceItemModel( + id: PythonBridge.int(d, key: "id"), + description: PythonBridge.string(d, key: "description"), + quantity: PythonBridge.double(d, key: "quantity"), + unit: PythonBridge.string(d, key: "unit", fallback: "hour"), + unitPrice: PythonBridge.double(d, key: "unit_price"), + unitPriceFormatted: PythonBridge.string(d, key: "unit_price_fmt"), + vatRate: PythonBridge.double(d, key: "vat_rate"), + subtotal: PythonBridge.double(d, key: "subtotal"), + subtotalFormatted: PythonBridge.string(d, key: "subtotal_fmt"), + startDate: PythonBridge.string(d, key: "start_date"), + endDate: endDate + ) + } +} + +// MARK: - Invoice + +struct InvoiceModel: Identifiable { + let id: Int + let number: String + let date: String + let clientName: String + let projectTitle: String + let contractTitle: String + let currency: String + let subtotal: Double + let subtotalFormatted: String + let vatTotal: Double + let vatTotalFormatted: String + let total: Double + let totalFormatted: String + let status: InvoiceStatus + let sent: Bool + let paid: Bool + let cancelled: Bool + let rendered: Bool + let dueDate: String? + let items: [InvoiceItemModel] + + var dateFormatted: String { Self.fmtDate(date) } + + var dueDateFormatted: String? { + guard let d = dueDate else { return nil } + return Self.fmtDate(d) + } + + private static func fmtDate(_ iso: String) -> String { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + guard let d = fmt.date(from: iso) else { return iso } + fmt.dateFormat = "MMM d, yyyy" + return fmt.string(from: d) + } + + static func from(_ d: PythonObject) -> InvoiceModel { + let statusStr = PythonBridge.string(d, key: "status", fallback: "draft") + let status = InvoiceStatus(rawValue: statusStr.capitalized) ?? .draft + + let dueDateVal = d.checking["due_date"] + let dueDate: String? = (dueDateVal != nil && dueDateVal != Python.None) + ? String(dueDateVal!) : nil + + var items: [InvoiceItemModel] = [] + if let itemsList = d.checking["items"], itemsList != Python.None { + for item in itemsList { + items.append(InvoiceItemModel.from(item)) + } + } + + return InvoiceModel( + id: PythonBridge.int(d, key: "id"), + number: PythonBridge.string(d, key: "number"), + date: PythonBridge.string(d, key: "date"), + clientName: PythonBridge.string(d, key: "client_name"), + projectTitle: PythonBridge.string(d, key: "project_title"), + contractTitle: PythonBridge.string(d, key: "contract_title"), + currency: PythonBridge.string(d, key: "currency", fallback: "EUR"), + subtotal: PythonBridge.double(d, key: "subtotal"), + subtotalFormatted: PythonBridge.string(d, key: "subtotal_fmt"), + vatTotal: PythonBridge.double(d, key: "vat_total"), + vatTotalFormatted: PythonBridge.string(d, key: "vat_total_fmt"), + total: PythonBridge.double(d, key: "total"), + totalFormatted: PythonBridge.string(d, key: "total_fmt"), + status: status, + sent: PythonBridge.bool(d, key: "sent"), + paid: PythonBridge.bool(d, key: "paid"), + cancelled: PythonBridge.bool(d, key: "cancelled"), + rendered: PythonBridge.bool(d, key: "rendered"), + dueDate: dueDate, + items: items + ) + } +} + +extension InvoiceModel: Hashable { + static func == (lhs: InvoiceModel, rhs: InvoiceModel) -> Bool { lhs.id == rhs.id } + func hash(into hasher: inout Hasher) { hasher.combine(id) } +} + +// MARK: - Sidebar + +enum SidebarItem: String, CaseIterable, Identifiable { + case dashboard = "Dashboard" + case timeline = "Timeline" + case taxReserves = "Tax & Reserves" + case salary = "Salary" + case projects = "Projects" + case contracts = "Contracts" + case clients = "Clients" + case contacts = "Contacts" + case timeTracking = "Time Tracking" + case invoicing = "Invoicing" + + var id: String { rawValue } + + var systemImage: String { + switch self { + case .dashboard: "square.grid.2x2" + case .timeline: "calendar.day.timeline.left" + case .taxReserves: "chart.pie" + case .salary: "banknote" + case .projects: "folder" + case .contracts: "signature" + case .clients: "building.2" + case .contacts: "person.2" + case .timeTracking: "clock" + case .invoicing: "doc.text" + } + } + + enum Section: String, CaseIterable { + case insights = "Insights" + case business = "My Business" + case workflows = "Workflows" + } + + var section: Section { + switch self { + case .dashboard, .timeline, .taxReserves, .salary: .insights + case .projects, .contracts, .clients, .contacts: .business + case .timeTracking, .invoicing: .workflows + } + } + + static func grouped() -> [(Section, [SidebarItem])] { + Section.allCases.map { section in + (section, SidebarItem.allCases.filter { $0.section == section }) + } + } +} diff --git a/TuttleMac/Sources/TuttleMac/Python/PythonBridge.swift b/TuttleMac/Sources/TuttleMac/Python/PythonBridge.swift new file mode 100644 index 00000000..f075abce --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Python/PythonBridge.swift @@ -0,0 +1,168 @@ +import Foundation +import PythonKit + +/// Manages the embedded Python interpreter and provides access to the tuttle bridge module. +/// All Python calls happen on a single dedicated thread to satisfy CPython's GIL requirements. +final class PythonBridge { + static let shared = PythonBridge() + + private var _bridge: PythonObject! + private var _runLoop: CFRunLoop! + private let _thread: Thread + + private init() { + let readySem = DispatchSemaphore(value: 0) + let initSem = DispatchSemaphore(value: 0) + + var capturedRunLoop: CFRunLoop! + + _thread = Thread { + capturedRunLoop = CFRunLoopGetCurrent() + // A run loop needs at least one source to stay alive + let keepAlive = NSMachPort() + RunLoop.current.add(keepAlive, forMode: .default) + readySem.signal() + CFRunLoopRun() + } + _thread.qualityOfService = .userInitiated + _thread.name = "dev.tuttle.python" + _thread.start() + + readySem.wait() + _runLoop = capturedRunLoop + + // Initialize Python on the dedicated thread + var bridge: PythonObject! + CFRunLoopPerformBlock(_runLoop, CFRunLoopMode.defaultMode.rawValue) { + let projectRoot = PythonBridge.findProjectRoot() + PythonBridge.configurePythonEnvironment(projectRoot: projectRoot) + let sys = Python.import("sys") + sys.path.insert(0, projectRoot) + bridge = Python.import("tuttle.bridge").TuttleBridge() + initSem.signal() + } + CFRunLoopWakeUp(_runLoop) + initSem.wait() + _bridge = bridge + } + + /// Execute `work` on the dedicated Python thread, then deliver the result on the main thread. + /// The `work` closure MUST convert all PythonObjects to Swift types before returning -- + /// the returned value must not contain any PythonObject references. + func run(_ work: @escaping (PythonObject) -> T, completion: @escaping (T) -> Void) { + let bridge = _bridge! + CFRunLoopPerformBlock(_runLoop, CFRunLoopMode.defaultMode.rawValue) { + let result = work(bridge) + DispatchQueue.main.async { + completion(result) + } + } + CFRunLoopWakeUp(_runLoop) + } + + // MARK: - Python Environment Configuration + + private static func configurePythonEnvironment(projectRoot: String) { + let venvPath = projectRoot + "/.venv" + let fm = FileManager.default + + let cfgPath = venvPath + "/pyvenv.cfg" + var pythonVersion = "3.13" + if let cfgContents = try? String(contentsOfFile: cfgPath, encoding: .utf8) { + for line in cfgContents.components(separatedBy: .newlines) { + let parts = line.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } + if parts.count == 2 && parts[0] == "version" { + let components = parts[1].split(separator: ".") + if components.count >= 2 { + pythonVersion = "\(components[0]).\(components[1])" + } + } + } + } + + let venvPython = venvPath + "/bin/python" + var basePythonHome: String? + if let resolved = try? fm.destinationOfSymbolicLink(atPath: venvPython) { + let resolvedURL = URL(fileURLWithPath: resolved) + basePythonHome = resolvedURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + } + + if let home = basePythonHome { + let candidates = [ + "\(home)/lib/libpython\(pythonVersion).dylib", + "\(home)/lib/libpython\(pythonVersion)m.dylib", + ] + for candidate in candidates { + if fm.fileExists(atPath: candidate) { + setenv("PYTHON_LIBRARY", candidate, 1) + break + } + } + } + + setenv("PYTHONHOME", venvPath, 1) + + var paths = [projectRoot] + let sitePackages = "\(venvPath)/lib/python\(pythonVersion)/site-packages" + if fm.fileExists(atPath: sitePackages) { + paths.append(sitePackages) + } + if let home = basePythonHome { + paths.append("\(home)/lib/python\(pythonVersion)") + paths.append("\(home)/lib/python\(pythonVersion)/lib-dynload") + } + setenv("PYTHONPATH", paths.joined(separator: ":"), 1) + } + + private static func findProjectRoot() -> String { + let candidates = [ + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path, + FileManager.default.currentDirectoryPath, + ] + + for candidate in candidates { + let marker = candidate + "/tuttle/__init__.py" + if FileManager.default.fileExists(atPath: marker) { + return candidate + } + } + return FileManager.default.currentDirectoryPath + } +} + +// MARK: - Conversion Helpers + +extension PythonBridge { + static func string(_ obj: PythonObject, key: String, fallback: String = "—") -> String { + guard let val = obj.checking[key] else { return fallback } + if val == Python.None { return fallback } + return String(val) ?? fallback + } + + static func double(_ obj: PythonObject, key: String, fallback: Double = 0) -> Double { + guard let val = obj.checking[key] else { return fallback } + if val == Python.None { return fallback } + return Double(val) ?? fallback + } + + static func int(_ obj: PythonObject, key: String, fallback: Int = 0) -> Int { + guard let val = obj.checking[key] else { return fallback } + if val == Python.None { return fallback } + return Int(val) ?? fallback + } + + static func bool(_ obj: PythonObject, key: String, fallback: Bool = false) -> Bool { + guard let val = obj.checking[key] else { return fallback } + if val == Python.None { return fallback } + return Bool(val) ?? fallback + } +} diff --git a/TuttleMac/Sources/TuttleMac/TuttleMacApp.swift b/TuttleMac/Sources/TuttleMac/TuttleMacApp.swift new file mode 100644 index 00000000..3da103b2 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/TuttleMacApp.swift @@ -0,0 +1,20 @@ +import SwiftUI +import AppKit + +@main +struct TuttleMacApp: App { + init() { + // SPM-built executables need explicit activation to show windows + NSApplication.shared.setActivationPolicy(.regular) + NSApplication.shared.activate(ignoringOtherApps: true) + } + + var body: some Scene { + WindowGroup { + ContentView() + .frame(minWidth: 800, minHeight: 600) + } + .windowStyle(.titleBar) + .defaultSize(width: 1100, height: 750) + } +} diff --git a/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift b/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift new file mode 100644 index 00000000..aef2920c --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift @@ -0,0 +1,121 @@ +import Foundation +import PythonKit + +@Observable +final class BusinessViewModel { + var clients: [ClientModel] = [] + var contacts: [ContactModel] = [] + var contracts: [ContractModel] = [] + var projects: [ProjectModel] = [] + + var isLoading = false + var errorMessage: String? + + func loadAll() { + isLoading = true + errorMessage = nil + + PythonBridge.shared.run({ bridge -> BusinessData in + let clientsResult = bridge.get_all_clients() + let contactsResult = bridge.get_all_contacts() + let contractsResult = bridge.get_all_contracts() + let projectsResult = bridge.get_all_projects() + + var clients: [ClientModel] = [] + if PythonBridge.bool(clientsResult, key: "ok") { + for item in clientsResult["clients"] { + clients.append(ClientModel.from(item)) + } + } + + var contacts: [ContactModel] = [] + if PythonBridge.bool(contactsResult, key: "ok") { + for item in contactsResult["contacts"] { + contacts.append(ContactModel.from(item)) + } + } + + var contracts: [ContractModel] = [] + if PythonBridge.bool(contractsResult, key: "ok") { + for item in contractsResult["contracts"] { + contracts.append(ContractModel.from(item)) + } + } + + var projects: [ProjectModel] = [] + if PythonBridge.bool(projectsResult, key: "ok") { + for item in projectsResult["projects"] { + projects.append(ProjectModel.from(item)) + } + } + + return BusinessData( + clients: clients, + contacts: contacts, + contracts: contracts, + projects: projects + ) + }, completion: { [self] data in + self.clients = data.clients + self.contacts = data.contacts + self.contracts = data.contracts + self.projects = data.projects + self.isLoading = false + }) + } + + func deleteClient(_ id: Int) { + PythonBridge.shared.run({ bridge -> Bool in + PythonBridge.bool(bridge.delete_client(id), key: "ok") + }, completion: { [self] ok in + if ok { self.clients.removeAll { $0.id == id } } + }) + } + + func deleteContact(_ id: Int) { + PythonBridge.shared.run({ bridge -> Bool in + PythonBridge.bool(bridge.delete_contact(id), key: "ok") + }, completion: { [self] ok in + if ok { self.contacts.removeAll { $0.id == id } } + }) + } + + func deleteContract(_ id: Int) { + PythonBridge.shared.run({ bridge -> Bool in + PythonBridge.bool(bridge.delete_contract(id), key: "ok") + }, completion: { [self] ok in + if ok { self.contracts.removeAll { $0.id == id } } + }) + } + + func deleteProject(_ id: Int) { + PythonBridge.shared.run({ bridge -> Bool in + PythonBridge.bool(bridge.delete_project(id), key: "ok") + }, completion: { [self] ok in + if ok { self.projects.removeAll { $0.id == id } } + }) + } + + func toggleContractCompleted(_ id: Int) { + PythonBridge.shared.run({ bridge -> Bool in + PythonBridge.bool(bridge.toggle_contract_completed(id), key: "ok") + }, completion: { [self] ok in + if ok { self.loadAll() } + }) + } + + func toggleProjectCompleted(_ id: Int) { + PythonBridge.shared.run({ bridge -> Bool in + PythonBridge.bool(bridge.toggle_project_completed(id), key: "ok") + }, completion: { [self] ok in + if ok { self.loadAll() } + }) + } +} + +struct BusinessData { + var clients: [ClientModel] + var contacts: [ContactModel] + var contracts: [ContractModel] + var projects: [ProjectModel] +} diff --git a/TuttleMac/Sources/TuttleMac/ViewModels/DashboardViewModel.swift b/TuttleMac/Sources/TuttleMac/ViewModels/DashboardViewModel.swift new file mode 100644 index 00000000..a4a9c402 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/ViewModels/DashboardViewModel.swift @@ -0,0 +1,97 @@ +import Foundation +import PythonKit + +/// Holds all dashboard data as pure Swift types (no PythonObject references). +struct DashboardData { + var kpis: KPISummary? + var revenueData: [MonthlyDataPoint] + var spendableData: [MonthlyDataPoint] + var projectBudgets: [ProjectBudget] + var financialGoals: [FinancialGoalModel] +} + +@Observable +final class DashboardViewModel { + var kpis: KPISummary? + var revenueData: [MonthlyDataPoint] = [] + var spendableData: [MonthlyDataPoint] = [] + var projectBudgets: [ProjectBudget] = [] + var financialGoals: [FinancialGoalModel] = [] + var isLoading = false + var errorMessage: String? + + func loadAll() { + isLoading = true + errorMessage = nil + + PythonBridge.shared.run({ bridge -> DashboardData in + // All PythonObject access happens here on the Python thread. + // Convert everything to Swift types before returning. + let kpiResult = bridge.get_dashboard_kpis() + let chartResult = bridge.get_monthly_chart_data(12) + let budgetResult = bridge.get_project_budgets() + let goalsResult = bridge.get_financial_goals() + + let kpis: KPISummary? = PythonBridge.bool(kpiResult, key: "ok") + ? KPISummary.from(kpiResult) : nil + + var rev: [MonthlyDataPoint] = [] + var sp: [MonthlyDataPoint] = [] + if PythonBridge.bool(chartResult, key: "ok") { + for item in chartResult["revenue"] { + let month = PythonBridge.string(item, key: "month") + rev.append(MonthlyDataPoint( + month: month, + label: Self.shortLabel(month), + value: PythonBridge.double(item, key: "revenue") + )) + } + for item in chartResult["spendable"] { + let month = PythonBridge.string(item, key: "month") + sp.append(MonthlyDataPoint( + month: month, + label: Self.shortLabel(month), + value: PythonBridge.double(item, key: "spendable") + )) + } + } + + var budgets: [ProjectBudget] = [] + if PythonBridge.bool(budgetResult, key: "ok") { + for item in budgetResult["budgets"] { + budgets.append(ProjectBudget.from(item)) + } + } + + var goals: [FinancialGoalModel] = [] + if PythonBridge.bool(goalsResult, key: "ok") { + for item in goalsResult["goals"] { + goals.append(FinancialGoalModel.from(item)) + } + } + + return DashboardData( + kpis: kpis, + revenueData: rev, + spendableData: sp, + projectBudgets: budgets, + financialGoals: goals + ) + }, completion: { [self] data in + self.kpis = data.kpis + self.revenueData = data.revenueData + self.spendableData = data.spendableData + self.projectBudgets = data.projectBudgets + self.financialGoals = data.financialGoals + self.isLoading = false + }) + } + + private static func shortLabel(_ month: String) -> String { + let parts = month.split(separator: "-") + guard parts.count == 2 else { return month } + let m = parts[1] + let y = parts[0].suffix(2) + return "\(m)/\(y)" + } +} diff --git a/TuttleMac/Sources/TuttleMac/ViewModels/InvoicingViewModel.swift b/TuttleMac/Sources/TuttleMac/ViewModels/InvoicingViewModel.swift new file mode 100644 index 00000000..23d5cd09 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/ViewModels/InvoicingViewModel.swift @@ -0,0 +1,59 @@ +import Foundation +import PythonKit + +@Observable +final class InvoicingViewModel { + var invoices: [InvoiceModel] = [] + var isLoading = false + var errorMessage: String? + + func loadInvoices() { + isLoading = true + errorMessage = nil + + PythonBridge.shared.run({ bridge -> [InvoiceModel] in + let result = bridge.get_all_invoices() + guard PythonBridge.bool(result, key: "ok") else { return [] } + var out: [InvoiceModel] = [] + for item in result["invoices"] { + out.append(InvoiceModel.from(item)) + } + return out + }, completion: { [self] data in + self.invoices = data + self.isLoading = false + }) + } + + func deleteInvoice(_ id: Int) { + PythonBridge.shared.run({ bridge -> Bool in + PythonBridge.bool(bridge.delete_invoice(id), key: "ok") + }, completion: { [self] ok in + if ok { self.invoices.removeAll { $0.id == id } } + }) + } + + func toggleSent(_ id: Int) { + PythonBridge.shared.run({ bridge -> Bool in + PythonBridge.bool(bridge.toggle_invoice_sent(id), key: "ok") + }, completion: { [self] ok in + if ok { self.loadInvoices() } + }) + } + + func togglePaid(_ id: Int) { + PythonBridge.shared.run({ bridge -> Bool in + PythonBridge.bool(bridge.toggle_invoice_paid(id), key: "ok") + }, completion: { [self] ok in + if ok { self.loadInvoices() } + }) + } + + func toggleCancelled(_ id: Int) { + PythonBridge.shared.run({ bridge -> Bool in + PythonBridge.bool(bridge.toggle_invoice_cancelled(id), key: "ok") + }, completion: { [self] ok in + if ok { self.loadInvoices() } + }) + } +} diff --git a/TuttleMac/Sources/TuttleMac/ViewModels/TimelineViewModel.swift b/TuttleMac/Sources/TuttleMac/ViewModels/TimelineViewModel.swift new file mode 100644 index 00000000..541e4137 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/ViewModels/TimelineViewModel.swift @@ -0,0 +1,74 @@ +import Foundation +import PythonKit + +@Observable +final class TimelineViewModel { + var events: [TimelineEvent] = [] + var activeFilter: TimelineCategory = .all + var searchQuery: String = "" + var isLoading = false + var errorMessage: String? + + var filteredEvents: [TimelineEvent] { + var result = events + if activeFilter != .all { + result = result.filter { $0.category == activeFilter } + } + if !searchQuery.isEmpty { + let q = searchQuery.lowercased() + result = result.filter { + $0.title.lowercased().contains(q) + || $0.description.lowercased().contains(q) + } + } + return result + } + + /// Events grouped by month, preserving the descending date order. + var groupedEvents: [(key: String, label: String, events: [TimelineEvent])] { + let filtered = filteredEvents + var groups: [(key: String, label: String, events: [TimelineEvent])] = [] + var currentKey = "" + var currentEvents: [TimelineEvent] = [] + var currentLabel = "" + + for event in filtered { + let key = event.monthKey + if key != currentKey { + if !currentEvents.isEmpty { + groups.append((key: currentKey, label: currentLabel, events: currentEvents)) + } + currentKey = key + currentLabel = event.monthLabel + currentEvents = [event] + } else { + currentEvents.append(event) + } + } + if !currentEvents.isEmpty { + groups.append((key: currentKey, label: currentLabel, events: currentEvents)) + } + return groups + } + + func loadEvents() { + isLoading = true + errorMessage = nil + + PythonBridge.shared.run({ bridge -> [TimelineEvent] in + let result = bridge.get_timeline_events() + var parsed: [TimelineEvent] = [] + if PythonBridge.bool(result, key: "ok") { + for item in result["events"] { + if let event = TimelineEvent.from(item) { + parsed.append(event) + } + } + } + return parsed + }, completion: { [self] parsed in + self.events = parsed + self.isLoading = false + }) + } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift new file mode 100644 index 00000000..5bbbe5a9 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift @@ -0,0 +1,181 @@ +import SwiftUI + +struct ClientsView: View { + @State private var viewModel = BusinessViewModel() + @State private var selectedClient: ClientModel? + @State private var searchText = "" + + private var filtered: [ClientModel] { + if searchText.isEmpty { return viewModel.clients } + return viewModel.clients.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + || $0.contactName.localizedCaseInsensitiveContains(searchText) + || $0.location.localizedCaseInsensitiveContains(searchText) + } + } + + var body: some View { + HStack(spacing: 0) { + clientList + Divider() + detailPane + .frame(minWidth: 280, maxWidth: 380) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Clients") + .searchable(text: $searchText, prompt: "Search clients…") + .onAppear { viewModel.loadAll() } + .refreshable { viewModel.loadAll() } + } + + // MARK: - List + + private var clientList: some View { + VStack(spacing: 0) { + HStack { + Spacer() + Text("\(filtered.count) clients") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + if viewModel.isLoading && viewModel.clients.isEmpty { + ProgressView("Loading clients…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filtered.isEmpty { + Group { + if searchText.isEmpty { + ContentUnavailableView( + "No Clients", + systemImage: "building.2", + description: Text("Add your first client to get started.") + ) + } else { + ContentUnavailableView.search(text: searchText) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(filtered, selection: $selectedClient) { client in + ClientRow(client: client) + .tag(client) + .contextMenu { + Button("Delete", role: .destructive) { + viewModel.deleteClient(client.id) + if selectedClient?.id == client.id { + selectedClient = nil + } + } + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Detail + + @ViewBuilder + private var detailPane: some View { + if let client = selectedClient { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 12) { + InitialsAvatar(text: client.initials, color: .blue, size: 52) + + VStack(alignment: .leading, spacing: 2) { + Text(client.name) + .font(.title2) + .fontWeight(.bold) + if !client.location.isEmpty { + Label(client.location, systemImage: "mappin") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + Divider() + + DetailSection(title: "Contact") { + DetailRow(label: "Name", value: client.contactName, icon: "person") + DetailRow(label: "Email", value: client.contactEmail, icon: "envelope") + if !client.contactCompany.isEmpty { + DetailRow(label: "Company", value: client.contactCompany, icon: "building.2") + } + } + + DetailSection(title: "Business") { + HStack(spacing: 20) { + StatPill(label: "Contracts", value: "\(client.numContracts)", icon: "signature") + } + } + } + .padding(20) + } + .frame(maxHeight: .infinity) + } else { + ContentUnavailableView( + "No Selection", + systemImage: "building.2", + description: Text("Select a client to view details.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +// MARK: - Client Row + +struct ClientRow: View { + let client: ClientModel + + var body: some View { + HStack(spacing: 12) { + InitialsAvatar(text: client.initials, color: .blue, size: 34) + + VStack(alignment: .leading, spacing: 2) { + Text(client.name) + .font(.body) + .fontWeight(.medium) + HStack(spacing: 4) { + if !client.contactName.isEmpty { + Text(client.contactName) + .font(.caption) + .foregroundStyle(.secondary) + } + if !client.location.isEmpty { + Text("·") + .foregroundStyle(.quaternary) + Text(client.location) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + Spacer() + + if client.numContracts > 0 { + Text("\(client.numContracts)") + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary, in: Capsule()) + } + } + .padding(.vertical, 4) + } +} + +extension ClientModel: Hashable { + static func == (lhs: ClientModel, rhs: ClientModel) -> Bool { lhs.id == rhs.id } + func hash(into hasher: inout Hasher) { hasher.combine(id) } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift new file mode 100644 index 00000000..1b207711 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift @@ -0,0 +1,174 @@ +import SwiftUI + +struct ContactsView: View { + @State private var viewModel = BusinessViewModel() + @State private var selectedContact: ContactModel? + @State private var searchText = "" + + private var filtered: [ContactModel] { + if searchText.isEmpty { return viewModel.contacts } + return viewModel.contacts.filter { + $0.fullName.localizedCaseInsensitiveContains(searchText) + || $0.company.localizedCaseInsensitiveContains(searchText) + || $0.email.localizedCaseInsensitiveContains(searchText) + || $0.location.localizedCaseInsensitiveContains(searchText) + } + } + + var body: some View { + HStack(spacing: 0) { + contactList + Divider() + detailPane + .frame(minWidth: 280, maxWidth: 380) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Contacts") + .searchable(text: $searchText, prompt: "Search contacts…") + .onAppear { viewModel.loadAll() } + .refreshable { viewModel.loadAll() } + } + + // MARK: - List + + private var contactList: some View { + VStack(spacing: 0) { + HStack { + Spacer() + Text("\(filtered.count) contacts") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + if viewModel.isLoading && viewModel.contacts.isEmpty { + ProgressView("Loading contacts…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filtered.isEmpty { + Group { + if searchText.isEmpty { + ContentUnavailableView( + "No Contacts", + systemImage: "person.2", + description: Text("Contacts will appear here.") + ) + } else { + ContentUnavailableView.search(text: searchText) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(filtered, selection: $selectedContact) { contact in + ContactRow(contact: contact) + .tag(contact) + .contextMenu { + Button("Delete", role: .destructive) { + viewModel.deleteContact(contact.id) + if selectedContact?.id == contact.id { + selectedContact = nil + } + } + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Detail + + @ViewBuilder + private var detailPane: some View { + if let contact = selectedContact { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 12) { + InitialsAvatar(text: contact.initials, color: .purple, size: 52) + + VStack(alignment: .leading, spacing: 2) { + Text(contact.displayName) + .font(.title2) + .fontWeight(.bold) + if !contact.company.isEmpty { + Text(contact.company) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + Divider() + + DetailSection(title: "Contact Info") { + if !contact.email.isEmpty { + DetailRow(label: "Email", value: contact.email, icon: "envelope") + } + if !contact.location.isEmpty { + DetailRow(label: "Location", value: contact.location, icon: "mappin") + } + } + } + .padding(20) + } + .frame(maxHeight: .infinity) + } else { + ContentUnavailableView( + "No Selection", + systemImage: "person.2", + description: Text("Select a contact to view details.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +// MARK: - Contact Row + +struct ContactRow: View { + let contact: ContactModel + + var body: some View { + HStack(spacing: 12) { + InitialsAvatar(text: contact.initials, color: .purple, size: 34) + + VStack(alignment: .leading, spacing: 2) { + Text(contact.displayName) + .font(.body) + .fontWeight(.medium) + HStack(spacing: 4) { + if !contact.company.isEmpty { + Text(contact.company) + .font(.caption) + .foregroundStyle(.secondary) + } + if !contact.email.isEmpty { + if !contact.company.isEmpty { + Text("·").foregroundStyle(.quaternary) + } + Text(contact.email) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + Spacer() + + if !contact.location.isEmpty { + Text(contact.location) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 4) + } +} + +extension ContactModel: Hashable { + static func == (lhs: ContactModel, rhs: ContactModel) -> Bool { lhs.id == rhs.id } + func hash(into hasher: inout Hasher) { hasher.combine(id) } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift new file mode 100644 index 00000000..9da52fc3 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct ContractsView: View { + @State private var viewModel = BusinessViewModel() + @State private var selectedContract: ContractModel? + @State private var statusFilter: EntityStatus = .all + @State private var searchText = "" + + private var filtered: [ContractModel] { + viewModel.contracts.filter { c in + (statusFilter == .all || c.status == statusFilter) + && (searchText.isEmpty + || c.title.localizedCaseInsensitiveContains(searchText) + || c.clientName.localizedCaseInsensitiveContains(searchText)) + } + } + + var body: some View { + HStack(spacing: 0) { + contractList + Divider() + detailPane + .frame(minWidth: 280, maxWidth: 380) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Contracts") + .searchable(text: $searchText, prompt: "Search contracts…") + .onAppear { viewModel.loadAll() } + .refreshable { viewModel.loadAll() } + } + + // MARK: - List + + private var contractList: some View { + VStack(spacing: 0) { + statusFilterBar + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + if viewModel.isLoading && viewModel.contracts.isEmpty { + ProgressView("Loading contracts…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filtered.isEmpty { + ContentUnavailableView.search(text: searchText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(filtered, selection: $selectedContract) { contract in + ContractRow(contract: contract) + .tag(contract) + .contextMenu { + Button(contract.isCompleted ? "Mark Active" : "Mark Completed") { + viewModel.toggleContractCompleted(contract.id) + } + Divider() + Button("Delete", role: .destructive) { + viewModel.deleteContract(contract.id) + if selectedContract?.id == contract.id { + selectedContract = nil + } + } + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Filter + + private var statusFilterBar: some View { + HStack(spacing: 6) { + ForEach([EntityStatus.all, .active, .upcoming, .completed], id: \.rawValue) { status in + StatusFilterChip( + label: status.rawValue, + icon: status.icon, + isActive: statusFilter == status, + color: status == .all ? .accentColor : status.color + ) { + withAnimation(.easeInOut(duration: 0.2)) { + statusFilter = status + } + } + } + Spacer() + Text("\(filtered.count) contracts") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + + // MARK: - Detail + + @ViewBuilder + private var detailPane: some View { + if let contract = selectedContract { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 12) { + Image(systemName: "signature") + .font(.title2) + .foregroundStyle(contract.status.color) + .frame(width: 48, height: 48) + .background(contract.status.color.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 2) { + Text(contract.title) + .font(.title2) + .fontWeight(.bold) + HStack(spacing: 6) { + Text(contract.clientName) + .font(.subheadline) + .foregroundStyle(.secondary) + StatusBadge(status: contract.status) + } + } + } + + Divider() + + DetailSection(title: "Terms") { + DetailRow(label: "Rate", value: "\(contract.rateFormatted) / \(contract.unit)") + if let vol = contract.volume { + DetailRow(label: "Volume", value: "\(vol) \(contract.unit)s") + } + DetailRow(label: "Billing", value: contract.billingCycle) + DetailRow(label: "VAT", value: String(format: "%.0f%%", contract.vatRate * 100)) + DetailRow(label: "Currency", value: contract.currency) + } + + DetailSection(title: "Period") { + DetailRow(label: "Duration", value: contract.dateRange) + } + + DetailSection(title: "Related") { + HStack(spacing: 20) { + StatPill(label: "Projects", value: "\(contract.numProjects)", icon: "folder") + StatPill(label: "Invoices", value: "\(contract.numInvoices)", icon: "doc.text") + } + } + } + .padding(20) + } + .frame(maxHeight: .infinity) + } else { + ContentUnavailableView( + "No Selection", + systemImage: "signature", + description: Text("Select a contract to view details.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +// MARK: - Contract Row + +struct ContractRow: View { + let contract: ContractModel + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "signature") + .font(.body) + .foregroundStyle(contract.status.color) + .frame(width: 34, height: 34) + .background(contract.status.color.opacity(0.1), in: RoundedRectangle(cornerRadius: 7)) + + VStack(alignment: .leading, spacing: 2) { + Text(contract.title) + .font(.body) + .fontWeight(.medium) + HStack(spacing: 4) { + Text(contract.clientName) + .font(.caption) + .foregroundStyle(.secondary) + Text("·") + .foregroundStyle(.quaternary) + Text(contract.rateFormatted + "/\(contract.unit)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + StatusBadge(status: contract.status) + } + .padding(.vertical, 4) + } +} + +extension ContractModel: Hashable { + static func == (lhs: ContractModel, rhs: ContractModel) -> Bool { lhs.id == rhs.id } + func hash(into hasher: inout Hasher) { hasher.combine(id) } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift new file mode 100644 index 00000000..b2bf4f42 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift @@ -0,0 +1,199 @@ +import SwiftUI + +struct ProjectsView: View { + @State private var viewModel = BusinessViewModel() + @State private var selectedProject: ProjectModel? + @State private var statusFilter: EntityStatus = .all + @State private var searchText = "" + + private var filtered: [ProjectModel] { + viewModel.projects.filter { p in + (statusFilter == .all || p.status == statusFilter) + && (searchText.isEmpty + || p.title.localizedCaseInsensitiveContains(searchText) + || p.clientName.localizedCaseInsensitiveContains(searchText) + || p.tag.localizedCaseInsensitiveContains(searchText)) + } + } + + var body: some View { + HStack(spacing: 0) { + projectList + Divider() + detailPane + .frame(minWidth: 280, maxWidth: 380) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Projects") + .searchable(text: $searchText, prompt: "Search projects…") + .onAppear { viewModel.loadAll() } + .refreshable { viewModel.loadAll() } + } + + // MARK: - List + + private var projectList: some View { + VStack(spacing: 0) { + statusFilterBar + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + if viewModel.isLoading && viewModel.projects.isEmpty { + ProgressView("Loading projects…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filtered.isEmpty { + ContentUnavailableView.search(text: searchText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(filtered, selection: $selectedProject) { project in + ProjectRow(project: project) + .tag(project) + .contextMenu { + Button(project.isCompleted ? "Mark Active" : "Mark Completed") { + viewModel.toggleProjectCompleted(project.id) + } + Divider() + Button("Delete", role: .destructive) { + viewModel.deleteProject(project.id) + if selectedProject?.id == project.id { + selectedProject = nil + } + } + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Filter + + private var statusFilterBar: some View { + HStack(spacing: 6) { + ForEach([EntityStatus.all, .active, .upcoming, .completed], id: \.rawValue) { status in + StatusFilterChip( + label: status.rawValue, + icon: status.icon, + isActive: statusFilter == status, + color: status == .all ? .accentColor : status.color + ) { + withAnimation(.easeInOut(duration: 0.2)) { + statusFilter = status + } + } + } + Spacer() + Text("\(filtered.count) projects") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + + // MARK: - Detail + + @ViewBuilder + private var detailPane: some View { + if let project = selectedProject { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 12) { + InitialsAvatar( + text: String(project.title.prefix(2)).uppercased(), + color: project.status.color, + size: 48 + ) + VStack(alignment: .leading, spacing: 2) { + Text(project.title) + .font(.title2) + .fontWeight(.bold) + HStack(spacing: 6) { + Text(project.tag) + .font(.subheadline) + .foregroundStyle(.secondary) + StatusBadge(status: project.status) + } + } + } + + Divider() + + DetailSection(title: "Details") { + DetailRow(label: "Client", value: project.clientName) + DetailRow(label: "Contract", value: project.contractTitle) + DetailRow(label: "Period", value: project.dateRange) + } + + if !project.description.isEmpty { + DetailSection(title: "Description") { + Text(project.description) + .font(.body) + .foregroundStyle(.secondary) + } + } + + DetailSection(title: "Activity") { + HStack(spacing: 20) { + StatPill(label: "Invoices", value: "\(project.numInvoices)", icon: "doc.text") + StatPill(label: "Timesheets", value: "\(project.numTimesheets)", icon: "clock") + } + } + } + .padding(20) + } + .frame(maxHeight: .infinity) + } else { + ContentUnavailableView( + "No Selection", + systemImage: "folder", + description: Text("Select a project to view details.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +// MARK: - Project Row + +struct ProjectRow: View { + let project: ProjectModel + + var body: some View { + HStack(spacing: 12) { + InitialsAvatar( + text: String(project.title.prefix(2)).uppercased(), + color: project.status.color, + size: 34 + ) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(project.title) + .font(.body) + .fontWeight(.medium) + Text(project.tag) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) + } + Text(project.clientName.isEmpty ? "No client" : project.clientName) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + StatusBadge(status: project.status) + } + .padding(.vertical, 4) + } +} + +extension ProjectModel: Hashable { + static func == (lhs: ProjectModel, rhs: ProjectModel) -> Bool { lhs.id == rhs.id } + func hash(into hasher: inout Hasher) { hasher.combine(id) } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/SharedComponents.swift b/TuttleMac/Sources/TuttleMac/Views/Business/SharedComponents.swift new file mode 100644 index 00000000..67bf57b1 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Business/SharedComponents.swift @@ -0,0 +1,142 @@ +import SwiftUI + +// MARK: - Initials Avatar + +struct InitialsAvatar: View { + let text: String + let color: Color + var size: CGFloat = 36 + + var body: some View { + Text(text) + .font(.system(size: size * 0.36, weight: .semibold, design: .rounded)) + .foregroundStyle(color) + .frame(width: size, height: size) + .background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: size * 0.22)) + } +} + +// MARK: - Status Badge + +struct StatusBadge: View { + let status: EntityStatus + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(status.color) + .frame(width: 6, height: 6) + Text(status.rawValue) + .font(.caption2) + .fontWeight(.semibold) + } + .foregroundStyle(status.color) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(status.color.opacity(0.1), in: Capsule()) + } +} + +// MARK: - Status Filter Chip + +struct StatusFilterChip: View { + let label: String + let icon: String + let isActive: Bool + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(label, systemImage: icon) + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .foregroundStyle(isActive ? .white : color) + .background( + isActive ? AnyShapeStyle(color) : AnyShapeStyle(.clear), + in: Capsule() + ) + .overlay(Capsule().strokeBorder(color.opacity(0.4), lineWidth: 1)) + } + .buttonStyle(.plain) + } +} + +// MARK: - Detail Section + +struct DetailSection: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title.uppercased()) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.tertiary) + .tracking(0.8) + + VStack(alignment: .leading, spacing: 8) { + content + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } + } +} + +// MARK: - Detail Row + +struct DetailRow: View { + let label: String + let value: String + var icon: String? = nil + + var body: some View { + HStack(spacing: 8) { + if let icon { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(.tertiary) + .frame(width: 16) + } + Text(label) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(width: 80, alignment: .leading) + Text(value.isEmpty ? "—" : value) + .font(.subheadline) + .textSelection(.enabled) + } + } +} + +// MARK: - Stat Pill + +struct StatPill: View { + let label: String + let value: String + let icon: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 0) { + Text(value) + .font(.title3) + .fontWeight(.bold) + Text(label) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Dashboard/DashboardView.swift b/TuttleMac/Sources/TuttleMac/Views/Dashboard/DashboardView.swift new file mode 100644 index 00000000..f93e85e2 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Dashboard/DashboardView.swift @@ -0,0 +1,344 @@ +import SwiftUI +import Charts + +struct DashboardView: View { + @State private var viewModel = DashboardViewModel() + + var body: some View { + Group { + if viewModel.isLoading && viewModel.kpis == nil { + ProgressView("Loading dashboard…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let kpis = viewModel.kpis { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + kpiSection(kpis) + taxSection(kpis) + chartSection + budgetSection + goalsSection + } + .padding(24) + } + } else { + ContentUnavailableView( + "No Data", + systemImage: "chart.bar.xaxis", + description: Text("Could not load dashboard data.") + ) + } + } + .navigationTitle("Dashboard") + .onAppear { viewModel.loadAll() } + .refreshable { viewModel.loadAll() } + } + + // MARK: - KPI Cards + + private func kpiSection(_ kpis: KPISummary) -> some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 160), spacing: 12)], spacing: 12) { + KPICard( + title: "Revenue (YTD)", + value: kpis.totalRevenueYTDFormatted, + icon: "arrow.up.right", + valueColor: kpis.totalRevenueYTD > 0 ? .green : .primary + ) + KPICard( + title: "Outstanding", + value: kpis.outstandingAmountFormatted, + icon: "wallet.bifold", + valueColor: kpis.outstandingAmount > 0 ? .yellow : .primary + ) + KPICard( + title: "Overdue", + value: kpis.overdueAmountFormatted, + icon: "exclamationmark.triangle", + valueColor: kpis.overdueAmount > 0 ? .red : .primary + ) + KPICard( + title: "Eff. Hourly Rate", + value: kpis.effectiveHourlyRateFormatted, + icon: "gauge.with.needle", + valueColor: .blue + ) + KPICard( + title: "Utilization", + value: kpis.utilizationRateFormatted, + icon: "chart.pie", + valueColor: (kpis.utilizationRate ?? 0) >= 0.7 ? .blue : .yellow + ) + KPICard( + title: "Active Projects", + value: "\(kpis.activeProjects)", + icon: "folder", + valueColor: .primary + ) + KPICard( + title: "Active Contracts", + value: "\(kpis.activeContracts)", + icon: "signature", + valueColor: .primary + ) + KPICard( + title: "Unpaid Invoices", + value: "\(kpis.unpaidInvoices)", + icon: "doc.text", + valueColor: kpis.unpaidInvoices > 0 ? .yellow : .primary + ) + } + } + + private func taxSection(_ kpis: KPISummary) -> some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 200), spacing: 12)], spacing: 12) { + KPICard( + title: "VAT Reserve", + value: kpis.vatReserveFormatted, + icon: "building.columns", + valueColor: kpis.vatReserve > 0 ? .yellow : .primary + ) + KPICard( + title: "Est. Income Tax", + value: kpis.incomeTaxReserveFormatted, + icon: "function", + valueColor: kpis.incomeTaxReserve > 0 ? .yellow : .primary + ) + KPICard( + title: "Spendable Income", + value: kpis.spendableIncomeFormatted, + icon: "banknote", + valueColor: kpis.spendableIncome > 0 ? .green : .red + ) + } + } + + // MARK: - Revenue Chart + + @ViewBuilder + private var chartSection: some View { + if !viewModel.revenueData.isEmpty { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Monthly Revenue vs Spendable Income (Est.)", systemImage: "chart.bar") + .font(.headline) + Spacer() + HStack(spacing: 12) { + legendChip("Revenue", color: .blue) + legendChip("Spendable", color: .green) + } + } + + Chart { + ForEach(viewModel.revenueData) { point in + BarMark( + x: .value("Month", point.label), + y: .value("Revenue", point.value) + ) + .foregroundStyle(.blue) + .position(by: .value("Type", "Revenue")) + } + ForEach(viewModel.spendableData) { point in + BarMark( + x: .value("Month", point.label), + y: .value("Spendable", max(point.value, 0)) + ) + .foregroundStyle(.green) + .position(by: .value("Type", "Spendable")) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [4, 4])) + AxisValueLabel { + if let v = value.as(Double.self) { + Text(Self.shortCurrency(v)) + .font(.caption2) + } + } + } + } + .chartXAxis { + AxisMarks { value in + AxisValueLabel() + .font(.caption2) + } + } + .frame(height: 260) + .padding() + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } + } + } + + private func legendChip(_ label: String, color: Color) -> some View { + HStack(spacing: 4) { + Circle().fill(color).frame(width: 8, height: 8) + Text(label).font(.caption).foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.quaternary.opacity(0.5), in: Capsule()) + } + + private static func shortCurrency(_ value: Double) -> String { + if value >= 1000 { + return "\(String(format: "%.0f", value / 1000))K" + } + return String(format: "%.0f", value) + } + + // MARK: - Project Budgets + + @ViewBuilder + private var budgetSection: some View { + if !viewModel.projectBudgets.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Label("Project Budgets", systemImage: "chart.bar.doc.horizontal") + .font(.headline) + + VStack(spacing: 8) { + ForEach(viewModel.projectBudgets) { budget in + ProjectBudgetRow(budget: budget) + } + } + .padding() + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } + } + } + + // MARK: - Financial Goals + + @ViewBuilder + private var goalsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Label("Financial Goals", systemImage: "flag") + .font(.headline) + + if viewModel.financialGoals.isEmpty { + Text("No goals yet.") + .foregroundStyle(.secondary) + .font(.subheadline) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } else { + VStack(spacing: 8) { + ForEach(viewModel.financialGoals) { goal in + FinancialGoalRow(goal: goal) + } + } + .padding() + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } + } + } +} + +// MARK: - Subviews + +struct KPICard: View { + let title: String + let value: String + let icon: String + var valueColor: Color = .primary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(.secondary) + Text(title.uppercased()) + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .tracking(0.6) + } + Text(value) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(valueColor) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } +} + +struct ProjectBudgetRow: View { + let budget: ProjectBudget + + private var barColor: Color { + if budget.progress >= 1.0 { return .red } + if budget.progress >= 0.8 { return .yellow } + return .green + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(budget.project) + .font(.body) + Spacer() + Text("\(Int(budget.hoursTracked)) / \(Int(budget.hoursBudget)) h (\(Int(budget.progress * 100))%)") + .font(.caption) + .foregroundStyle(.secondary) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(.quaternary) + .frame(height: 6) + Capsule() + .fill(barColor) + .frame(width: geo.size.width * min(budget.progress, 1.0), height: 6) + } + } + .frame(height: 6) + } + } +} + +struct FinancialGoalRow: View { + let goal: FinancialGoalModel + + private var barColor: Color { + goal.isReached ? .green : .blue + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(goal.title) + .font(.body) + Spacer() + if goal.isReached { + Text("Reached!") + .font(.caption) + .foregroundStyle(.green) + .fontWeight(.semibold) + } else { + Text("\(goal.ytdRevenueFormatted) / \(goal.targetAmountFormatted)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Text("Target: \(goal.targetAmountFormatted) by \(goal.targetDateFormatted)") + .font(.caption2) + .foregroundStyle(.tertiary) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(.quaternary) + .frame(height: 6) + Capsule() + .fill(barColor) + .frame(width: geo.size.width * min(goal.progress, 1.0), height: 6) + } + } + .frame(height: 6) + } + } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift b/TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift new file mode 100644 index 00000000..f5b07002 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift @@ -0,0 +1,407 @@ +import SwiftUI + +struct InvoicingView: View { + @State private var viewModel = InvoicingViewModel() + @State private var selectedInvoice: InvoiceModel? + @State private var statusFilter: InvoiceStatus = .all + @State private var searchText = "" + + private var filtered: [InvoiceModel] { + viewModel.invoices.filter { inv in + (statusFilter == .all || inv.status == statusFilter) + && (searchText.isEmpty + || inv.number.localizedCaseInsensitiveContains(searchText) + || inv.clientName.localizedCaseInsensitiveContains(searchText) + || inv.projectTitle.localizedCaseInsensitiveContains(searchText)) + } + } + + private var totalFiltered: Double { + filtered.reduce(0) { $0 + $1.total } + } + + var body: some View { + HStack(spacing: 0) { + invoiceList + Divider() + detailPane + .frame(minWidth: 320, maxWidth: 420) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Invoicing") + .searchable(text: $searchText, prompt: "Search invoices…") + .onAppear { viewModel.loadInvoices() } + .refreshable { viewModel.loadInvoices() } + } + + // MARK: - List + + private var invoiceList: some View { + VStack(spacing: 0) { + statusFilterBar + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + if viewModel.isLoading && viewModel.invoices.isEmpty { + ProgressView("Loading invoices…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filtered.isEmpty { + if searchText.isEmpty && statusFilter != .all { + ContentUnavailableView( + "No \(statusFilter.rawValue) Invoices", + systemImage: statusFilter.icon, + description: Text("No invoices match this filter.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView.search(text: searchText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + List(filtered, selection: $selectedInvoice) { invoice in + InvoiceRow(invoice: invoice) + .tag(invoice) + .contextMenu { + statusContextMenu(for: invoice) + Divider() + Button("Delete", role: .destructive) { + viewModel.deleteInvoice(invoice.id) + if selectedInvoice?.id == invoice.id { + selectedInvoice = nil + } + } + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Filter + + private var statusFilterBar: some View { + HStack(spacing: 6) { + ForEach(InvoiceStatus.allCases, id: \.rawValue) { status in + StatusFilterChip( + label: status.rawValue, + icon: status.icon, + isActive: statusFilter == status, + color: status == .all ? .accentColor : status.color + ) { + withAnimation(.easeInOut(duration: 0.2)) { + statusFilter = status + } + } + } + Spacer() + Text("\(filtered.count) invoices") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + + // MARK: - Context Menu + + @ViewBuilder + private func statusContextMenu(for invoice: InvoiceModel) -> some View { + if !invoice.cancelled { + Button(invoice.sent ? "Mark as Not Sent" : "Mark as Sent") { + viewModel.toggleSent(invoice.id) + } + Button(invoice.paid ? "Mark as Unpaid" : "Mark as Paid") { + viewModel.togglePaid(invoice.id) + } + } + Button(invoice.cancelled ? "Restore Invoice" : "Cancel Invoice") { + viewModel.toggleCancelled(invoice.id) + } + } + + // MARK: - Detail + + @ViewBuilder + private var detailPane: some View { + if let invoice = selectedInvoice { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + invoiceHeader(invoice) + Divider() + invoiceAmounts(invoice) + invoiceItems(invoice) + invoiceDetails(invoice) + invoiceActions(invoice) + } + .padding(20) + } + .frame(maxHeight: .infinity) + } else { + ContentUnavailableView( + "No Selection", + systemImage: "doc.text", + description: Text("Select an invoice to view details.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: - Detail Subviews + + private func invoiceHeader(_ invoice: InvoiceModel) -> some View { + HStack(spacing: 12) { + Image(systemName: "doc.text") + .font(.title2) + .foregroundStyle(invoice.status.color) + .frame(width: 48, height: 48) + .background(invoice.status.color.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 2) { + Text(invoice.number.isEmpty ? "Draft" : invoice.number) + .font(.title2) + .fontWeight(.bold) + HStack(spacing: 6) { + Text(invoice.clientName.isEmpty ? "No client" : invoice.clientName) + .font(.subheadline) + .foregroundStyle(.secondary) + InvoiceStatusBadge(status: invoice.status) + } + } + Spacer() + } + } + + private func invoiceAmounts(_ invoice: InvoiceModel) -> some View { + HStack(spacing: 12) { + AmountCard(label: "Subtotal", value: invoice.subtotalFormatted, color: .secondary) + AmountCard(label: "VAT", value: invoice.vatTotalFormatted, color: .orange) + AmountCard(label: "Total", value: invoice.totalFormatted, color: invoice.status.color, isProminent: true) + } + } + + @ViewBuilder + private func invoiceItems(_ invoice: InvoiceModel) -> some View { + if !invoice.items.isEmpty { + DetailSection(title: "Line Items") { + VStack(spacing: 0) { + ForEach(invoice.items) { item in + InvoiceItemRow(item: item) + if item.id != invoice.items.last?.id { + Divider().padding(.vertical, 4) + } + } + } + } + } + } + + private func invoiceDetails(_ invoice: InvoiceModel) -> some View { + DetailSection(title: "Details") { + DetailRow(label: "Date", value: invoice.dateFormatted, icon: "calendar") + if let due = invoice.dueDateFormatted { + DetailRow(label: "Due", value: due, icon: "clock") + } + DetailRow(label: "Project", value: invoice.projectTitle, icon: "folder") + DetailRow(label: "Contract", value: invoice.contractTitle, icon: "signature") + DetailRow(label: "Currency", value: invoice.currency, icon: "banknote") + } + } + + private func invoiceActions(_ invoice: InvoiceModel) -> some View { + DetailSection(title: "Actions") { + HStack(spacing: 10) { + if !invoice.cancelled { + ActionButton( + label: invoice.sent ? "Unsend" : "Mark Sent", + icon: "paperplane", + color: .blue, + isActive: invoice.sent + ) { + viewModel.toggleSent(invoice.id) + } + ActionButton( + label: invoice.paid ? "Unpay" : "Mark Paid", + icon: "checkmark.circle", + color: .green, + isActive: invoice.paid + ) { + viewModel.togglePaid(invoice.id) + } + } + ActionButton( + label: invoice.cancelled ? "Restore" : "Cancel", + icon: invoice.cancelled ? "arrow.uturn.left" : "xmark.circle", + color: .orange, + isActive: invoice.cancelled + ) { + viewModel.toggleCancelled(invoice.id) + } + } + } + } +} + +// MARK: - Invoice Row + +struct InvoiceRow: View { + let invoice: InvoiceModel + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "doc.text") + .font(.body) + .foregroundStyle(invoice.status.color) + .frame(width: 34, height: 34) + .background(invoice.status.color.opacity(0.1), in: RoundedRectangle(cornerRadius: 7)) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(invoice.number.isEmpty ? "Draft" : invoice.number) + .font(.body) + .fontWeight(.medium) + Text(invoice.dateFormatted) + .font(.caption) + .foregroundStyle(.tertiary) + } + HStack(spacing: 4) { + Text(invoice.clientName.isEmpty ? "No client" : invoice.clientName) + .font(.caption) + .foregroundStyle(.secondary) + if !invoice.projectTitle.isEmpty { + Text("·") + .foregroundStyle(.quaternary) + Text(invoice.projectTitle) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(invoice.totalFormatted) + .font(.subheadline) + .fontWeight(.semibold) + .monospacedDigit() + InvoiceStatusBadge(status: invoice.status) + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Invoice Status Badge + +struct InvoiceStatusBadge: View { + let status: InvoiceStatus + + var body: some View { + HStack(spacing: 4) { + Image(systemName: status.icon) + .font(.system(size: 8)) + Text(status.rawValue) + .font(.caption2) + .fontWeight(.semibold) + } + .foregroundStyle(status.color) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(status.color.opacity(0.1), in: Capsule()) + } +} + +// MARK: - Amount Card + +struct AmountCard: View { + let label: String + let value: String + let color: Color + var isProminent: Bool = false + + var body: some View { + VStack(spacing: 4) { + Text(label.uppercased()) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.tertiary) + .tracking(0.6) + Text(value) + .font(isProminent ? .title3 : .subheadline) + .fontWeight(isProminent ? .bold : .medium) + .monospacedDigit() + .foregroundStyle(isProminent ? color : .primary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(isProminent ? color.opacity(0.3) : .clear, lineWidth: 1) + ) + } +} + +// MARK: - Invoice Item Row + +struct InvoiceItemRow: View { + let item: InvoiceItemModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(item.description) + .font(.subheadline) + .fontWeight(.medium) + Spacer() + Text(item.subtotalFormatted) + .font(.subheadline) + .fontWeight(.semibold) + .monospacedDigit() + } + HStack(spacing: 12) { + Label(String(format: "%.1f %@", item.quantity, item.unit), systemImage: "number") + Label(item.unitPriceFormatted + "/" + item.unit, systemImage: "banknote") + Label(item.vatPercent + " VAT", systemImage: "percent") + Spacer() + if !item.dateRange.isEmpty { + Text(item.dateRange) + .foregroundStyle(.tertiary) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } +} + +// MARK: - Action Button + +struct ActionButton: View { + let label: String + let icon: String + let color: Color + var isActive: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + Image(systemName: isActive ? icon + ".fill" : icon) + .font(.title3) + Text(label) + .font(.caption2) + .fontWeight(.medium) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .foregroundStyle(isActive ? .white : color) + .background( + isActive ? AnyShapeStyle(color) : AnyShapeStyle(color.opacity(0.1)), + in: RoundedRectangle(cornerRadius: 8) + ) + } + .buttonStyle(.plain) + } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Sidebar.swift b/TuttleMac/Sources/TuttleMac/Views/Sidebar.swift new file mode 100644 index 00000000..9b79ebaf --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Sidebar.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct Sidebar: View { + @Binding var selection: SidebarItem? + + var body: some View { + List(selection: $selection) { + ForEach(SidebarItem.grouped(), id: \.0) { section, items in + Section(section.rawValue) { + ForEach(items) { item in + Label(item.rawValue, systemImage: item.systemImage) + .tag(item) + } + } + } + } + .listStyle(.sidebar) + .navigationTitle("Tuttle") + } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Timeline/TimelineView.swift b/TuttleMac/Sources/TuttleMac/Views/Timeline/TimelineView.swift new file mode 100644 index 00000000..d668948e --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Timeline/TimelineView.swift @@ -0,0 +1,270 @@ +import SwiftUI + +struct TimelineView: View { + @State private var viewModel = TimelineViewModel() + + var body: some View { + Group { + if viewModel.isLoading && viewModel.events.isEmpty { + ProgressView("Loading timeline…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.filteredEvents.isEmpty && !viewModel.events.isEmpty { + ContentUnavailableView.search(text: viewModel.searchQuery) + } else if viewModel.events.isEmpty { + ContentUnavailableView( + "No Events Yet", + systemImage: "calendar.day.timeline.left", + description: Text("Create invoices, contracts, or projects to see them here.") + ) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + filterBar + .padding(.bottom, 16) + + timelineContent + } + .padding(24) + } + } + } + .navigationTitle("Timeline") + .searchable(text: $viewModel.searchQuery, prompt: "Search events…") + .onAppear { viewModel.loadEvents() } + .refreshable { viewModel.loadEvents() } + } + + // MARK: - Filter Bar + + private var filterBar: some View { + HStack(spacing: 6) { + ForEach(TimelineCategory.allCases) { category in + FilterChip( + label: category.label, + systemImage: category.systemImage, + isActive: viewModel.activeFilter == category, + color: Self.categoryColor(category) + ) { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.activeFilter = category + } + } + } + Spacer() + } + } + + // MARK: - Timeline Content + + private var timelineContent: some View { + let groups = viewModel.groupedEvents + let today = Calendar.current.startOfDay(for: Date()) + var todayInserted = false + + return VStack(alignment: .leading, spacing: 0) { + ForEach(Array(groups.enumerated()), id: \.element.key) { groupIdx, group in + // Month header + MonthHeader(label: group.label) + .padding(.top, groupIdx > 0 ? 8 : 0) + + ForEach(Array(group.events.enumerated()), id: \.element.id) { eventIdx, event in + let isLast = groupIdx == groups.count - 1 + && eventIdx == group.events.count - 1 + + // Insert today marker before the first past event + if !todayInserted && !event.isFuture && event.date <= today { + let _ = { todayInserted = true }() + TodayMarker() + } + + TimelineEventCard(event: event, isLast: isLast) + } + } + + // If all events are in the future, show today at the bottom + if !todayInserted { + TodayMarker() + } + } + } + + static func categoryColor(_ category: TimelineCategory) -> Color { + switch category { + case .all: .blue + case .invoice: .blue + case .contract: .green + case .project: .orange + case .goal: .purple + } + } +} + +// MARK: - Filter Chip + +struct FilterChip: View { + let label: String + let systemImage: String + let isActive: Bool + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(label, systemImage: systemImage) + .font(.caption) + .fontWeight(.semibold) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .foregroundStyle(isActive ? .white : color) + .background( + isActive ? AnyShapeStyle(color) : AnyShapeStyle(.clear), + in: Capsule() + ) + .overlay( + Capsule() + .strokeBorder(color, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} + +// MARK: - Month Header + +struct MonthHeader: View { + let label: String + + var body: some View { + HStack(spacing: 8) { + spineSegment + Text(label) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.tertiary) + .tracking(0.4) + } + .frame(height: 36) + } + + private var spineSegment: some View { + Rectangle() + .fill(.quaternary) + .frame(width: 2) + .frame(width: 36, height: 36) + } +} + +// MARK: - Today Marker + +struct TodayMarker: View { + var body: some View { + HStack(spacing: 8) { + // Spine dot + ZStack { + Rectangle() + .fill(.quaternary) + .frame(width: 2) + Circle() + .fill(.red) + .frame(width: 10, height: 10) + } + .frame(width: 36, height: 32) + + Text("Today") + .font(.caption) + .fontWeight(.bold) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 3) + .background(.red, in: Capsule()) + + VStack { Divider() } + } + } +} + +// MARK: - Event Card + +struct TimelineEventCard: View { + let event: TimelineEvent + var isLast: Bool = false + + private var dotColor: Color { + switch event.status { + case "paid", "completed": .green + case "overdue", "cancelled": .red + case "due": TimelineView.categoryColor(event.category) + default: TimelineView.categoryColor(event.category) + } + } + + private var categoryColor: Color { + TimelineView.categoryColor(event.category) + } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + // Spine with dot + VStack(spacing: 0) { + Rectangle() + .fill(.quaternary) + .frame(width: 2, height: 14) + + ZStack { + Circle() + .fill(dotColor.opacity(0.15)) + .frame(width: 18, height: 18) + Circle() + .fill(dotColor) + .frame(width: 10, height: 10) + } + + if !isLast { + Rectangle() + .fill(.quaternary) + .frame(width: 2) + .frame(minHeight: 46) + } else { + Spacer(minLength: 0) + } + } + .frame(width: 36) + + // Card + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + HStack(spacing: 6) { + Image(systemName: event.category.systemImage) + .font(.subheadline) + .foregroundStyle(dotColor) + Text(event.title) + .font(.body) + .fontWeight(.semibold) + } + Spacer() + Text(event.dateFormatted) + .font(.caption) + .foregroundStyle(.tertiary) + } + + if !event.description.isEmpty { + Text(event.description) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Text(event.category.label) + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(categoryColor) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(categoryColor.opacity(0.12), in: Capsule()) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + .opacity(event.isFuture ? 0.55 : 1.0) + } + } +} diff --git a/tuttle/__init__.py b/tuttle/__init__.py index 404ad40d..f81e4900 100644 --- a/tuttle/__init__.py +++ b/tuttle/__init__.py @@ -6,7 +6,11 @@ ] __version__ = "2.1.0a1" -from . import app +try: + from . import app +except ImportError: + pass + from . import ( banking, calendar, diff --git a/tuttle/app/core/abstractions.py b/tuttle/app/core/abstractions.py index 165675be..e86d366d 100644 --- a/tuttle/app/core/abstractions.py +++ b/tuttle/app/core/abstractions.py @@ -1,21 +1,24 @@ -from typing import Any, Callable, List, Mapping, Optional, Type +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, List, Mapping, Optional, Type from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path import functools -from flet import AlertDialog, FilePicker - import sqlalchemy import sqlmodel from sqlmodel import pool from loguru import logger -from .utils import AUTO_SCROLL, START_ALIGNMENT, CROSS_START, AlertDialogControls from .intent_result import IntentResult +if TYPE_CHECKING: + from flet import AlertDialog + from .utils import AlertDialogControls + class DatabaseStorage(ABC): """Abstract class for database storage""" @@ -99,11 +102,21 @@ class TViewParams: dialog_controller: Callable pick_file_callback: Callable client_storage: ClientStorage - vertical_alignment_in_parent = START_ALIGNMENT - horizontal_alignment_in_parent = CROSS_START keep_back_stack: bool = True on_navigate_back: Optional[Callable] = None - page_scroll_type = AUTO_SCROLL + vertical_alignment_in_parent: Any = None + horizontal_alignment_in_parent: Any = None + page_scroll_type: Any = None + + def __post_init__(self): + from .utils import AUTO_SCROLL, START_ALIGNMENT, CROSS_START + + if self.vertical_alignment_in_parent is None: + self.vertical_alignment_in_parent = START_ALIGNMENT + if self.horizontal_alignment_in_parent is None: + self.horizontal_alignment_in_parent = CROSS_START + if self.page_scroll_type is None: + self.page_scroll_type = AUTO_SCROLL class TView(ABC): @@ -169,12 +182,16 @@ def __init__( ): super().__init__() self.dialog_controller = dialog_controller - self.dialog: AlertDialog = dialog + self.dialog = dialog def close_dialog(self, e: Optional[any] = None): + from .utils import AlertDialogControls + self.dialog_controller(self.dialog, AlertDialogControls.CLOSE) def open_dialog(self, e: Optional[any] = None): + from .utils import AlertDialogControls + self.dialog_controller(self.dialog, AlertDialogControls.ADD_AND_OPEN) def dimiss_open_dialogs(self): diff --git a/tuttle/app/core/formatting.py b/tuttle/app/core/formatting.py new file mode 100644 index 00000000..48d06267 --- /dev/null +++ b/tuttle/app/core/formatting.py @@ -0,0 +1,16 @@ +"""Flet-free formatting utilities.""" + +from babel.numbers import format_currency as _babel_format_currency + + +def fmt_currency(value, currency: str = "EUR", locale: str = "en_US") -> str: + """Format a numeric value as a currency string using babel. + + Args: + value: Decimal, float, or int to format. None returns "---". + currency: ISO 4217 code (e.g. "EUR", "USD", "SEK"). + locale: Babel locale for number formatting. + """ + if value is None: + return "—" + return _babel_format_currency(float(value), currency, locale=locale) diff --git a/tuttle/app/core/utils.py b/tuttle/app/core/utils.py index ad9bcbfd..14fb7254 100644 --- a/tuttle/app/core/utils.py +++ b/tuttle/app/core/utils.py @@ -25,7 +25,7 @@ ) import pycountry -from babel.numbers import format_currency as _babel_format_currency + from ...dev import deprecated @@ -172,17 +172,8 @@ def get_currencies() -> List[Tuple[str, str, str]]: return currencies -def fmt_currency(value, currency: str = "EUR", locale: str = "en_US") -> str: - """Format a numeric value as a currency string using babel. - - Args: - value: Decimal, float, or int to format. None returns "---". - currency: ISO 4217 code (e.g. "EUR", "USD", "SEK"). - locale: Babel locale for number formatting. - """ - if value is None: - return "—" - return _babel_format_currency(float(value), currency, locale=locale) +# Re-export from Flet-free module for backward compatibility +from .formatting import fmt_currency # noqa: F811 def toBase64( diff --git a/tuttle/app/timeline/intent.py b/tuttle/app/timeline/intent.py index 12918248..4814cd39 100644 --- a/tuttle/app/timeline/intent.py +++ b/tuttle/app/timeline/intent.py @@ -11,11 +11,9 @@ from dataclasses import dataclass from typing import List, Optional -from flet import Icons - from ..core.abstractions import SQLModelDataSourceMixin, Intent from ..core.intent_result import IntentResult -from ..core.utils import fmt_currency +from ..core.formatting import fmt_currency from ..res import colors from ...model import Contract, Invoice, Project, FinancialGoal @@ -35,11 +33,22 @@ CATEGORY_GOAL: colors.goal_purple, } +# Icon codepoints (Material Symbols) -- same values as flet.Icons members. +ICON_RECEIPT_OUTLINED = 71694 +ICON_HANDSHAKE_OUTLINED = 69034 +ICON_WORK_OUTLINE = 74308 +ICON_FLAG_OUTLINED = 68514 +ICON_CANCEL_OUTLINED = 66730 +ICON_CHECK_CIRCLE_OUTLINE = 66871 +ICON_WARNING_AMBER_ROUNDED = 74074 +ICON_SCHEDULE = 72057 +ICON_EMOJI_EVENTS_OUTLINED = 68020 + CATEGORY_ICONS = { - CATEGORY_INVOICE: Icons.RECEIPT_OUTLINED, - CATEGORY_CONTRACT: Icons.HANDSHAKE_OUTLINED, - CATEGORY_PROJECT: Icons.WORK_OUTLINE, - CATEGORY_GOAL: Icons.FLAG_OUTLINED, + CATEGORY_INVOICE: ICON_RECEIPT_OUTLINED, + CATEGORY_CONTRACT: ICON_HANDSHAKE_OUTLINED, + CATEGORY_PROJECT: ICON_WORK_OUTLINE, + CATEGORY_GOAL: ICON_FLAG_OUTLINED, } @@ -54,7 +63,7 @@ class TimelineEvent: title: str description: str category: str - icon: str + icon: int color: str is_future: bool entity_id: Optional[int] = None @@ -127,7 +136,7 @@ def _events_from_invoices(self, today: datetime.date) -> List[TimelineEvent]: title=f"{label} cancelled", description=client_name, category=cat, - icon=Icons.CANCEL_OUTLINED, + icon=ICON_CANCEL_OUTLINED, color=colors.danger, is_future=False, entity_id=inv.id, @@ -142,7 +151,7 @@ def _events_from_invoices(self, today: datetime.date) -> List[TimelineEvent]: title=f"{label} paid", description=desc, category=cat, - icon=Icons.CHECK_CIRCLE_OUTLINE, + icon=ICON_CHECK_CIRCLE_OUTLINE, color=colors.success, is_future=False, entity_id=inv.id, @@ -174,9 +183,9 @@ def _events_from_invoices(self, today: datetime.date) -> List[TimelineEvent]: title=f"{label} {'overdue' if is_overdue else 'due'}", description=desc, category=cat, - icon=Icons.WARNING_AMBER_ROUNDED + icon=ICON_WARNING_AMBER_ROUNDED if is_overdue - else Icons.SCHEDULE, + else ICON_SCHEDULE, color=colors.danger if is_overdue else color, is_future=due > today, entity_id=inv.id, @@ -263,7 +272,7 @@ def _events_from_projects(self, today: datetime.date) -> List[TimelineEvent]: title=f"{label} completed", description=client_name, category=cat, - icon=Icons.CHECK_CIRCLE_OUTLINE, + icon=ICON_CHECK_CIRCLE_OUTLINE, color=colors.success, is_future=False, entity_id=p.id, @@ -303,7 +312,7 @@ def _events_from_goals(self, today: datetime.date) -> List[TimelineEvent]: title=f"{g.title} reached", description=fmt_currency(g.target_amount), category=cat, - icon=Icons.EMOJI_EVENTS_OUTLINED, + icon=ICON_EMOJI_EVENTS_OUTLINED, color=colors.success, is_future=False, entity_id=g.id, diff --git a/tuttle/bridge.py b/tuttle/bridge.py new file mode 100644 index 00000000..0c3aff0f --- /dev/null +++ b/tuttle/bridge.py @@ -0,0 +1,463 @@ +"""Flet-free bridge for the macOS native app. + +Thin serialization layer that delegates to the existing Intent classes +and converts their results to PythonKit-friendly dicts. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from loguru import logger + +from .app.core.formatting import fmt_currency +from .app.clients.intent import ClientsIntent +from .app.contacts.intent import ContactsIntent +from .app.contracts.intent import ContractsIntent +from .app.dashboard.intent import DashboardIntent +from .app.invoicing.data_source import InvoicingDataSource +from .app.projects.intent import ProjectsIntent +from .app.timeline.intent import TimelineIntent +from .migrations.run import run_migrations + + +class TuttleBridge: + """Flet-free service layer for the Swift macOS app.""" + + def __init__(self): + self._dashboard = DashboardIntent() + self._timeline = TimelineIntent() + self._clients = ClientsIntent() + self._contacts = ContactsIntent() + self._contracts = ContractsIntent() + self._projects = ProjectsIntent() + self._invoicing_ds = InvoicingDataSource() + self._db_path = Path.home() / ".tuttle" / "tuttle.db" + + def install_demo_data(self, n_projects: int = 4): + """Reset DB and install demo data. Returns True on success.""" + from . import demo + + try: + if self._db_path.exists(): + self._db_path.unlink() + db_url = f"sqlite:///{self._db_path}" + run_migrations(db_url) + demo.install_demo_data( + n_projects=n_projects, + db_path=str(self._db_path), + on_cache_timetracking_dataframe=lambda _df: None, + ) + self._dashboard = DashboardIntent() + self._timeline = TimelineIntent() + self._clients = ClientsIntent() + self._contacts = ContactsIntent() + self._contracts = ContractsIntent() + self._projects = ProjectsIntent() + self._invoicing_ds = InvoicingDataSource() + return True + except Exception as e: + logger.exception(e) + return False + + # ── Dashboard ────────────────────────────────────────────── + + def get_dashboard_kpis(self) -> dict: + result = self._dashboard.get_kpis() + if not result.was_intent_successful or result.data is None: + result.log_message_if_any() + return {"ok": False, "error": result.error_msg} + kpis = result.data + tc = kpis.tax_currency + return { + "ok": True, + "total_revenue_ytd": float(kpis.total_revenue_ytd), + "outstanding_amount": float(kpis.outstanding_amount), + "overdue_amount": float(kpis.overdue_amount), + "effective_hourly_rate": float(kpis.effective_hourly_rate) + if kpis.effective_hourly_rate + else None, + "utilization_rate": kpis.utilization_rate, + "active_projects": kpis.active_projects, + "active_contracts": kpis.active_contracts, + "unpaid_invoices": kpis.unpaid_invoices, + "vat_reserve": float(kpis.vat_reserve), + "income_tax_reserve": float(kpis.income_tax_reserve), + "spendable_income": float(kpis.spendable_income), + "tax_currency": tc, + "total_revenue_ytd_fmt": fmt_currency(kpis.total_revenue_ytd, tc), + "outstanding_amount_fmt": fmt_currency(kpis.outstanding_amount, tc), + "overdue_amount_fmt": fmt_currency(kpis.overdue_amount, tc), + "effective_hourly_rate_fmt": fmt_currency(kpis.effective_hourly_rate, tc) + if kpis.effective_hourly_rate + else "—", + "vat_reserve_fmt": fmt_currency(kpis.vat_reserve, tc), + "income_tax_reserve_fmt": fmt_currency(kpis.income_tax_reserve, tc), + "spendable_income_fmt": fmt_currency(kpis.spendable_income, tc), + "utilization_rate_fmt": f"{kpis.utilization_rate * 100:.0f}%" + if kpis.utilization_rate is not None + else "—", + } + + def get_monthly_chart_data(self, n_months: int = 12) -> dict: + result = self._dashboard.get_monthly_chart_data(n_months=n_months) + if not result.was_intent_successful or result.data is None: + result.log_message_if_any() + return {"ok": False, "error": result.error_msg} + data = result.data + rev_list = [ + {"month": m["month"], "revenue": float(m["revenue"])} + for m in data["revenue"] + ] + sp_list = [ + {"month": m["month"], "spendable": float(m["spendable"])} + for m in data["spendable"] + ] + return {"ok": True, "revenue": rev_list, "spendable": sp_list} + + def get_project_budgets(self) -> dict: + result = self._dashboard.get_project_budgets() + if not result.was_intent_successful or result.data is None: + result.log_message_if_any() + return {"ok": False, "error": result.error_msg} + return {"ok": True, "budgets": result.data} + + def get_financial_goals(self) -> dict: + result = self._dashboard.get_financial_goals() + if not result.was_intent_successful or result.data is None: + result.log_message_if_any() + return {"ok": False, "error": result.error_msg} + + kpi_result = self._dashboard.get_kpis() + ytd_revenue = 0.0 + tc = "EUR" + if kpi_result.was_intent_successful and kpi_result.data is not None: + ytd_revenue = float(kpi_result.data.total_revenue_ytd) + tc = kpi_result.data.tax_currency + + goals_out = [] + for g in result.data: + target = float(g.target_amount) + progress = min(ytd_revenue / target, 1.0) if target > 0 else 0.0 + goals_out.append( + { + "id": g.id, + "title": g.title, + "target_amount": target, + "target_amount_fmt": fmt_currency(g.target_amount, tc), + "target_date": g.target_date.isoformat(), + "target_date_fmt": g.target_date.strftime("%b %Y"), + "is_reached": g.is_reached, + "progress": progress, + "ytd_revenue_fmt": fmt_currency(ytd_revenue, tc), + } + ) + return {"ok": True, "goals": goals_out, "currency": tc} + + # ── Timeline ─────────────────────────────────────────────── + + def get_timeline_events(self, category_filter: Optional[str] = None) -> dict: + result = self._timeline.get_timeline_events(category_filter=category_filter) + if not result.was_intent_successful or result.data is None: + result.log_message_if_any() + return {"ok": False, "error": result.error_msg} + + events_out = [ + { + "date": e.date.isoformat(), + "title": e.title, + "description": e.description, + "category": e.category, + "icon": e.icon, + "color": e.color, + "status": self._infer_status(e.title), + "is_future": e.is_future, + "entity_id": e.entity_id, + } + for e in result.data + ] + return {"ok": True, "events": events_out} + + @staticmethod + def _infer_status(title: str) -> str: + t = title.lower() + for keyword in ("cancelled", "overdue", "paid", "completed", "reached", "due"): + if keyword in t: + return keyword + return "default" + + # ── Contacts ─────────────────────────────────────────────── + + def get_all_contacts(self) -> dict: + result = self._contacts.get_all() + if not result.was_intent_successful or result.data is None: + result.log_message_if_any() + return {"ok": False, "error": result.error_msg} + contacts = [] + for c in result.data: + addr = c.address + contacts.append( + { + "id": c.id, + "first_name": c.first_name or "", + "last_name": c.last_name or "", + "company": c.company or "", + "email": c.email or "", + "street": addr.street if addr else "", + "city": addr.city if addr else "", + "postal_code": addr.postal_code if addr else "", + "country": addr.country if addr else "", + } + ) + return {"ok": True, "contacts": contacts} + + def delete_contact(self, contact_id: int) -> dict: + result = self._contacts.delete(contact_id) + if not result.was_intent_successful: + return {"ok": False, "error": result.error_msg} + return {"ok": True} + + # ── Clients ──────────────────────────────────────────────── + + def get_all_clients(self) -> dict: + result = self._clients.get_all() + if not result.was_intent_successful or result.data is None: + result.log_message_if_any() + return {"ok": False, "error": result.error_msg} + clients = [] + for cl in result.data: + contact = cl.invoicing_contact + n_contracts = len(cl.contracts) if cl.contracts else 0 + clients.append( + { + "id": cl.id, + "name": cl.name, + "contact_name": contact.name if contact else "", + "contact_email": contact.email if contact else "", + "contact_company": contact.company if contact else "", + "contact_city": contact.address.city + if contact and contact.address + else "", + "contact_country": contact.address.country + if contact and contact.address + else "", + "num_contracts": n_contracts, + } + ) + return {"ok": True, "clients": clients} + + def delete_client(self, client_id: int) -> dict: + result = self._clients.delete(client_id) + if not result.was_intent_successful: + return {"ok": False, "error": result.error_msg} + return {"ok": True} + + # ── Contracts ────────────────────────────────────────────── + + def get_all_contracts(self) -> dict: + result = self._contracts.get_all() + if not result.was_intent_successful or result.data is None: + result.log_message_if_any() + return {"ok": False, "error": result.error_msg} + contracts = [] + for c in result.data: + client_name = c.client.name if c.client else "—" + contracts.append( + { + "id": c.id, + "title": c.title, + "client_name": client_name, + "status": c.get_status(), + "start_date": c.start_date.isoformat(), + "end_date": c.end_date.isoformat() if c.end_date else None, + "rate": float(c.rate), + "rate_fmt": fmt_currency(c.rate, c.currency), + "currency": c.currency, + "unit": c.unit.value if c.unit else "hour", + "volume": c.volume, + "billing_cycle": c.billing_cycle.value if c.billing_cycle else "", + "is_completed": c.is_completed, + "vat_rate": float(c.VAT_rate), + "num_projects": len(c.projects) if c.projects else 0, + "num_invoices": len(c.invoices) if c.invoices else 0, + } + ) + return {"ok": True, "contracts": contracts} + + def delete_contract(self, contract_id: int) -> dict: + result = self._contracts.delete(contract_id) + if not result.was_intent_successful: + return {"ok": False, "error": result.error_msg} + return {"ok": True} + + def toggle_contract_completed(self, contract_id: int) -> dict: + result = self._contracts.get_by_id(contract_id) + if not result.was_intent_successful or result.data is None: + return {"ok": False, "error": result.error_msg} + toggle_result = self._contracts.toggle_complete_status(result.data) + if not toggle_result.was_intent_successful: + return {"ok": False, "error": toggle_result.error_msg} + return {"ok": True} + + # ── Projects ─────────────────────────────────────────────── + + def get_all_projects(self) -> dict: + result = self._projects.get_all() + if not result.was_intent_successful or result.data is None: + result.log_message_if_any() + return {"ok": False, "error": result.error_msg} + projects = [] + for p in result.data: + client_name = "" + contract_title = "" + if p.contract: + contract_title = p.contract.title + if p.contract.client: + client_name = p.contract.client.name + projects.append( + { + "id": p.id, + "title": p.title, + "tag": p.tag, + "description": p.description, + "client_name": client_name, + "contract_title": contract_title, + "status": p.get_status(), + "start_date": p.start_date.isoformat(), + "end_date": p.end_date.isoformat() if p.end_date else None, + "is_completed": p.is_completed, + "num_invoices": len(p.invoices) if p.invoices else 0, + "num_timesheets": len(p.timesheets) if p.timesheets else 0, + } + ) + return {"ok": True, "projects": projects} + + def delete_project(self, project_id: int) -> dict: + result = self._projects.delete(project_id) + if not result.was_intent_successful: + return {"ok": False, "error": result.error_msg} + return {"ok": True} + + def toggle_project_completed(self, project_id: int) -> dict: + result = self._projects.get_by_id(project_id) + if not result.was_intent_successful or result.data is None: + return {"ok": False, "error": result.error_msg} + toggle_result = self._projects.toggle_project_completed_status(result.data) + if not toggle_result.was_intent_successful: + return {"ok": False, "error": toggle_result.error_msg} + return {"ok": True} + + # ── Invoicing ───────────────────────────────────────────── + + def get_all_invoices(self) -> dict: + result = self._invoicing_ds.get_all_invoices() + if not result.was_intent_successful or result.data is None: + result.log_message_if_any() + return {"ok": False, "error": result.error_msg} + invoices = [] + for inv in result.data: + client_name = "" + if inv.contract and inv.contract.client: + client_name = inv.contract.client.name + project_title = inv.project.title if inv.project else "" + contract_title = inv.contract.title if inv.contract else "" + currency = inv.contract.currency if inv.contract else "EUR" + + status = "draft" + if inv.cancelled: + status = "cancelled" + elif inv.paid: + status = "paid" + elif inv.sent: + due = inv.due_date + if due and due < __import__("datetime").date.today(): + status = "overdue" + else: + status = "sent" + + items_out = [] + for item in inv.items or []: + items_out.append( + { + "id": item.id, + "description": item.description, + "quantity": float(item.quantity), + "unit": item.unit, + "unit_price": float(item.unit_price), + "unit_price_fmt": fmt_currency(item.unit_price, currency), + "vat_rate": float(item.VAT_rate), + "subtotal": float(item.subtotal), + "subtotal_fmt": fmt_currency(item.subtotal, currency), + "start_date": item.start_date.isoformat(), + "end_date": item.end_date.isoformat() + if item.end_date + else None, + } + ) + + invoices.append( + { + "id": inv.id, + "number": inv.number or "", + "date": inv.date.isoformat(), + "client_name": client_name, + "project_title": project_title, + "contract_title": contract_title, + "currency": currency, + "subtotal": float(inv.sum), + "subtotal_fmt": fmt_currency(inv.sum, currency), + "vat_total": float(inv.VAT_total), + "vat_total_fmt": fmt_currency(inv.VAT_total, currency), + "total": float(inv.total), + "total_fmt": fmt_currency(inv.total, currency), + "status": status, + "sent": bool(inv.sent), + "paid": bool(inv.paid), + "cancelled": bool(inv.cancelled), + "rendered": bool(inv.rendered), + "due_date": inv.due_date.isoformat() if inv.due_date else None, + "items": items_out, + } + ) + return {"ok": True, "invoices": invoices} + + def delete_invoice(self, invoice_id: int) -> dict: + try: + self._invoicing_ds.delete_invoice_by_id(invoice_id) + return {"ok": True} + except Exception as e: + logger.exception(e) + return {"ok": False, "error": str(e)} + + def _get_invoice_by_id(self, invoice_id: int): + """Load a single Invoice from DB by primary key.""" + from .model import Invoice + + with self._invoicing_ds.create_session() as session: + inv = session.get(Invoice, invoice_id) + return inv + + def toggle_invoice_sent(self, invoice_id: int) -> dict: + inv = self._get_invoice_by_id(invoice_id) + if inv is None: + return {"ok": False, "error": "Invoice not found"} + inv.sent = not inv.sent + self._invoicing_ds.save_invoice(inv) + return {"ok": True} + + def toggle_invoice_paid(self, invoice_id: int) -> dict: + inv = self._get_invoice_by_id(invoice_id) + if inv is None: + return {"ok": False, "error": "Invoice not found"} + inv.paid = not inv.paid + self._invoicing_ds.save_invoice(inv) + return {"ok": True} + + def toggle_invoice_cancelled(self, invoice_id: int) -> dict: + inv = self._get_invoice_by_id(invoice_id) + if inv is None: + return {"ok": False, "error": "Invoice not found"} + inv.cancelled = not inv.cancelled + self._invoicing_ds.save_invoice(inv) + return {"ok": True} From 71cd45ffbf80e34f610d566f2e59eb1cc86e7703 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sun, 10 May 2026 11:31:59 +0200 Subject: [PATCH 2/4] Refactor data models to use a generic Entity structure - Replaced specific model types (ClientModel, ContactModel, etc.) with a generic Entity type across various view models and views. - Updated data fetching and processing logic to accommodate the new Entity structure, enhancing flexibility and reducing redundancy. - Adjusted UI components to access properties dynamically from the Entity type, improving maintainability. - Refactored PythonBridge to streamline interactions with Python data, ensuring consistent handling of model data across the application. --- .../Sources/TuttleMac/Models/AppModels.swift | 633 +++++------------- .../TuttleMac/Python/PythonBridge.swift | 178 ++++- .../ViewModels/BusinessViewModel.swift | 158 +++-- .../ViewModels/DashboardViewModel.swift | 112 +++- .../ViewModels/InvoicingViewModel.swift | 86 ++- .../ViewModels/TimelineViewModel.swift | 67 +- .../Views/Business/ClientsView.swift | 33 +- .../Views/Business/ContactsView.swift | 25 +- .../Views/Business/ContractsView.swift | 55 +- .../Views/Business/ProjectsView.swift | 47 +- .../Views/Dashboard/DashboardView.swift | 69 +- .../Views/Invoicing/InvoicingView.swift | 142 ++-- .../Views/Timeline/TimelineView.swift | 33 +- tuttle/app/dashboard/intent.py | 23 +- tuttle/bridge.py | 463 ------------- tuttle/model.py | 14 + 16 files changed, 813 insertions(+), 1325 deletions(-) delete mode 100644 tuttle/bridge.py diff --git a/TuttleMac/Sources/TuttleMac/Models/AppModels.swift b/TuttleMac/Sources/TuttleMac/Models/AppModels.swift index 0382f1e1..9e7651a4 100644 --- a/TuttleMac/Sources/TuttleMac/Models/AppModels.swift +++ b/TuttleMac/Sources/TuttleMac/Models/AppModels.swift @@ -1,281 +1,181 @@ import Foundation import SwiftUI -import PythonKit - -// MARK: - KPI Summary - -struct KPISummary { - let totalRevenueYTD: Double - let totalRevenueYTDFormatted: String - let outstandingAmount: Double - let outstandingAmountFormatted: String - let overdueAmount: Double - let overdueAmountFormatted: String - let effectiveHourlyRate: Double? - let effectiveHourlyRateFormatted: String - let utilizationRate: Double? - let utilizationRateFormatted: String - let activeProjects: Int - let activeContracts: Int - let unpaidInvoices: Int - let vatReserve: Double - let vatReserveFormatted: String - let incomeTaxReserve: Double - let incomeTaxReserveFormatted: String - let spendableIncome: Double - let spendableIncomeFormatted: String - let taxCurrency: String - - static func from(_ d: PythonObject) -> KPISummary { - KPISummary( - totalRevenueYTD: PythonBridge.double(d, key: "total_revenue_ytd"), - totalRevenueYTDFormatted: PythonBridge.string(d, key: "total_revenue_ytd_fmt"), - outstandingAmount: PythonBridge.double(d, key: "outstanding_amount"), - outstandingAmountFormatted: PythonBridge.string(d, key: "outstanding_amount_fmt"), - overdueAmount: PythonBridge.double(d, key: "overdue_amount"), - overdueAmountFormatted: PythonBridge.string(d, key: "overdue_amount_fmt"), - effectiveHourlyRate: { - let v = d["effective_hourly_rate"] - return v == Python.None ? nil : Double(v) - }(), - effectiveHourlyRateFormatted: PythonBridge.string(d, key: "effective_hourly_rate_fmt"), - utilizationRate: { - let v = d["utilization_rate"] - return v == Python.None ? nil : Double(v) - }(), - utilizationRateFormatted: PythonBridge.string(d, key: "utilization_rate_fmt"), - activeProjects: PythonBridge.int(d, key: "active_projects"), - activeContracts: PythonBridge.int(d, key: "active_contracts"), - unpaidInvoices: PythonBridge.int(d, key: "unpaid_invoices"), - vatReserve: PythonBridge.double(d, key: "vat_reserve"), - vatReserveFormatted: PythonBridge.string(d, key: "vat_reserve_fmt"), - incomeTaxReserve: PythonBridge.double(d, key: "income_tax_reserve"), - incomeTaxReserveFormatted: PythonBridge.string(d, key: "income_tax_reserve_fmt"), - spendableIncome: PythonBridge.double(d, key: "spendable_income"), - spendableIncomeFormatted: PythonBridge.string(d, key: "spendable_income_fmt"), - taxCurrency: PythonBridge.string(d, key: "tax_currency", fallback: "EUR") - ) - } -} -// MARK: - Monthly Chart Data +// MARK: - Entity (generic wrapper for Python model data) -struct MonthlyDataPoint: Identifiable { - let id = UUID() - let month: String - let label: String // short label like "05/25" - let value: Double -} +@dynamicMemberLookup +struct Entity: Identifiable, Hashable { + let data: [String: Any] -// MARK: - Project Budget + var id: Int { data["id"] as? Int ?? UUID().hashValue } -struct ProjectBudget: Identifiable { - let id = UUID() - let project: String - let hoursTracked: Double - let hoursBudget: Double - let progress: Double - - static func from(_ d: PythonObject) -> ProjectBudget { - ProjectBudget( - project: PythonBridge.string(d, key: "project", fallback: "Project"), - hoursTracked: PythonBridge.double(d, key: "hours_tracked"), - hoursBudget: PythonBridge.double(d, key: "hours_budget"), - progress: PythonBridge.double(d, key: "progress") - ) + static func == (lhs: Entity, rhs: Entity) -> Bool { lhs.id == rhs.id } + func hash(into hasher: inout Hasher) { hasher.combine(id) } + + subscript(dynamicMember key: String) -> String { + str(key) } -} -// MARK: - Financial Goal - -struct FinancialGoalModel: Identifiable { - let id: Int - let title: String - let targetAmount: Double - let targetAmountFormatted: String - let targetDate: String - let targetDateFormatted: String - let isReached: Bool - let progress: Double - let ytdRevenueFormatted: String - - static func from(_ d: PythonObject) -> FinancialGoalModel { - FinancialGoalModel( - id: PythonBridge.int(d, key: "id"), - title: PythonBridge.string(d, key: "title"), - targetAmount: PythonBridge.double(d, key: "target_amount"), - targetAmountFormatted: PythonBridge.string(d, key: "target_amount_fmt"), - targetDate: PythonBridge.string(d, key: "target_date"), - targetDateFormatted: PythonBridge.string(d, key: "target_date_fmt"), - isReached: PythonBridge.bool(d, key: "is_reached"), - progress: PythonBridge.double(d, key: "progress"), - ytdRevenueFormatted: PythonBridge.string(d, key: "ytd_revenue_fmt") - ) + func str(_ key: String) -> String { + guard let val = data[key] else { return "" } + if let s = val as? String { return s } + if let n = val as? Int { return String(n) } + if let d = val as? Double { return String(d) } + if let b = val as? Bool { return b ? "true" : "false" } + return String(describing: val) } -} -// MARK: - Timeline Event + func num(_ key: String) -> Double { + guard let val = data[key] else { return 0 } + if let d = val as? Double { return d } + if let i = val as? Int { return Double(i) } + if let s = val as? String { return Double(s) ?? 0 } + return 0 + } -enum TimelineCategory: String, CaseIterable, Identifiable { - case all = "all" - case invoice = "invoice" - case contract = "contract" - case project = "project" - case goal = "goal" + func int(_ key: String) -> Int { + guard let val = data[key] else { return 0 } + if let i = val as? Int { return i } + if let d = val as? Double { return Int(d) } + if let s = val as? String { return Int(s) ?? 0 } + return 0 + } - var id: String { rawValue } + func bool(_ key: String) -> Bool { + guard let val = data[key] else { return false } + if let b = val as? Bool { return b } + if let i = val as? Int { return i != 0 } + return false + } - var label: String { - switch self { - case .all: "All" - case .invoice: "Invoices" - case .contract: "Contracts" - case .project: "Projects" - case .goal: "Goals" - } + func date(_ key: String) -> Date? { + data[key] as? Date } - var systemImage: String { - switch self { - case .all: "list.bullet" - case .invoice: "doc.text" - case .contract: "signature" - case .project: "folder" - case .goal: "flag" - } + func entity(_ key: String) -> Entity? { + guard let d = data[key] as? [String: Any] else { return nil } + return Entity(data: d) } -} -struct TimelineEvent: Identifiable { - let id = UUID() - let date: Date - let dateFormatted: String - let title: String - let description: String - let category: TimelineCategory - let status: String - let isFuture: Bool - let entityId: Int? + func list(_ key: String) -> [Entity] { + guard let arr = data[key] as? [[String: Any]] else { return [] } + return arr.map { Entity(data: $0) } + } - var monthKey: String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM" - return formatter.string(from: date) + func has(_ key: String) -> Bool { + data[key] != nil } - var monthLabel: String { - let formatter = DateFormatter() - formatter.dateFormat = "MMMM yyyy" - return formatter.string(from: date) - } - - static func from(_ d: PythonObject) -> TimelineEvent? { - let dateStr = PythonBridge.string(d, key: "date") - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - guard let date = formatter.date(from: dateStr) else { return nil } - - let catStr = PythonBridge.string(d, key: "category") - let category = TimelineCategory(rawValue: catStr) ?? .invoice - - let displayFormatter = DateFormatter() - displayFormatter.dateFormat = "MMM d, yyyy" - - return TimelineEvent( - date: date, - dateFormatted: displayFormatter.string(from: date), - title: PythonBridge.string(d, key: "title"), - description: PythonBridge.string(d, key: "description", fallback: ""), - category: category, - status: PythonBridge.string(d, key: "status", fallback: "default"), - isFuture: PythonBridge.bool(d, key: "is_future"), - entityId: { - let v = d["entity_id"] - return v == Python.None ? nil : Int(v) - }() - ) + func optStr(_ key: String) -> String? { + guard let val = data[key] else { return nil } + if let s = val as? String { return s } + return nil + } + + func optInt(_ key: String) -> Int? { + guard let val = data[key] else { return nil } + if let i = val as? Int { return i } + if let d = val as? Double { return Int(d) } + return nil } -} -// MARK: - Contact + func optNum(_ key: String) -> Double? { + guard let val = data[key] else { return nil } + if let d = val as? Double { return d } + if let i = val as? Int { return Double(i) } + return nil + } +} -struct ContactModel: Identifiable { - let id: Int - let firstName: String - let lastName: String - let company: String - let email: String - let city: String - let country: String +// MARK: - Entity Display Extensions +extension Entity { var fullName: String { - [firstName, lastName].filter { !$0.isEmpty }.joined(separator: " ") + [str("first_name"), str("last_name")].filter { !$0.isEmpty }.joined(separator: " ") } var displayName: String { let name = fullName if !name.isEmpty { return name } + let company = str("company") if !company.isEmpty { return company } return "—" } + var initials: String { + let parts = [str("first_name"), str("last_name")].filter { !$0.isEmpty } + if parts.isEmpty { + let name = str("name") + if !name.isEmpty { + let words = name.split(separator: " ") + return words.prefix(2).map { String($0.prefix(1)).uppercased() }.joined() + } + return "?" + } + return parts.map { String($0.prefix(1)).uppercased() }.joined() + } + var location: String { - [city, country].filter { !$0.isEmpty }.joined(separator: ", ") + [str("city"), str("country")].filter { !$0.isEmpty }.joined(separator: ", ") } - var initials: String { - let parts = [firstName, lastName].filter { !$0.isEmpty } - if parts.isEmpty { return "?" } - return parts.map { String($0.prefix(1)).uppercased() }.joined() + var dateRange: String { + let start = str("start_date") + let end = optStr("end_date") + if let end, !end.isEmpty { + return "\(Self.formatDate(start)) – \(Self.formatDate(end))" + } + if !start.isEmpty { + return "From \(Self.formatDate(start))" + } + return "" } - static func from(_ d: PythonObject) -> ContactModel { - ContactModel( - id: PythonBridge.int(d, key: "id"), - firstName: PythonBridge.string(d, key: "first_name", fallback: ""), - lastName: PythonBridge.string(d, key: "last_name", fallback: ""), - company: PythonBridge.string(d, key: "company", fallback: ""), - email: PythonBridge.string(d, key: "email", fallback: ""), - city: PythonBridge.string(d, key: "city", fallback: ""), - country: PythonBridge.string(d, key: "country", fallback: "") - ) + var entityStatus: EntityStatus { + EntityStatus(rawValue: str("status")) ?? .all + } + + var invoiceStatus: InvoiceStatus { + InvoiceStatus(rawValue: str("status").capitalized) ?? .draft + } + + var vatPercent: String { + String(format: "%.0f%%", num("VAT_rate") * 100) } -} -// MARK: - Client + var monthKey: String { + guard let d = date("_date") else { return "" } + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM" + return fmt.string(from: d) + } -struct ClientModel: Identifiable { - let id: Int - let name: String - let contactName: String - let contactEmail: String - let contactCompany: String - let location: String - let numContracts: Int + var monthLabel: String { + guard let d = date("_date") else { return "" } + let fmt = DateFormatter() + fmt.dateFormat = "MMMM yyyy" + return fmt.string(from: d) + } - var initials: String { - let words = name.split(separator: " ") - if words.isEmpty { return "?" } - return words.prefix(2).map { String($0.prefix(1)).uppercased() }.joined() - } - - static func from(_ d: PythonObject) -> ClientModel { - let city = PythonBridge.string(d, key: "contact_city", fallback: "") - let country = PythonBridge.string(d, key: "contact_country", fallback: "") - let loc = [city, country].filter { !$0.isEmpty }.joined(separator: ", ") - return ClientModel( - id: PythonBridge.int(d, key: "id"), - name: PythonBridge.string(d, key: "name"), - contactName: PythonBridge.string(d, key: "contact_name", fallback: ""), - contactEmail: PythonBridge.string(d, key: "contact_email", fallback: ""), - contactCompany: PythonBridge.string(d, key: "contact_company", fallback: ""), - location: loc, - numContracts: PythonBridge.int(d, key: "num_contracts") - ) + private static func formatDate(_ iso: String) -> String { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + guard let d = fmt.date(from: iso) else { return iso } + fmt.dateFormat = "MMM d, yyyy" + return fmt.string(from: d) } } -// MARK: - Contract +// MARK: - Monthly Chart Data (view-specific, not a Python model) + +struct MonthlyDataPoint: Identifiable { + let id = UUID() + let month: String + let label: String + let value: Double +} + +// MARK: - Entity Status enum EntityStatus: String { case active = "Active" @@ -302,123 +202,6 @@ enum EntityStatus: String { } } -struct ContractModel: Identifiable { - let id: Int - let title: String - let clientName: String - let status: EntityStatus - let startDate: String - let endDate: String? - let rate: Double - let rateFormatted: String - let currency: String - let unit: String - let volume: Int? - let billingCycle: String - let isCompleted: Bool - let vatRate: Double - let numProjects: Int - let numInvoices: Int - - var dateRange: String { - if let end = endDate { - return "\(Self.formatDate(startDate)) – \(Self.formatDate(end))" - } - return "From \(Self.formatDate(startDate))" - } - - private static func formatDate(_ iso: String) -> String { - let fmt = DateFormatter() - fmt.dateFormat = "yyyy-MM-dd" - guard let d = fmt.date(from: iso) else { return iso } - fmt.dateFormat = "MMM d, yyyy" - return fmt.string(from: d) - } - - static func from(_ d: PythonObject) -> ContractModel { - let statusStr = PythonBridge.string(d, key: "status", fallback: "All") - let status = EntityStatus(rawValue: statusStr) ?? .all - let endVal = d.checking["end_date"] - let endDate: String? = (endVal != nil && endVal != Python.None) - ? String(endVal!) : nil - return ContractModel( - id: PythonBridge.int(d, key: "id"), - title: PythonBridge.string(d, key: "title"), - clientName: PythonBridge.string(d, key: "client_name"), - status: status, - startDate: PythonBridge.string(d, key: "start_date"), - endDate: endDate, - rate: PythonBridge.double(d, key: "rate"), - rateFormatted: PythonBridge.string(d, key: "rate_fmt"), - currency: PythonBridge.string(d, key: "currency", fallback: "EUR"), - unit: PythonBridge.string(d, key: "unit", fallback: "hour"), - volume: { - guard let v = d.checking["volume"], v != Python.None else { return nil } - return Int(v) - }(), - billingCycle: PythonBridge.string(d, key: "billing_cycle", fallback: ""), - isCompleted: PythonBridge.bool(d, key: "is_completed"), - vatRate: PythonBridge.double(d, key: "vat_rate"), - numProjects: PythonBridge.int(d, key: "num_projects"), - numInvoices: PythonBridge.int(d, key: "num_invoices") - ) - } -} - -// MARK: - Project - -struct ProjectModel: Identifiable { - let id: Int - let title: String - let tag: String - let description: String - let clientName: String - let contractTitle: String - let status: EntityStatus - let startDate: String - let endDate: String? - let isCompleted: Bool - let numInvoices: Int - let numTimesheets: Int - - var dateRange: String { - if let end = endDate { - return "\(Self.formatDate(startDate)) – \(Self.formatDate(end))" - } - return "From \(Self.formatDate(startDate))" - } - - private static func formatDate(_ iso: String) -> String { - let fmt = DateFormatter() - fmt.dateFormat = "yyyy-MM-dd" - guard let d = fmt.date(from: iso) else { return iso } - fmt.dateFormat = "MMM d, yyyy" - return fmt.string(from: d) - } - - static func from(_ d: PythonObject) -> ProjectModel { - let statusStr = PythonBridge.string(d, key: "status", fallback: "") - let status = EntityStatus(rawValue: statusStr) ?? .all - let endVal = d.checking["end_date"] - let endDate: String? = (endVal != nil && endVal != Python.None) - ? String(endVal!) : nil - return ProjectModel( - id: PythonBridge.int(d, key: "id"), - title: PythonBridge.string(d, key: "title"), - tag: PythonBridge.string(d, key: "tag", fallback: ""), - description: PythonBridge.string(d, key: "description", fallback: ""), - clientName: PythonBridge.string(d, key: "client_name", fallback: ""), - contractTitle: PythonBridge.string(d, key: "contract_title", fallback: ""), - status: status, - startDate: PythonBridge.string(d, key: "start_date"), - endDate: endDate, - isCompleted: PythonBridge.bool(d, key: "is_completed"), - numInvoices: PythonBridge.int(d, key: "num_invoices"), - numTimesheets: PythonBridge.int(d, key: "num_timesheets") - ) - } -} - // MARK: - Invoice Status enum InvoiceStatus: String, CaseIterable { @@ -452,144 +235,38 @@ enum InvoiceStatus: String, CaseIterable { } } -// MARK: - Invoice Item - -struct InvoiceItemModel: Identifiable { - let id: Int - let description: String - let quantity: Double - let unit: String - let unitPrice: Double - let unitPriceFormatted: String - let vatRate: Double - let subtotal: Double - let subtotalFormatted: String - let startDate: String - let endDate: String? - - var vatPercent: String { - String(format: "%.0f%%", vatRate * 100) - } - - var dateRange: String { - if let end = endDate { - return "\(Self.fmtDate(startDate)) – \(Self.fmtDate(end))" - } - return Self.fmtDate(startDate) - } +// MARK: - Timeline Category - private static func fmtDate(_ iso: String) -> String { - let fmt = DateFormatter() - fmt.dateFormat = "yyyy-MM-dd" - guard let d = fmt.date(from: iso) else { return iso } - fmt.dateFormat = "MMM d, yyyy" - return fmt.string(from: d) - } +enum TimelineCategory: String, CaseIterable, Identifiable { + case all = "all" + case invoice = "invoice" + case contract = "contract" + case project = "project" + case goal = "goal" - static func from(_ d: PythonObject) -> InvoiceItemModel { - let endVal = d.checking["end_date"] - let endDate: String? = (endVal != nil && endVal != Python.None) - ? String(endVal!) : nil - return InvoiceItemModel( - id: PythonBridge.int(d, key: "id"), - description: PythonBridge.string(d, key: "description"), - quantity: PythonBridge.double(d, key: "quantity"), - unit: PythonBridge.string(d, key: "unit", fallback: "hour"), - unitPrice: PythonBridge.double(d, key: "unit_price"), - unitPriceFormatted: PythonBridge.string(d, key: "unit_price_fmt"), - vatRate: PythonBridge.double(d, key: "vat_rate"), - subtotal: PythonBridge.double(d, key: "subtotal"), - subtotalFormatted: PythonBridge.string(d, key: "subtotal_fmt"), - startDate: PythonBridge.string(d, key: "start_date"), - endDate: endDate - ) - } -} + var id: String { rawValue } -// MARK: - Invoice - -struct InvoiceModel: Identifiable { - let id: Int - let number: String - let date: String - let clientName: String - let projectTitle: String - let contractTitle: String - let currency: String - let subtotal: Double - let subtotalFormatted: String - let vatTotal: Double - let vatTotalFormatted: String - let total: Double - let totalFormatted: String - let status: InvoiceStatus - let sent: Bool - let paid: Bool - let cancelled: Bool - let rendered: Bool - let dueDate: String? - let items: [InvoiceItemModel] - - var dateFormatted: String { Self.fmtDate(date) } - - var dueDateFormatted: String? { - guard let d = dueDate else { return nil } - return Self.fmtDate(d) - } - - private static func fmtDate(_ iso: String) -> String { - let fmt = DateFormatter() - fmt.dateFormat = "yyyy-MM-dd" - guard let d = fmt.date(from: iso) else { return iso } - fmt.dateFormat = "MMM d, yyyy" - return fmt.string(from: d) + var label: String { + switch self { + case .all: "All" + case .invoice: "Invoices" + case .contract: "Contracts" + case .project: "Projects" + case .goal: "Goals" + } } - static func from(_ d: PythonObject) -> InvoiceModel { - let statusStr = PythonBridge.string(d, key: "status", fallback: "draft") - let status = InvoiceStatus(rawValue: statusStr.capitalized) ?? .draft - - let dueDateVal = d.checking["due_date"] - let dueDate: String? = (dueDateVal != nil && dueDateVal != Python.None) - ? String(dueDateVal!) : nil - - var items: [InvoiceItemModel] = [] - if let itemsList = d.checking["items"], itemsList != Python.None { - for item in itemsList { - items.append(InvoiceItemModel.from(item)) - } + var systemImage: String { + switch self { + case .all: "list.bullet" + case .invoice: "doc.text" + case .contract: "signature" + case .project: "folder" + case .goal: "flag" } - - return InvoiceModel( - id: PythonBridge.int(d, key: "id"), - number: PythonBridge.string(d, key: "number"), - date: PythonBridge.string(d, key: "date"), - clientName: PythonBridge.string(d, key: "client_name"), - projectTitle: PythonBridge.string(d, key: "project_title"), - contractTitle: PythonBridge.string(d, key: "contract_title"), - currency: PythonBridge.string(d, key: "currency", fallback: "EUR"), - subtotal: PythonBridge.double(d, key: "subtotal"), - subtotalFormatted: PythonBridge.string(d, key: "subtotal_fmt"), - vatTotal: PythonBridge.double(d, key: "vat_total"), - vatTotalFormatted: PythonBridge.string(d, key: "vat_total_fmt"), - total: PythonBridge.double(d, key: "total"), - totalFormatted: PythonBridge.string(d, key: "total_fmt"), - status: status, - sent: PythonBridge.bool(d, key: "sent"), - paid: PythonBridge.bool(d, key: "paid"), - cancelled: PythonBridge.bool(d, key: "cancelled"), - rendered: PythonBridge.bool(d, key: "rendered"), - dueDate: dueDate, - items: items - ) } } -extension InvoiceModel: Hashable { - static func == (lhs: InvoiceModel, rhs: InvoiceModel) -> Bool { lhs.id == rhs.id } - func hash(into hasher: inout Hasher) { hasher.combine(id) } -} - // MARK: - Sidebar enum SidebarItem: String, CaseIterable, Identifiable { diff --git a/TuttleMac/Sources/TuttleMac/Python/PythonBridge.swift b/TuttleMac/Sources/TuttleMac/Python/PythonBridge.swift index f075abce..e2122232 100644 --- a/TuttleMac/Sources/TuttleMac/Python/PythonBridge.swift +++ b/TuttleMac/Sources/TuttleMac/Python/PythonBridge.swift @@ -1,12 +1,23 @@ import Foundation import PythonKit -/// Manages the embedded Python interpreter and provides access to the tuttle bridge module. -/// All Python calls happen on a single dedicated thread to satisfy CPython's GIL requirements. +/// Manages the embedded Python interpreter. Exposes tuttle intents directly. +/// All Python calls happen on a single dedicated thread to satisfy CPython's GIL. final class PythonBridge { static let shared = PythonBridge() - private var _bridge: PythonObject! + // Intents -- Swift calls these directly, no intermediary + private(set) var contacts: PythonObject! + private(set) var clients: PythonObject! + private(set) var contracts: PythonObject! + private(set) var projects: PythonObject! + private(set) var dashboard: PythonObject! + private(set) var timeline: PythonObject! + private(set) var invoicingDS: PythonObject! // InvoicingDataSource for reads + private(set) var invoicing: PythonObject! // InvoicingIntent for mutations + private(set) var demo: PythonObject! + private(set) var fmtCurrency: PythonObject! + private var _runLoop: CFRunLoop! private let _thread: Thread @@ -18,7 +29,6 @@ final class PythonBridge { _thread = Thread { capturedRunLoop = CFRunLoopGetCurrent() - // A run loop needs at least one source to stay alive let keepAlive = NSMachPort() RunLoop.current.add(keepAlive, forMode: .default) readySem.signal() @@ -31,28 +41,34 @@ final class PythonBridge { readySem.wait() _runLoop = capturedRunLoop - // Initialize Python on the dedicated thread - var bridge: PythonObject! - CFRunLoopPerformBlock(_runLoop, CFRunLoopMode.defaultMode.rawValue) { + CFRunLoopPerformBlock(_runLoop, CFRunLoopMode.defaultMode.rawValue) { [self] in let projectRoot = PythonBridge.findProjectRoot() PythonBridge.configurePythonEnvironment(projectRoot: projectRoot) let sys = Python.import("sys") sys.path.insert(0, projectRoot) - bridge = Python.import("tuttle.bridge").TuttleBridge() + + self.contacts = Python.import("tuttle.app.contacts.intent").ContactsIntent() + self.clients = Python.import("tuttle.app.clients.intent").ClientsIntent() + self.contracts = Python.import("tuttle.app.contracts.intent").ContractsIntent() + self.projects = Python.import("tuttle.app.projects.intent").ProjectsIntent() + self.dashboard = Python.import("tuttle.app.dashboard.intent").DashboardIntent() + self.timeline = Python.import("tuttle.app.timeline.intent").TimelineIntent() + self.invoicingDS = Python.import("tuttle.app.invoicing.data_source").InvoicingDataSource() + self.invoicing = Python.import("tuttle.app.invoicing.intent").InvoicingIntent(client_storage: Python.None) + self.demo = Python.import("tuttle.demo") + self.fmtCurrency = Python.import("tuttle.app.core.formatting").fmt_currency + initSem.signal() } CFRunLoopWakeUp(_runLoop) initSem.wait() - _bridge = bridge } - /// Execute `work` on the dedicated Python thread, then deliver the result on the main thread. - /// The `work` closure MUST convert all PythonObjects to Swift types before returning -- - /// the returned value must not contain any PythonObject references. - func run(_ work: @escaping (PythonObject) -> T, completion: @escaping (T) -> Void) { - let bridge = _bridge! + /// Execute `work` on the dedicated Python thread, deliver result on main thread. + /// The closure MUST convert all PythonObjects to Swift types before returning. + func run(_ work: @escaping () -> T, completion: @escaping (T) -> Void) { CFRunLoopPerformBlock(_runLoop, CFRunLoopMode.defaultMode.rawValue) { - let result = work(bridge) + let result = work() DispatchQueue.main.async { completion(result) } @@ -60,7 +76,33 @@ final class PythonBridge { CFRunLoopWakeUp(_runLoop) } - // MARK: - Python Environment Configuration + /// Reinstall demo data, then re-create all intents. + func installDemoData(nProjects: Int = 4, completion: @escaping (Bool) -> Void) { + run({ + let pathlib = Python.import("pathlib") + let dbPath = pathlib.Path.home() / ".tuttle" / "tuttle.db" + if Bool(dbPath.exists())! { dbPath.unlink() } + let migrations = Python.import("tuttle.migrations.run") + migrations.run_migrations("sqlite:///\(dbPath)") + PythonBridge.shared.demo.install_demo_data( + n_projects: nProjects, + db_path: String(dbPath)!, + on_cache_timetracking_dataframe: Python.None + ) + // Re-create intents so they pick up the new DB + PythonBridge.shared.contacts = Python.import("tuttle.app.contacts.intent").ContactsIntent() + PythonBridge.shared.clients = Python.import("tuttle.app.clients.intent").ClientsIntent() + PythonBridge.shared.contracts = Python.import("tuttle.app.contracts.intent").ContractsIntent() + PythonBridge.shared.projects = Python.import("tuttle.app.projects.intent").ProjectsIntent() + PythonBridge.shared.dashboard = Python.import("tuttle.app.dashboard.intent").DashboardIntent() + PythonBridge.shared.timeline = Python.import("tuttle.app.timeline.intent").TimelineIntent() + PythonBridge.shared.invoicingDS = Python.import("tuttle.app.invoicing.data_source").InvoicingDataSource() + PythonBridge.shared.invoicing = Python.import("tuttle.app.invoicing.intent").InvoicingIntent(client_storage: Python.None) + return true + }, completion: completion) + } + + // MARK: - Python Environment Configuration (unchanged) private static func configurePythonEnvironment(projectRoot: String) { let venvPath = projectRoot + "/.venv" @@ -139,30 +181,100 @@ final class PythonBridge { } } -// MARK: - Conversion Helpers +// MARK: - Python → Swift Conversion extension PythonBridge { - static func string(_ obj: PythonObject, key: String, fallback: String = "—") -> String { - guard let val = obj.checking[key] else { return fallback } - if val == Python.None { return fallback } - return String(val) ?? fallback + /// Check IntentResult success + static func isOk(_ result: PythonObject) -> Bool { + Bool(result.was_intent_successful) ?? false } - static func double(_ obj: PythonObject, key: String, fallback: Double = 0) -> Double { - guard let val = obj.checking[key] else { return fallback } - if val == Python.None { return fallback } - return Double(val) ?? fallback + /// Format a Python numeric value with tuttle's fmt_currency + static func fmtCurrencyStr(_ amount: PythonObject, _ currency: String) -> String { + String(PythonBridge.shared.fmtCurrency(amount, currency)) ?? "—" } - static func int(_ obj: PythonObject, key: String, fallback: Int = 0) -> Int { - guard let val = obj.checking[key] else { return fallback } - if val == Python.None { return fallback } - return Int(val) ?? fallback + /// Recursively convert a Python value to a Swift-native value. + /// Handles: None, bool, int, float, Decimal, str, date/datetime, Enum, list, dict. + static func toSwift(_ val: PythonObject) -> Any? { + if val == Python.None { return nil } + + let typeName = String(Python.type(val).__name__) ?? "" + + switch typeName { + case "bool": + return Bool(val) ?? false + case "int": + return Int(val) ?? 0 + case "float": + return Double(val) ?? 0.0 + case "str": + return String(val) ?? "" + case "Decimal": + return Double(Python.float(val)) ?? 0.0 + case "date", "datetime": + return String(val.isoformat()) ?? "" + case "list", "tuple": + var arr: [Any] = [] + for item in val { + if let v = toSwift(item) { arr.append(v) } + } + return arr + case "dict", "OrderedDict": + return toSwiftDict(val) + default: + // Enum types have a .value attribute + if let v = val.checking.value { + return String(v) ?? String(val) + } + return String(val) + } } - static func bool(_ obj: PythonObject, key: String, fallback: Bool = false) -> Bool { - guard let val = obj.checking[key] else { return fallback } - if val == Python.None { return fallback } - return Bool(val) ?? fallback + /// Convert a Python dict to [String: Any] + static func toSwiftDict(_ pyDict: PythonObject) -> [String: Any] { + var result: [String: Any] = [:] + guard let items = Dictionary(pyDict) else { return result } + for (key, val) in items { + if let swiftVal = toSwift(val) { + result[key] = swiftVal + } + } + return result + } + + /// Convert a Python model object to an Entity using model_dump(). + /// The `extras` closure can inject additional computed fields. + static func toEntity( + _ obj: PythonObject, + extras: ((PythonObject, inout [String: Any]) -> Void)? = nil + ) -> Entity { + let pyDict = obj.model_dump() + var dict = toSwiftDict(pyDict) + extras?(obj, &dict) + return Entity(data: dict) + } + + /// Convert a Python list of model objects to [Entity]. + static func toEntityList( + _ pyList: PythonObject, + extras: ((PythonObject, inout [String: Any]) -> Void)? = nil + ) -> [Entity] { + if pyList == Python.None { return [] } + var out: [Entity] = [] + for obj in pyList { + out.append(toEntity(obj, extras: extras)) + } + return out + } + + /// Convert a Python list of plain dicts to [Entity]. + static func dictListToEntities(_ pyList: PythonObject) -> [Entity] { + if pyList == Python.None { return [] } + var out: [Entity] = [] + for item in pyList { + out.append(Entity(data: toSwiftDict(item))) + } + return out } } diff --git a/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift b/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift index aef2920c..b7ad3de3 100644 --- a/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift +++ b/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift @@ -3,10 +3,10 @@ import PythonKit @Observable final class BusinessViewModel { - var clients: [ClientModel] = [] - var contacts: [ContactModel] = [] - var contracts: [ContractModel] = [] - var projects: [ProjectModel] = [] + var clients: [Entity] = [] + var contacts: [Entity] = [] + var contracts: [Entity] = [] + var projects: [Entity] = [] var isLoading = false var errorMessage: String? @@ -15,107 +15,133 @@ final class BusinessViewModel { isLoading = true errorMessage = nil - PythonBridge.shared.run({ bridge -> BusinessData in - let clientsResult = bridge.get_all_clients() - let contactsResult = bridge.get_all_contacts() - let contractsResult = bridge.get_all_contracts() - let projectsResult = bridge.get_all_projects() - - var clients: [ClientModel] = [] - if PythonBridge.bool(clientsResult, key: "ok") { - for item in clientsResult["clients"] { - clients.append(ClientModel.from(item)) + PythonBridge.shared.run({ + let py = PythonBridge.shared + + let cr = py.contacts.get_all() + let clr = py.clients.get_all() + let ctr = py.contracts.get_all() + let pr = py.projects.get_all() + + let contacts: [Entity] = PythonBridge.isOk(cr) + ? PythonBridge.toEntityList(cr.data) { obj, dict in + let addr = obj.address + if addr != Python.None { + dict["city"] = String(addr.city) ?? "" + dict["country"] = String(addr.country) ?? "" + } + dict["name"] = String(obj.name) ?? "" } - } - - var contacts: [ContactModel] = [] - if PythonBridge.bool(contactsResult, key: "ok") { - for item in contactsResult["contacts"] { - contacts.append(ContactModel.from(item)) + : [] + + let clients: [Entity] = PythonBridge.isOk(clr) + ? PythonBridge.toEntityList(clr.data) { obj, dict in + let contact = obj.invoicing_contact + if contact != Python.None { + dict["contact_name"] = String(contact.name) ?? "" + dict["contact_email"] = String(contact.email) ?? "" + dict["contact_company"] = String(contact.company) ?? "" + let addr = contact.address + if addr != Python.None { + dict["city"] = String(addr.city) ?? "" + dict["country"] = String(addr.country) ?? "" + } + } + dict["num_contracts"] = Int(Python.len(obj.contracts)) ?? 0 } - } - - var contracts: [ContractModel] = [] - if PythonBridge.bool(contractsResult, key: "ok") { - for item in contractsResult["contracts"] { - contracts.append(ContractModel.from(item)) + : [] + + let contracts: [Entity] = PythonBridge.isOk(ctr) + ? PythonBridge.toEntityList(ctr.data) { obj, dict in + dict["status"] = String(obj.get_status()) ?? "All" + if obj.client != Python.None { + dict["client_name"] = String(obj.client.name) ?? "" + } + let cur = dict["currency"] as? String ?? "EUR" + dict["rate_formatted"] = PythonBridge.fmtCurrencyStr(obj.rate, cur) + dict["unit_value"] = obj.unit != Python.None ? (String(obj.unit.value) ?? "hour") : "hour" + dict["billing_cycle_value"] = obj.billing_cycle != Python.None ? (String(obj.billing_cycle.value) ?? "") : "" + dict["num_projects"] = Int(Python.len(obj.projects)) ?? 0 + dict["num_invoices"] = Int(Python.len(obj.invoices)) ?? 0 } - } - - var projects: [ProjectModel] = [] - if PythonBridge.bool(projectsResult, key: "ok") { - for item in projectsResult["projects"] { - projects.append(ProjectModel.from(item)) + : [] + + let projects: [Entity] = PythonBridge.isOk(pr) + ? PythonBridge.toEntityList(pr.data) { obj, dict in + dict["status"] = String(obj.get_status()) ?? "All" + let contract = obj.contract + if contract != Python.None { + dict["contract_title"] = String(contract.title) ?? "" + if contract.client != Python.None { + dict["client_name"] = String(contract.client.name) ?? "" + } + } + dict["num_invoices"] = Int(Python.len(obj.invoices)) ?? 0 + dict["num_timesheets"] = Int(Python.len(obj.timesheets)) ?? 0 } - } - - return BusinessData( - clients: clients, - contacts: contacts, - contracts: contracts, - projects: projects - ) - }, completion: { [self] data in - self.clients = data.clients - self.contacts = data.contacts - self.contracts = data.contracts - self.projects = data.projects + : [] + + return (contacts, clients, contracts, projects) + }, completion: { [self] (c, cl, ct, p) in + self.contacts = c + self.clients = cl + self.contracts = ct + self.projects = p self.isLoading = false }) } func deleteClient(_ id: Int) { - PythonBridge.shared.run({ bridge -> Bool in - PythonBridge.bool(bridge.delete_client(id), key: "ok") + PythonBridge.shared.run({ + PythonBridge.isOk(PythonBridge.shared.clients.delete(id)) }, completion: { [self] ok in if ok { self.clients.removeAll { $0.id == id } } }) } func deleteContact(_ id: Int) { - PythonBridge.shared.run({ bridge -> Bool in - PythonBridge.bool(bridge.delete_contact(id), key: "ok") + PythonBridge.shared.run({ + PythonBridge.isOk(PythonBridge.shared.contacts.delete(id)) }, completion: { [self] ok in if ok { self.contacts.removeAll { $0.id == id } } }) } func deleteContract(_ id: Int) { - PythonBridge.shared.run({ bridge -> Bool in - PythonBridge.bool(bridge.delete_contract(id), key: "ok") + PythonBridge.shared.run({ + PythonBridge.isOk(PythonBridge.shared.contracts.delete(id)) }, completion: { [self] ok in if ok { self.contracts.removeAll { $0.id == id } } }) } func deleteProject(_ id: Int) { - PythonBridge.shared.run({ bridge -> Bool in - PythonBridge.bool(bridge.delete_project(id), key: "ok") + PythonBridge.shared.run({ + PythonBridge.isOk(PythonBridge.shared.projects.delete(id)) }, completion: { [self] ok in if ok { self.projects.removeAll { $0.id == id } } }) } func toggleContractCompleted(_ id: Int) { - PythonBridge.shared.run({ bridge -> Bool in - PythonBridge.bool(bridge.toggle_contract_completed(id), key: "ok") - }, completion: { [self] ok in + PythonBridge.shared.run({ + let intent = PythonBridge.shared.contracts! + let result = intent.get_by_id(id) + guard PythonBridge.isOk(result) else { return false } + return PythonBridge.isOk(intent.toggle_complete_status(result.data)) + }, completion: { [self] (ok: Bool) in if ok { self.loadAll() } }) } func toggleProjectCompleted(_ id: Int) { - PythonBridge.shared.run({ bridge -> Bool in - PythonBridge.bool(bridge.toggle_project_completed(id), key: "ok") - }, completion: { [self] ok in + PythonBridge.shared.run({ + let intent = PythonBridge.shared.projects! + let result = intent.get_by_id(id) + guard PythonBridge.isOk(result) else { return false } + return PythonBridge.isOk(intent.toggle_project_completed_status(result.data)) + }, completion: { [self] (ok: Bool) in if ok { self.loadAll() } }) } } - -struct BusinessData { - var clients: [ClientModel] - var contacts: [ContactModel] - var contracts: [ContractModel] - var projects: [ProjectModel] -} diff --git a/TuttleMac/Sources/TuttleMac/ViewModels/DashboardViewModel.swift b/TuttleMac/Sources/TuttleMac/ViewModels/DashboardViewModel.swift index a4a9c402..224c10ce 100644 --- a/TuttleMac/Sources/TuttleMac/ViewModels/DashboardViewModel.swift +++ b/TuttleMac/Sources/TuttleMac/ViewModels/DashboardViewModel.swift @@ -1,22 +1,21 @@ import Foundation import PythonKit -/// Holds all dashboard data as pure Swift types (no PythonObject references). struct DashboardData { - var kpis: KPISummary? + var kpis: Entity? var revenueData: [MonthlyDataPoint] var spendableData: [MonthlyDataPoint] - var projectBudgets: [ProjectBudget] - var financialGoals: [FinancialGoalModel] + var projectBudgets: [Entity] + var financialGoals: [Entity] } @Observable final class DashboardViewModel { - var kpis: KPISummary? + var kpis: Entity? var revenueData: [MonthlyDataPoint] = [] var spendableData: [MonthlyDataPoint] = [] - var projectBudgets: [ProjectBudget] = [] - var financialGoals: [FinancialGoalModel] = [] + var projectBudgets: [Entity] = [] + var financialGoals: [Entity] = [] var isLoading = false var errorMessage: String? @@ -24,49 +23,92 @@ final class DashboardViewModel { isLoading = true errorMessage = nil - PythonBridge.shared.run({ bridge -> DashboardData in - // All PythonObject access happens here on the Python thread. - // Convert everything to Swift types before returning. - let kpiResult = bridge.get_dashboard_kpis() - let chartResult = bridge.get_monthly_chart_data(12) - let budgetResult = bridge.get_project_budgets() - let goalsResult = bridge.get_financial_goals() + PythonBridge.shared.run({ + let db = PythonBridge.shared.dashboard! - let kpis: KPISummary? = PythonBridge.bool(kpiResult, key: "ok") - ? KPISummary.from(kpiResult) : nil + // KPIs (NamedTuple, not SQLModel — use _asdict()) + let kpiResult = db.get_kpis() + var kpis: Entity? = nil + if PythonBridge.isOk(kpiResult) { + let obj = kpiResult.data + var dict = PythonBridge.toSwiftDict(obj._asdict()) + let tc = dict["tax_currency"] as? String ?? "EUR" + dict["total_revenue_ytd_formatted"] = PythonBridge.fmtCurrencyStr(obj.total_revenue_ytd, tc) + dict["outstanding_amount_formatted"] = PythonBridge.fmtCurrencyStr(obj.outstanding_amount, tc) + dict["overdue_amount_formatted"] = PythonBridge.fmtCurrencyStr(obj.overdue_amount, tc) + dict["vat_reserve_formatted"] = PythonBridge.fmtCurrencyStr(obj.vat_reserve, tc) + dict["income_tax_reserve_formatted"] = PythonBridge.fmtCurrencyStr(obj.income_tax_reserve, tc) + dict["spendable_income_formatted"] = PythonBridge.fmtCurrencyStr(obj.spendable_income, tc) + let ehr = obj.effective_hourly_rate + if ehr != Python.None { + dict["effective_hourly_rate_formatted"] = PythonBridge.fmtCurrencyStr(ehr, tc) + } else { + dict["effective_hourly_rate_formatted"] = "—" + } + + let ur = obj.utilization_rate + if ur != Python.None { + if let d = Double(ur) { + dict["utilization_rate_formatted"] = String(format: "%.0f%%", d * 100) + } + } else { + dict["utilization_rate_formatted"] = "—" + } + + kpis = Entity(data: dict) + } + + // Monthly chart data + let chartResult = db.get_monthly_chart_data(12) var rev: [MonthlyDataPoint] = [] var sp: [MonthlyDataPoint] = [] - if PythonBridge.bool(chartResult, key: "ok") { - for item in chartResult["revenue"] { - let month = PythonBridge.string(item, key: "month") + if PythonBridge.isOk(chartResult) { + let data = chartResult.data + for item in data["revenue"] { + let month = String(item["month"]) ?? "" rev.append(MonthlyDataPoint( month: month, label: Self.shortLabel(month), - value: PythonBridge.double(item, key: "revenue") + value: Double(Python.float(item["revenue"])) ?? 0 )) } - for item in chartResult["spendable"] { - let month = PythonBridge.string(item, key: "month") + for item in data["spendable"] { + let month = String(item["month"]) ?? "" sp.append(MonthlyDataPoint( month: month, label: Self.shortLabel(month), - value: PythonBridge.double(item, key: "spendable") + value: Double(Python.float(item["spendable"])) ?? 0 )) } } - var budgets: [ProjectBudget] = [] - if PythonBridge.bool(budgetResult, key: "ok") { - for item in budgetResult["budgets"] { - budgets.append(ProjectBudget.from(item)) - } + // Project budgets + let budgetResult = db.get_project_budgets() + var budgets: [Entity] = [] + if PythonBridge.isOk(budgetResult) { + budgets = PythonBridge.dictListToEntities(budgetResult.data) } - var goals: [FinancialGoalModel] = [] - if PythonBridge.bool(goalsResult, key: "ok") { - for item in goalsResult["goals"] { - goals.append(FinancialGoalModel.from(item)) + // Financial goals + let goalsResult = db.get_financial_goals() + var goals: [Entity] = [] + if PythonBridge.isOk(goalsResult) { + for item in goalsResult.data { + let g = item["goal"] + let tc = String(item["currency"]) ?? "EUR" + let dict: [String: Any] = [ + "id": Int(g.id) ?? 0, + "title": String(g.title) ?? "", + "target_amount": Double(Python.float(g.target_amount)) ?? 0, + "target_amount_formatted": PythonBridge.fmtCurrencyStr(g.target_amount, tc), + "target_date": String(g.target_date.isoformat()) ?? "", + "target_date_formatted": String(g.target_date.strftime("%b %Y")) ?? "", + "is_reached": Bool(g.is_reached) ?? false, + "progress": Double(item["progress"]) ?? 0, + "ytd_revenue_formatted": PythonBridge.fmtCurrencyStr(item["ytd_revenue"], tc), + ] + goals.append(Entity(data: dict)) } } @@ -77,7 +119,7 @@ final class DashboardViewModel { projectBudgets: budgets, financialGoals: goals ) - }, completion: { [self] data in + }, completion: { [self] (data: DashboardData) in self.kpis = data.kpis self.revenueData = data.revenueData self.spendableData = data.spendableData @@ -90,8 +132,6 @@ final class DashboardViewModel { private static func shortLabel(_ month: String) -> String { let parts = month.split(separator: "-") guard parts.count == 2 else { return month } - let m = parts[1] - let y = parts[0].suffix(2) - return "\(m)/\(y)" + return "\(parts[1])/\(parts[0].suffix(2))" } } diff --git a/TuttleMac/Sources/TuttleMac/ViewModels/InvoicingViewModel.swift b/TuttleMac/Sources/TuttleMac/ViewModels/InvoicingViewModel.swift index 23d5cd09..e74b9044 100644 --- a/TuttleMac/Sources/TuttleMac/ViewModels/InvoicingViewModel.swift +++ b/TuttleMac/Sources/TuttleMac/ViewModels/InvoicingViewModel.swift @@ -3,7 +3,7 @@ import PythonKit @Observable final class InvoicingViewModel { - var invoices: [InvoiceModel] = [] + var invoices: [Entity] = [] var isLoading = false var errorMessage: String? @@ -11,14 +11,49 @@ final class InvoicingViewModel { isLoading = true errorMessage = nil - PythonBridge.shared.run({ bridge -> [InvoiceModel] in - let result = bridge.get_all_invoices() - guard PythonBridge.bool(result, key: "ok") else { return [] } - var out: [InvoiceModel] = [] - for item in result["invoices"] { - out.append(InvoiceModel.from(item)) + PythonBridge.shared.run({ + let result = PythonBridge.shared.invoicingDS.get_all_invoices() + guard PythonBridge.isOk(result) else { return [Entity]() } + return PythonBridge.toEntityList(result.data) { obj, dict in + let contract = obj.contract + let currency: String + if contract != Python.None { + currency = String(contract.currency) ?? "EUR" + dict["contract_title"] = String(contract.title) ?? "" + } else { + currency = "EUR" + } + dict["currency"] = currency + + if obj.client != Python.None { + dict["client_name"] = String(obj.client.name) ?? "" + } + if obj.project != Python.None { + dict["project_title"] = String(obj.project.title) ?? "" + } + + dict["status"] = String(obj.status) ?? "draft" + dict["sum_value"] = Double(Python.float(obj.sum)) ?? 0 + dict["sum_formatted"] = PythonBridge.fmtCurrencyStr(obj.sum, currency) + dict["vat_total_value"] = Double(Python.float(obj.VAT_total)) ?? 0 + dict["vat_total_formatted"] = PythonBridge.fmtCurrencyStr(obj.VAT_total, currency) + dict["total_value"] = Double(Python.float(obj.total)) ?? 0 + dict["total_formatted"] = PythonBridge.fmtCurrencyStr(obj.total, currency) + + // Line items + var items: [[String: Any]] = [] + let pyItems = obj.items + if pyItems != Python.None { + for item in pyItems { + var itemDict = PythonBridge.toSwiftDict(item.model_dump()) + itemDict["unit_price_formatted"] = PythonBridge.fmtCurrencyStr(item.unit_price, currency) + itemDict["subtotal_value"] = Double(Python.float(item.subtotal)) ?? 0 + itemDict["subtotal_formatted"] = PythonBridge.fmtCurrencyStr(item.subtotal, currency) + items.append(itemDict) + } + } + dict["items"] = items } - return out }, completion: { [self] data in self.invoices = data self.isLoading = false @@ -26,33 +61,26 @@ final class InvoicingViewModel { } func deleteInvoice(_ id: Int) { - PythonBridge.shared.run({ bridge -> Bool in - PythonBridge.bool(bridge.delete_invoice(id), key: "ok") + PythonBridge.shared.run({ + PythonBridge.isOk(PythonBridge.shared.invoicing.delete_invoice_by_id(id)) }, completion: { [self] ok in if ok { self.invoices.removeAll { $0.id == id } } }) } - func toggleSent(_ id: Int) { - PythonBridge.shared.run({ bridge -> Bool in - PythonBridge.bool(bridge.toggle_invoice_sent(id), key: "ok") - }, completion: { [self] ok in - if ok { self.loadInvoices() } - }) - } + func toggleSent(_ id: Int) { toggleField(id, method: "toggle_invoice_sent_status") } + func togglePaid(_ id: Int) { toggleField(id, method: "toggle_invoice_paid_status") } + func toggleCancelled(_ id: Int) { toggleField(id, method: "toggle_invoice_cancelled_status") } - func togglePaid(_ id: Int) { - PythonBridge.shared.run({ bridge -> Bool in - PythonBridge.bool(bridge.toggle_invoice_paid(id), key: "ok") - }, completion: { [self] ok in - if ok { self.loadInvoices() } - }) - } - - func toggleCancelled(_ id: Int) { - PythonBridge.shared.run({ bridge -> Bool in - PythonBridge.bool(bridge.toggle_invoice_cancelled(id), key: "ok") - }, completion: { [self] ok in + private func toggleField(_ id: Int, method: String) { + PythonBridge.shared.run({ + let intent = PythonBridge.shared.invoicing! + let allMap = intent.get_all_invoices_as_map() + let inv = allMap[id] + guard inv != Python.None else { return false } + let toggle = Python.getattr(intent, method) + return PythonBridge.isOk(toggle(inv)) + }, completion: { [self] (ok: Bool) in if ok { self.loadInvoices() } }) } diff --git a/TuttleMac/Sources/TuttleMac/ViewModels/TimelineViewModel.swift b/TuttleMac/Sources/TuttleMac/ViewModels/TimelineViewModel.swift index 541e4137..dfc73367 100644 --- a/TuttleMac/Sources/TuttleMac/ViewModels/TimelineViewModel.swift +++ b/TuttleMac/Sources/TuttleMac/ViewModels/TimelineViewModel.swift @@ -3,33 +3,34 @@ import PythonKit @Observable final class TimelineViewModel { - var events: [TimelineEvent] = [] + var events: [Entity] = [] var activeFilter: TimelineCategory = .all var searchQuery: String = "" var isLoading = false var errorMessage: String? - var filteredEvents: [TimelineEvent] { + var filteredEvents: [Entity] { var result = events if activeFilter != .all { - result = result.filter { $0.category == activeFilter } + result = result.filter { + TimelineCategory(rawValue: $0.str("category")) == activeFilter + } } if !searchQuery.isEmpty { let q = searchQuery.lowercased() result = result.filter { - $0.title.lowercased().contains(q) - || $0.description.lowercased().contains(q) + $0.str("title").lowercased().contains(q) + || $0.str("description").lowercased().contains(q) } } return result } - /// Events grouped by month, preserving the descending date order. - var groupedEvents: [(key: String, label: String, events: [TimelineEvent])] { + var groupedEvents: [(key: String, label: String, events: [Entity])] { let filtered = filteredEvents - var groups: [(key: String, label: String, events: [TimelineEvent])] = [] + var groups: [(key: String, label: String, events: [Entity])] = [] var currentKey = "" - var currentEvents: [TimelineEvent] = [] + var currentEvents: [Entity] = [] var currentLabel = "" for event in filtered { @@ -55,20 +56,50 @@ final class TimelineViewModel { isLoading = true errorMessage = nil - PythonBridge.shared.run({ bridge -> [TimelineEvent] in - let result = bridge.get_timeline_events() - var parsed: [TimelineEvent] = [] - if PythonBridge.bool(result, key: "ok") { - for item in result["events"] { - if let event = TimelineEvent.from(item) { - parsed.append(event) - } + PythonBridge.shared.run({ + let result = PythonBridge.shared.timeline.get_timeline_events() + guard PythonBridge.isOk(result) else { return [Entity]() } + + var entities: [Entity] = [] + for e in result.data { + let dateStr = String(e.date.isoformat()) ?? "" + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + guard let date = fmt.date(from: dateStr) else { continue } + + let displayFmt = DateFormatter() + displayFmt.dateFormat = "MMM d, yyyy" + + let title = String(e.title) ?? "" + let catStr = String(e.category) ?? "invoice" + + var dict: [String: Any] = [ + "id": Int(e.entity_id) ?? entities.count, + "_date": date, + "date_formatted": displayFmt.string(from: date), + "title": title, + "description": String(e[dynamicMember: "description"]) ?? "", + "category": catStr, + "status": Self.inferStatus(title), + "is_future": Bool(e.is_future) ?? false, + ] + if e.entity_id != Python.None { + dict["entity_id"] = Int(e.entity_id) ?? 0 } + entities.append(Entity(data: dict)) } - return parsed + return entities }, completion: { [self] parsed in self.events = parsed self.isLoading = false }) } + + private static func inferStatus(_ title: String) -> String { + let t = title.lowercased() + for kw in ["cancelled", "overdue", "paid", "completed", "reached", "due"] { + if t.contains(kw) { return kw } + } + return "default" + } } diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift index 5bbbe5a9..bbdea203 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift @@ -2,14 +2,14 @@ import SwiftUI struct ClientsView: View { @State private var viewModel = BusinessViewModel() - @State private var selectedClient: ClientModel? + @State private var selectedClient: Entity? @State private var searchText = "" - private var filtered: [ClientModel] { + private var filtered: [Entity] { if searchText.isEmpty { return viewModel.clients } return viewModel.clients.filter { - $0.name.localizedCaseInsensitiveContains(searchText) - || $0.contactName.localizedCaseInsensitiveContains(searchText) + $0.str("name").localizedCaseInsensitiveContains(searchText) + || $0.str("contact_name").localizedCaseInsensitiveContains(searchText) || $0.location.localizedCaseInsensitiveContains(searchText) } } @@ -103,16 +103,16 @@ struct ClientsView: View { Divider() DetailSection(title: "Contact") { - DetailRow(label: "Name", value: client.contactName, icon: "person") - DetailRow(label: "Email", value: client.contactEmail, icon: "envelope") - if !client.contactCompany.isEmpty { - DetailRow(label: "Company", value: client.contactCompany, icon: "building.2") + DetailRow(label: "Name", value: client.str("contact_name"), icon: "person") + DetailRow(label: "Email", value: client.str("contact_email"), icon: "envelope") + if !client.str("contact_company").isEmpty { + DetailRow(label: "Company", value: client.str("contact_company"), icon: "building.2") } } DetailSection(title: "Business") { HStack(spacing: 20) { - StatPill(label: "Contracts", value: "\(client.numContracts)", icon: "signature") + StatPill(label: "Contracts", value: "\(client.int("num_contracts"))", icon: "signature") } } } @@ -133,7 +133,7 @@ struct ClientsView: View { // MARK: - Client Row struct ClientRow: View { - let client: ClientModel + let client: Entity var body: some View { HStack(spacing: 12) { @@ -144,8 +144,8 @@ struct ClientRow: View { .font(.body) .fontWeight(.medium) HStack(spacing: 4) { - if !client.contactName.isEmpty { - Text(client.contactName) + if !client.str("contact_name").isEmpty { + Text(client.str("contact_name")) .font(.caption) .foregroundStyle(.secondary) } @@ -161,8 +161,8 @@ struct ClientRow: View { Spacer() - if client.numContracts > 0 { - Text("\(client.numContracts)") + if client.int("num_contracts") > 0 { + Text("\(client.int("num_contracts"))") .font(.caption2) .fontWeight(.semibold) .foregroundStyle(.secondary) @@ -174,8 +174,3 @@ struct ClientRow: View { .padding(.vertical, 4) } } - -extension ClientModel: Hashable { - static func == (lhs: ClientModel, rhs: ClientModel) -> Bool { lhs.id == rhs.id } - func hash(into hasher: inout Hasher) { hasher.combine(id) } -} diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift index 1b207711..8350c7b9 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift @@ -2,15 +2,15 @@ import SwiftUI struct ContactsView: View { @State private var viewModel = BusinessViewModel() - @State private var selectedContact: ContactModel? + @State private var selectedContact: Entity? @State private var searchText = "" - private var filtered: [ContactModel] { + private var filtered: [Entity] { if searchText.isEmpty { return viewModel.contacts } return viewModel.contacts.filter { $0.fullName.localizedCaseInsensitiveContains(searchText) - || $0.company.localizedCaseInsensitiveContains(searchText) - || $0.email.localizedCaseInsensitiveContains(searchText) + || $0.str("company").localizedCaseInsensitiveContains(searchText) + || $0.str("email").localizedCaseInsensitiveContains(searchText) || $0.location.localizedCaseInsensitiveContains(searchText) } } @@ -93,7 +93,7 @@ struct ContactsView: View { Text(contact.displayName) .font(.title2) .fontWeight(.bold) - if !contact.company.isEmpty { + if !contact.str("company").isEmpty { Text(contact.company) .font(.subheadline) .foregroundStyle(.secondary) @@ -104,7 +104,7 @@ struct ContactsView: View { Divider() DetailSection(title: "Contact Info") { - if !contact.email.isEmpty { + if !contact.str("email").isEmpty { DetailRow(label: "Email", value: contact.email, icon: "envelope") } if !contact.location.isEmpty { @@ -129,7 +129,7 @@ struct ContactsView: View { // MARK: - Contact Row struct ContactRow: View { - let contact: ContactModel + let contact: Entity var body: some View { HStack(spacing: 12) { @@ -140,13 +140,13 @@ struct ContactRow: View { .font(.body) .fontWeight(.medium) HStack(spacing: 4) { - if !contact.company.isEmpty { + if !contact.str("company").isEmpty { Text(contact.company) .font(.caption) .foregroundStyle(.secondary) } - if !contact.email.isEmpty { - if !contact.company.isEmpty { + if !contact.str("email").isEmpty { + if !contact.str("company").isEmpty { Text("·").foregroundStyle(.quaternary) } Text(contact.email) @@ -167,8 +167,3 @@ struct ContactRow: View { .padding(.vertical, 4) } } - -extension ContactModel: Hashable { - static func == (lhs: ContactModel, rhs: ContactModel) -> Bool { lhs.id == rhs.id } - func hash(into hasher: inout Hasher) { hasher.combine(id) } -} diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift index 9da52fc3..dfd956c8 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift @@ -2,16 +2,16 @@ import SwiftUI struct ContractsView: View { @State private var viewModel = BusinessViewModel() - @State private var selectedContract: ContractModel? + @State private var selectedContract: Entity? @State private var statusFilter: EntityStatus = .all @State private var searchText = "" - private var filtered: [ContractModel] { + private var filtered: [Entity] { viewModel.contracts.filter { c in - (statusFilter == .all || c.status == statusFilter) + (statusFilter == .all || c.entityStatus == statusFilter) && (searchText.isEmpty - || c.title.localizedCaseInsensitiveContains(searchText) - || c.clientName.localizedCaseInsensitiveContains(searchText)) + || c.str("title").localizedCaseInsensitiveContains(searchText) + || c.str("client_name").localizedCaseInsensitiveContains(searchText)) } } @@ -50,7 +50,7 @@ struct ContractsView: View { ContractRow(contract: contract) .tag(contract) .contextMenu { - Button(contract.isCompleted ? "Mark Active" : "Mark Completed") { + Button(contract.bool("is_completed") ? "Mark Active" : "Mark Completed") { viewModel.toggleContractCompleted(contract.id) } Divider() @@ -96,24 +96,25 @@ struct ContractsView: View { @ViewBuilder private var detailPane: some View { if let contract = selectedContract { + let status = contract.entityStatus ScrollView { VStack(alignment: .leading, spacing: 20) { HStack(spacing: 12) { Image(systemName: "signature") .font(.title2) - .foregroundStyle(contract.status.color) + .foregroundStyle(status.color) .frame(width: 48, height: 48) - .background(contract.status.color.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + .background(status.color.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 2) { Text(contract.title) .font(.title2) .fontWeight(.bold) HStack(spacing: 6) { - Text(contract.clientName) + Text(contract.str("client_name")) .font(.subheadline) .foregroundStyle(.secondary) - StatusBadge(status: contract.status) + StatusBadge(status: status) } } } @@ -121,13 +122,13 @@ struct ContractsView: View { Divider() DetailSection(title: "Terms") { - DetailRow(label: "Rate", value: "\(contract.rateFormatted) / \(contract.unit)") - if let vol = contract.volume { - DetailRow(label: "Volume", value: "\(vol) \(contract.unit)s") + DetailRow(label: "Rate", value: "\(contract.str("rate_formatted")) / \(contract.str("unit_value"))") + if let vol = contract.optInt("volume") { + DetailRow(label: "Volume", value: "\(vol) \(contract.str("unit_value"))s") } - DetailRow(label: "Billing", value: contract.billingCycle) - DetailRow(label: "VAT", value: String(format: "%.0f%%", contract.vatRate * 100)) - DetailRow(label: "Currency", value: contract.currency) + DetailRow(label: "Billing", value: contract.str("billing_cycle_value")) + DetailRow(label: "VAT", value: contract.vatPercent) + DetailRow(label: "Currency", value: contract.str("currency")) } DetailSection(title: "Period") { @@ -136,8 +137,8 @@ struct ContractsView: View { DetailSection(title: "Related") { HStack(spacing: 20) { - StatPill(label: "Projects", value: "\(contract.numProjects)", icon: "folder") - StatPill(label: "Invoices", value: "\(contract.numInvoices)", icon: "doc.text") + StatPill(label: "Projects", value: "\(contract.int("num_projects"))", icon: "folder") + StatPill(label: "Invoices", value: "\(contract.int("num_invoices"))", icon: "doc.text") } } } @@ -158,27 +159,28 @@ struct ContractsView: View { // MARK: - Contract Row struct ContractRow: View { - let contract: ContractModel + let contract: Entity var body: some View { + let status = contract.entityStatus HStack(spacing: 12) { Image(systemName: "signature") .font(.body) - .foregroundStyle(contract.status.color) + .foregroundStyle(status.color) .frame(width: 34, height: 34) - .background(contract.status.color.opacity(0.1), in: RoundedRectangle(cornerRadius: 7)) + .background(status.color.opacity(0.1), in: RoundedRectangle(cornerRadius: 7)) VStack(alignment: .leading, spacing: 2) { Text(contract.title) .font(.body) .fontWeight(.medium) HStack(spacing: 4) { - Text(contract.clientName) + Text(contract.str("client_name")) .font(.caption) .foregroundStyle(.secondary) Text("·") .foregroundStyle(.quaternary) - Text(contract.rateFormatted + "/\(contract.unit)") + Text(contract.str("rate_formatted") + "/\(contract.str("unit_value"))") .font(.caption) .foregroundStyle(.secondary) } @@ -186,13 +188,8 @@ struct ContractRow: View { Spacer() - StatusBadge(status: contract.status) + StatusBadge(status: status) } .padding(.vertical, 4) } } - -extension ContractModel: Hashable { - static func == (lhs: ContractModel, rhs: ContractModel) -> Bool { lhs.id == rhs.id } - func hash(into hasher: inout Hasher) { hasher.combine(id) } -} diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift index b2bf4f42..e4e6f0ee 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift @@ -2,17 +2,17 @@ import SwiftUI struct ProjectsView: View { @State private var viewModel = BusinessViewModel() - @State private var selectedProject: ProjectModel? + @State private var selectedProject: Entity? @State private var statusFilter: EntityStatus = .all @State private var searchText = "" - private var filtered: [ProjectModel] { + private var filtered: [Entity] { viewModel.projects.filter { p in - (statusFilter == .all || p.status == statusFilter) + (statusFilter == .all || p.entityStatus == statusFilter) && (searchText.isEmpty - || p.title.localizedCaseInsensitiveContains(searchText) - || p.clientName.localizedCaseInsensitiveContains(searchText) - || p.tag.localizedCaseInsensitiveContains(searchText)) + || p.str("title").localizedCaseInsensitiveContains(searchText) + || p.str("client_name").localizedCaseInsensitiveContains(searchText) + || p.str("tag").localizedCaseInsensitiveContains(searchText)) } } @@ -51,7 +51,7 @@ struct ProjectsView: View { ProjectRow(project: project) .tag(project) .contextMenu { - Button(project.isCompleted ? "Mark Active" : "Mark Completed") { + Button(project.bool("is_completed") ? "Mark Active" : "Mark Completed") { viewModel.toggleProjectCompleted(project.id) } Divider() @@ -97,12 +97,13 @@ struct ProjectsView: View { @ViewBuilder private var detailPane: some View { if let project = selectedProject { + let status = project.entityStatus ScrollView { VStack(alignment: .leading, spacing: 20) { HStack(spacing: 12) { InitialsAvatar( - text: String(project.title.prefix(2)).uppercased(), - color: project.status.color, + text: String(project.str("title").prefix(2)).uppercased(), + color: status.color, size: 48 ) VStack(alignment: .leading, spacing: 2) { @@ -113,7 +114,7 @@ struct ProjectsView: View { Text(project.tag) .font(.subheadline) .foregroundStyle(.secondary) - StatusBadge(status: project.status) + StatusBadge(status: status) } } } @@ -121,12 +122,12 @@ struct ProjectsView: View { Divider() DetailSection(title: "Details") { - DetailRow(label: "Client", value: project.clientName) - DetailRow(label: "Contract", value: project.contractTitle) + DetailRow(label: "Client", value: project.str("client_name")) + DetailRow(label: "Contract", value: project.str("contract_title")) DetailRow(label: "Period", value: project.dateRange) } - if !project.description.isEmpty { + if !project.str("description").isEmpty { DetailSection(title: "Description") { Text(project.description) .font(.body) @@ -136,8 +137,8 @@ struct ProjectsView: View { DetailSection(title: "Activity") { HStack(spacing: 20) { - StatPill(label: "Invoices", value: "\(project.numInvoices)", icon: "doc.text") - StatPill(label: "Timesheets", value: "\(project.numTimesheets)", icon: "clock") + StatPill(label: "Invoices", value: "\(project.int("num_invoices"))", icon: "doc.text") + StatPill(label: "Timesheets", value: "\(project.int("num_timesheets"))", icon: "clock") } } } @@ -158,13 +159,14 @@ struct ProjectsView: View { // MARK: - Project Row struct ProjectRow: View { - let project: ProjectModel + let project: Entity var body: some View { + let status = project.entityStatus HStack(spacing: 12) { InitialsAvatar( - text: String(project.title.prefix(2)).uppercased(), - color: project.status.color, + text: String(project.str("title").prefix(2)).uppercased(), + color: status.color, size: 34 ) @@ -180,20 +182,15 @@ struct ProjectRow: View { .padding(.vertical, 1) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) } - Text(project.clientName.isEmpty ? "No client" : project.clientName) + Text(project.str("client_name").isEmpty ? "No client" : project.str("client_name")) .font(.caption) .foregroundStyle(.secondary) } Spacer() - StatusBadge(status: project.status) + StatusBadge(status: status) } .padding(.vertical, 4) } } - -extension ProjectModel: Hashable { - static func == (lhs: ProjectModel, rhs: ProjectModel) -> Bool { lhs.id == rhs.id } - func hash(into hasher: inout Hasher) { hasher.combine(id) } -} diff --git a/TuttleMac/Sources/TuttleMac/Views/Dashboard/DashboardView.swift b/TuttleMac/Sources/TuttleMac/Views/Dashboard/DashboardView.swift index f93e85e2..83cbb7c1 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Dashboard/DashboardView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Dashboard/DashboardView.swift @@ -35,78 +35,78 @@ struct DashboardView: View { // MARK: - KPI Cards - private func kpiSection(_ kpis: KPISummary) -> some View { + private func kpiSection(_ kpis: Entity) -> some View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 160), spacing: 12)], spacing: 12) { KPICard( title: "Revenue (YTD)", - value: kpis.totalRevenueYTDFormatted, + value: kpis.str("total_revenue_ytd_formatted"), icon: "arrow.up.right", - valueColor: kpis.totalRevenueYTD > 0 ? .green : .primary + valueColor: kpis.num("total_revenue_ytd") > 0 ? .green : .primary ) KPICard( title: "Outstanding", - value: kpis.outstandingAmountFormatted, + value: kpis.str("outstanding_amount_formatted"), icon: "wallet.bifold", - valueColor: kpis.outstandingAmount > 0 ? .yellow : .primary + valueColor: kpis.num("outstanding_amount") > 0 ? .yellow : .primary ) KPICard( title: "Overdue", - value: kpis.overdueAmountFormatted, + value: kpis.str("overdue_amount_formatted"), icon: "exclamationmark.triangle", - valueColor: kpis.overdueAmount > 0 ? .red : .primary + valueColor: kpis.num("overdue_amount") > 0 ? .red : .primary ) KPICard( title: "Eff. Hourly Rate", - value: kpis.effectiveHourlyRateFormatted, + value: kpis.str("effective_hourly_rate_formatted"), icon: "gauge.with.needle", valueColor: .blue ) KPICard( title: "Utilization", - value: kpis.utilizationRateFormatted, + value: kpis.str("utilization_rate_formatted"), icon: "chart.pie", - valueColor: (kpis.utilizationRate ?? 0) >= 0.7 ? .blue : .yellow + valueColor: (kpis.optNum("utilization_rate") ?? 0) >= 0.7 ? .blue : .yellow ) KPICard( title: "Active Projects", - value: "\(kpis.activeProjects)", + value: "\(kpis.int("active_projects"))", icon: "folder", valueColor: .primary ) KPICard( title: "Active Contracts", - value: "\(kpis.activeContracts)", + value: "\(kpis.int("active_contracts"))", icon: "signature", valueColor: .primary ) KPICard( title: "Unpaid Invoices", - value: "\(kpis.unpaidInvoices)", + value: "\(kpis.int("unpaid_invoices"))", icon: "doc.text", - valueColor: kpis.unpaidInvoices > 0 ? .yellow : .primary + valueColor: kpis.int("unpaid_invoices") > 0 ? .yellow : .primary ) } } - private func taxSection(_ kpis: KPISummary) -> some View { + private func taxSection(_ kpis: Entity) -> some View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 200), spacing: 12)], spacing: 12) { KPICard( title: "VAT Reserve", - value: kpis.vatReserveFormatted, + value: kpis.str("vat_reserve_formatted"), icon: "building.columns", - valueColor: kpis.vatReserve > 0 ? .yellow : .primary + valueColor: kpis.num("vat_reserve") > 0 ? .yellow : .primary ) KPICard( title: "Est. Income Tax", - value: kpis.incomeTaxReserveFormatted, + value: kpis.str("income_tax_reserve_formatted"), icon: "function", - valueColor: kpis.incomeTaxReserve > 0 ? .yellow : .primary + valueColor: kpis.num("income_tax_reserve") > 0 ? .yellow : .primary ) KPICard( title: "Spendable Income", - value: kpis.spendableIncomeFormatted, + value: kpis.str("spendable_income_formatted"), icon: "banknote", - valueColor: kpis.spendableIncome > 0 ? .green : .red + valueColor: kpis.num("spendable_income") > 0 ? .green : .red ) } } @@ -157,7 +157,7 @@ struct DashboardView: View { } } .chartXAxis { - AxisMarks { value in + AxisMarks { _ in AxisValueLabel() .font(.caption2) } @@ -268,21 +268,22 @@ struct KPICard: View { } struct ProjectBudgetRow: View { - let budget: ProjectBudget + let budget: Entity private var barColor: Color { - if budget.progress >= 1.0 { return .red } - if budget.progress >= 0.8 { return .yellow } + let progress = budget.num("progress") + if progress >= 1.0 { return .red } + if progress >= 0.8 { return .yellow } return .green } var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { - Text(budget.project) + Text(budget.str("project")) .font(.body) Spacer() - Text("\(Int(budget.hoursTracked)) / \(Int(budget.hoursBudget)) h (\(Int(budget.progress * 100))%)") + Text("\(Int(budget.num("hours_tracked"))) / \(Int(budget.num("hours_budget"))) h (\(Int(budget.num("progress") * 100))%)") .font(.caption) .foregroundStyle(.secondary) } @@ -293,7 +294,7 @@ struct ProjectBudgetRow: View { .frame(height: 6) Capsule() .fill(barColor) - .frame(width: geo.size.width * min(budget.progress, 1.0), height: 6) + .frame(width: geo.size.width * min(budget.num("progress"), 1.0), height: 6) } } .frame(height: 6) @@ -302,10 +303,10 @@ struct ProjectBudgetRow: View { } struct FinancialGoalRow: View { - let goal: FinancialGoalModel + let goal: Entity private var barColor: Color { - goal.isReached ? .green : .blue + goal.bool("is_reached") ? .green : .blue } var body: some View { @@ -314,18 +315,18 @@ struct FinancialGoalRow: View { Text(goal.title) .font(.body) Spacer() - if goal.isReached { + if goal.bool("is_reached") { Text("Reached!") .font(.caption) .foregroundStyle(.green) .fontWeight(.semibold) } else { - Text("\(goal.ytdRevenueFormatted) / \(goal.targetAmountFormatted)") + Text("\(goal.str("ytd_revenue_formatted")) / \(goal.str("target_amount_formatted"))") .font(.caption) .foregroundStyle(.secondary) } } - Text("Target: \(goal.targetAmountFormatted) by \(goal.targetDateFormatted)") + Text("Target: \(goal.str("target_amount_formatted")) by \(goal.str("target_date_formatted"))") .font(.caption2) .foregroundStyle(.tertiary) GeometryReader { geo in @@ -335,7 +336,7 @@ struct FinancialGoalRow: View { .frame(height: 6) Capsule() .fill(barColor) - .frame(width: geo.size.width * min(goal.progress, 1.0), height: 6) + .frame(width: geo.size.width * min(goal.num("progress"), 1.0), height: 6) } } .frame(height: 6) diff --git a/TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift b/TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift index f5b07002..bd9f5ee5 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift @@ -2,22 +2,22 @@ import SwiftUI struct InvoicingView: View { @State private var viewModel = InvoicingViewModel() - @State private var selectedInvoice: InvoiceModel? + @State private var selectedInvoice: Entity? @State private var statusFilter: InvoiceStatus = .all @State private var searchText = "" - private var filtered: [InvoiceModel] { + private var filtered: [Entity] { viewModel.invoices.filter { inv in - (statusFilter == .all || inv.status == statusFilter) + (statusFilter == .all || inv.invoiceStatus == statusFilter) && (searchText.isEmpty - || inv.number.localizedCaseInsensitiveContains(searchText) - || inv.clientName.localizedCaseInsensitiveContains(searchText) - || inv.projectTitle.localizedCaseInsensitiveContains(searchText)) + || inv.str("number").localizedCaseInsensitiveContains(searchText) + || inv.str("client_name").localizedCaseInsensitiveContains(searchText) + || inv.str("project_title").localizedCaseInsensitiveContains(searchText)) } } private var totalFiltered: Double { - filtered.reduce(0) { $0 + $1.total } + filtered.reduce(0) { $0 + $1.num("total_value") } } var body: some View { @@ -106,16 +106,16 @@ struct InvoicingView: View { // MARK: - Context Menu @ViewBuilder - private func statusContextMenu(for invoice: InvoiceModel) -> some View { - if !invoice.cancelled { - Button(invoice.sent ? "Mark as Not Sent" : "Mark as Sent") { + private func statusContextMenu(for invoice: Entity) -> some View { + if !invoice.bool("cancelled") { + Button(invoice.bool("sent") ? "Mark as Not Sent" : "Mark as Sent") { viewModel.toggleSent(invoice.id) } - Button(invoice.paid ? "Mark as Unpaid" : "Mark as Paid") { + Button(invoice.bool("paid") ? "Mark as Unpaid" : "Mark as Paid") { viewModel.togglePaid(invoice.id) } } - Button(invoice.cancelled ? "Restore Invoice" : "Cancel Invoice") { + Button(invoice.bool("cancelled") ? "Restore Invoice" : "Cancel Invoice") { viewModel.toggleCancelled(invoice.id) } } @@ -149,45 +149,48 @@ struct InvoicingView: View { // MARK: - Detail Subviews - private func invoiceHeader(_ invoice: InvoiceModel) -> some View { - HStack(spacing: 12) { + private func invoiceHeader(_ invoice: Entity) -> some View { + let status = invoice.invoiceStatus + return HStack(spacing: 12) { Image(systemName: "doc.text") .font(.title2) - .foregroundStyle(invoice.status.color) + .foregroundStyle(status.color) .frame(width: 48, height: 48) - .background(invoice.status.color.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + .background(status.color.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 2) { - Text(invoice.number.isEmpty ? "Draft" : invoice.number) + Text(invoice.str("number").isEmpty ? "Draft" : invoice.number) .font(.title2) .fontWeight(.bold) HStack(spacing: 6) { - Text(invoice.clientName.isEmpty ? "No client" : invoice.clientName) + Text(invoice.str("client_name").isEmpty ? "No client" : invoice.str("client_name")) .font(.subheadline) .foregroundStyle(.secondary) - InvoiceStatusBadge(status: invoice.status) + InvoiceStatusBadge(status: status) } } Spacer() } } - private func invoiceAmounts(_ invoice: InvoiceModel) -> some View { - HStack(spacing: 12) { - AmountCard(label: "Subtotal", value: invoice.subtotalFormatted, color: .secondary) - AmountCard(label: "VAT", value: invoice.vatTotalFormatted, color: .orange) - AmountCard(label: "Total", value: invoice.totalFormatted, color: invoice.status.color, isProminent: true) + private func invoiceAmounts(_ invoice: Entity) -> some View { + let status = invoice.invoiceStatus + return HStack(spacing: 12) { + AmountCard(label: "Subtotal", value: invoice.str("sum_formatted"), color: .secondary) + AmountCard(label: "VAT", value: invoice.str("vat_total_formatted"), color: .orange) + AmountCard(label: "Total", value: invoice.str("total_formatted"), color: status.color, isProminent: true) } } @ViewBuilder - private func invoiceItems(_ invoice: InvoiceModel) -> some View { - if !invoice.items.isEmpty { + private func invoiceItems(_ invoice: Entity) -> some View { + let items = invoice.list("items") + if !items.isEmpty { DetailSection(title: "Line Items") { VStack(spacing: 0) { - ForEach(invoice.items) { item in + ForEach(items) { item in InvoiceItemRow(item: item) - if item.id != invoice.items.last?.id { + if item.id != items.last?.id { Divider().padding(.vertical, 4) } } @@ -196,44 +199,46 @@ struct InvoicingView: View { } } - private func invoiceDetails(_ invoice: InvoiceModel) -> some View { - DetailSection(title: "Details") { - DetailRow(label: "Date", value: invoice.dateFormatted, icon: "calendar") - if let due = invoice.dueDateFormatted { - DetailRow(label: "Due", value: due, icon: "clock") + private func invoiceDetails(_ invoice: Entity) -> some View { + let dateStr = invoice.str("date") + let dueDateStr = invoice.optStr("due_date") + return DetailSection(title: "Details") { + DetailRow(label: "Date", value: Entity.formatDateStr(dateStr), icon: "calendar") + if let due = dueDateStr, !due.isEmpty { + DetailRow(label: "Due", value: Entity.formatDateStr(due), icon: "clock") } - DetailRow(label: "Project", value: invoice.projectTitle, icon: "folder") - DetailRow(label: "Contract", value: invoice.contractTitle, icon: "signature") - DetailRow(label: "Currency", value: invoice.currency, icon: "banknote") + DetailRow(label: "Project", value: invoice.str("project_title"), icon: "folder") + DetailRow(label: "Contract", value: invoice.str("contract_title"), icon: "signature") + DetailRow(label: "Currency", value: invoice.str("currency"), icon: "banknote") } } - private func invoiceActions(_ invoice: InvoiceModel) -> some View { + private func invoiceActions(_ invoice: Entity) -> some View { DetailSection(title: "Actions") { HStack(spacing: 10) { - if !invoice.cancelled { + if !invoice.bool("cancelled") { ActionButton( - label: invoice.sent ? "Unsend" : "Mark Sent", + label: invoice.bool("sent") ? "Unsend" : "Mark Sent", icon: "paperplane", color: .blue, - isActive: invoice.sent + isActive: invoice.bool("sent") ) { viewModel.toggleSent(invoice.id) } ActionButton( - label: invoice.paid ? "Unpay" : "Mark Paid", + label: invoice.bool("paid") ? "Unpay" : "Mark Paid", icon: "checkmark.circle", color: .green, - isActive: invoice.paid + isActive: invoice.bool("paid") ) { viewModel.togglePaid(invoice.id) } } ActionButton( - label: invoice.cancelled ? "Restore" : "Cancel", - icon: invoice.cancelled ? "arrow.uturn.left" : "xmark.circle", + label: invoice.bool("cancelled") ? "Restore" : "Cancel", + icon: invoice.bool("cancelled") ? "arrow.uturn.left" : "xmark.circle", color: .orange, - isActive: invoice.cancelled + isActive: invoice.bool("cancelled") ) { viewModel.toggleCancelled(invoice.id) } @@ -245,33 +250,34 @@ struct InvoicingView: View { // MARK: - Invoice Row struct InvoiceRow: View { - let invoice: InvoiceModel + let invoice: Entity var body: some View { + let status = invoice.invoiceStatus HStack(spacing: 12) { Image(systemName: "doc.text") .font(.body) - .foregroundStyle(invoice.status.color) + .foregroundStyle(status.color) .frame(width: 34, height: 34) - .background(invoice.status.color.opacity(0.1), in: RoundedRectangle(cornerRadius: 7)) + .background(status.color.opacity(0.1), in: RoundedRectangle(cornerRadius: 7)) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { - Text(invoice.number.isEmpty ? "Draft" : invoice.number) + Text(invoice.str("number").isEmpty ? "Draft" : invoice.number) .font(.body) .fontWeight(.medium) - Text(invoice.dateFormatted) + Text(Entity.formatDateStr(invoice.str("date"))) .font(.caption) .foregroundStyle(.tertiary) } HStack(spacing: 4) { - Text(invoice.clientName.isEmpty ? "No client" : invoice.clientName) + Text(invoice.str("client_name").isEmpty ? "No client" : invoice.str("client_name")) .font(.caption) .foregroundStyle(.secondary) - if !invoice.projectTitle.isEmpty { + if !invoice.str("project_title").isEmpty { Text("·") .foregroundStyle(.quaternary) - Text(invoice.projectTitle) + Text(invoice.str("project_title")) .font(.caption) .foregroundStyle(.tertiary) } @@ -281,11 +287,11 @@ struct InvoiceRow: View { Spacer() VStack(alignment: .trailing, spacing: 2) { - Text(invoice.totalFormatted) + Text(invoice.str("total_formatted")) .font(.subheadline) .fontWeight(.semibold) .monospacedDigit() - InvoiceStatusBadge(status: invoice.status) + InvoiceStatusBadge(status: status) } } .padding(.vertical, 4) @@ -345,7 +351,7 @@ struct AmountCard: View { // MARK: - Invoice Item Row struct InvoiceItemRow: View { - let item: InvoiceItemModel + let item: Entity var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -354,18 +360,20 @@ struct InvoiceItemRow: View { .font(.subheadline) .fontWeight(.medium) Spacer() - Text(item.subtotalFormatted) + Text(item.str("subtotal_formatted")) .font(.subheadline) .fontWeight(.semibold) .monospacedDigit() } HStack(spacing: 12) { - Label(String(format: "%.1f %@", item.quantity, item.unit), systemImage: "number") - Label(item.unitPriceFormatted + "/" + item.unit, systemImage: "banknote") + let unit = item.str("unit").isEmpty ? "hour" : item.str("unit") + Label(String(format: "%.1f %@", item.num("quantity"), unit), systemImage: "number") + Label(item.str("unit_price_formatted") + "/" + unit, systemImage: "banknote") Label(item.vatPercent + " VAT", systemImage: "percent") Spacer() - if !item.dateRange.isEmpty { - Text(item.dateRange) + let itemDateRange = item.dateRange + if !itemDateRange.isEmpty { + Text(itemDateRange) .foregroundStyle(.tertiary) } } @@ -405,3 +413,15 @@ struct ActionButton: View { .buttonStyle(.plain) } } + +// MARK: - Entity date formatting helper + +extension Entity { + static func formatDateStr(_ iso: String) -> String { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + guard let d = fmt.date(from: iso) else { return iso } + fmt.dateFormat = "MMM d, yyyy" + return fmt.string(from: d) + } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Timeline/TimelineView.swift b/TuttleMac/Sources/TuttleMac/Views/Timeline/TimelineView.swift index d668948e..15692073 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Timeline/TimelineView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Timeline/TimelineView.swift @@ -63,7 +63,6 @@ struct TimelineView: View { return VStack(alignment: .leading, spacing: 0) { ForEach(Array(groups.enumerated()), id: \.element.key) { groupIdx, group in - // Month header MonthHeader(label: group.label) .padding(.top, groupIdx > 0 ? 8 : 0) @@ -71,8 +70,8 @@ struct TimelineView: View { let isLast = groupIdx == groups.count - 1 && eventIdx == group.events.count - 1 - // Insert today marker before the first past event - if !todayInserted && !event.isFuture && event.date <= today { + if !todayInserted && !event.bool("is_future"), + let eventDate = event.date("_date"), eventDate <= today { let _ = { todayInserted = true }() TodayMarker() } @@ -81,7 +80,6 @@ struct TimelineView: View { } } - // If all events are in the future, show today at the bottom if !todayInserted { TodayMarker() } @@ -159,7 +157,6 @@ struct MonthHeader: View { struct TodayMarker: View { var body: some View { HStack(spacing: 8) { - // Spine dot ZStack { Rectangle() .fill(.quaternary) @@ -186,25 +183,28 @@ struct TodayMarker: View { // MARK: - Event Card struct TimelineEventCard: View { - let event: TimelineEvent + let event: Entity var isLast: Bool = false + private var category: TimelineCategory { + TimelineCategory(rawValue: event.str("category")) ?? .invoice + } + private var dotColor: Color { - switch event.status { + switch event.str("status") { case "paid", "completed": .green case "overdue", "cancelled": .red - case "due": TimelineView.categoryColor(event.category) - default: TimelineView.categoryColor(event.category) + case "due": TimelineView.categoryColor(category) + default: TimelineView.categoryColor(category) } } private var categoryColor: Color { - TimelineView.categoryColor(event.category) + TimelineView.categoryColor(category) } var body: some View { HStack(alignment: .top, spacing: 8) { - // Spine with dot VStack(spacing: 0) { Rectangle() .fill(.quaternary) @@ -230,11 +230,10 @@ struct TimelineEventCard: View { } .frame(width: 36) - // Card VStack(alignment: .leading, spacing: 4) { HStack(alignment: .top) { HStack(spacing: 6) { - Image(systemName: event.category.systemImage) + Image(systemName: category.systemImage) .font(.subheadline) .foregroundStyle(dotColor) Text(event.title) @@ -242,18 +241,18 @@ struct TimelineEventCard: View { .fontWeight(.semibold) } Spacer() - Text(event.dateFormatted) + Text(event.str("date_formatted")) .font(.caption) .foregroundStyle(.tertiary) } - if !event.description.isEmpty { + if !event.str("description").isEmpty { Text(event.description) .font(.subheadline) .foregroundStyle(.secondary) } - Text(event.category.label) + Text(category.label) .font(.caption2) .fontWeight(.semibold) .foregroundStyle(categoryColor) @@ -264,7 +263,7 @@ struct TimelineEventCard: View { .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) - .opacity(event.isFuture ? 0.55 : 1.0) + .opacity(event.bool("is_future") ? 0.55 : 1.0) } } } diff --git a/tuttle/app/dashboard/intent.py b/tuttle/app/dashboard/intent.py index ac1287c1..f62fd768 100644 --- a/tuttle/app/dashboard/intent.py +++ b/tuttle/app/dashboard/intent.py @@ -131,10 +131,29 @@ def get_project_budgets(self) -> IntentResult: ) def get_financial_goals(self) -> IntentResult: - """Load all financial goals.""" + """Load all financial goals with progress calculated against YTD revenue.""" try: goals = self.query(FinancialGoal) - return IntentResult(was_intent_successful=True, data=goals) + invoices = self.query(Invoice) + country = self._get_country() + kpis = compute_kpis( + invoices, self.query(Contract), self.query(Project), country=country + ) + ytd_revenue = float(kpis.total_revenue_ytd) + + goals_with_progress = [] + for g in goals: + target = float(g.target_amount) + progress = min(ytd_revenue / target, 1.0) if target > 0 else 0.0 + goals_with_progress.append( + { + "goal": g, + "progress": progress, + "ytd_revenue": ytd_revenue, + "currency": kpis.tax_currency, + } + ) + return IntentResult(was_intent_successful=True, data=goals_with_progress) except Exception as e: return IntentResult( was_intent_successful=False, diff --git a/tuttle/bridge.py b/tuttle/bridge.py deleted file mode 100644 index 0c3aff0f..00000000 --- a/tuttle/bridge.py +++ /dev/null @@ -1,463 +0,0 @@ -"""Flet-free bridge for the macOS native app. - -Thin serialization layer that delegates to the existing Intent classes -and converts their results to PythonKit-friendly dicts. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Optional - -from loguru import logger - -from .app.core.formatting import fmt_currency -from .app.clients.intent import ClientsIntent -from .app.contacts.intent import ContactsIntent -from .app.contracts.intent import ContractsIntent -from .app.dashboard.intent import DashboardIntent -from .app.invoicing.data_source import InvoicingDataSource -from .app.projects.intent import ProjectsIntent -from .app.timeline.intent import TimelineIntent -from .migrations.run import run_migrations - - -class TuttleBridge: - """Flet-free service layer for the Swift macOS app.""" - - def __init__(self): - self._dashboard = DashboardIntent() - self._timeline = TimelineIntent() - self._clients = ClientsIntent() - self._contacts = ContactsIntent() - self._contracts = ContractsIntent() - self._projects = ProjectsIntent() - self._invoicing_ds = InvoicingDataSource() - self._db_path = Path.home() / ".tuttle" / "tuttle.db" - - def install_demo_data(self, n_projects: int = 4): - """Reset DB and install demo data. Returns True on success.""" - from . import demo - - try: - if self._db_path.exists(): - self._db_path.unlink() - db_url = f"sqlite:///{self._db_path}" - run_migrations(db_url) - demo.install_demo_data( - n_projects=n_projects, - db_path=str(self._db_path), - on_cache_timetracking_dataframe=lambda _df: None, - ) - self._dashboard = DashboardIntent() - self._timeline = TimelineIntent() - self._clients = ClientsIntent() - self._contacts = ContactsIntent() - self._contracts = ContractsIntent() - self._projects = ProjectsIntent() - self._invoicing_ds = InvoicingDataSource() - return True - except Exception as e: - logger.exception(e) - return False - - # ── Dashboard ────────────────────────────────────────────── - - def get_dashboard_kpis(self) -> dict: - result = self._dashboard.get_kpis() - if not result.was_intent_successful or result.data is None: - result.log_message_if_any() - return {"ok": False, "error": result.error_msg} - kpis = result.data - tc = kpis.tax_currency - return { - "ok": True, - "total_revenue_ytd": float(kpis.total_revenue_ytd), - "outstanding_amount": float(kpis.outstanding_amount), - "overdue_amount": float(kpis.overdue_amount), - "effective_hourly_rate": float(kpis.effective_hourly_rate) - if kpis.effective_hourly_rate - else None, - "utilization_rate": kpis.utilization_rate, - "active_projects": kpis.active_projects, - "active_contracts": kpis.active_contracts, - "unpaid_invoices": kpis.unpaid_invoices, - "vat_reserve": float(kpis.vat_reserve), - "income_tax_reserve": float(kpis.income_tax_reserve), - "spendable_income": float(kpis.spendable_income), - "tax_currency": tc, - "total_revenue_ytd_fmt": fmt_currency(kpis.total_revenue_ytd, tc), - "outstanding_amount_fmt": fmt_currency(kpis.outstanding_amount, tc), - "overdue_amount_fmt": fmt_currency(kpis.overdue_amount, tc), - "effective_hourly_rate_fmt": fmt_currency(kpis.effective_hourly_rate, tc) - if kpis.effective_hourly_rate - else "—", - "vat_reserve_fmt": fmt_currency(kpis.vat_reserve, tc), - "income_tax_reserve_fmt": fmt_currency(kpis.income_tax_reserve, tc), - "spendable_income_fmt": fmt_currency(kpis.spendable_income, tc), - "utilization_rate_fmt": f"{kpis.utilization_rate * 100:.0f}%" - if kpis.utilization_rate is not None - else "—", - } - - def get_monthly_chart_data(self, n_months: int = 12) -> dict: - result = self._dashboard.get_monthly_chart_data(n_months=n_months) - if not result.was_intent_successful or result.data is None: - result.log_message_if_any() - return {"ok": False, "error": result.error_msg} - data = result.data - rev_list = [ - {"month": m["month"], "revenue": float(m["revenue"])} - for m in data["revenue"] - ] - sp_list = [ - {"month": m["month"], "spendable": float(m["spendable"])} - for m in data["spendable"] - ] - return {"ok": True, "revenue": rev_list, "spendable": sp_list} - - def get_project_budgets(self) -> dict: - result = self._dashboard.get_project_budgets() - if not result.was_intent_successful or result.data is None: - result.log_message_if_any() - return {"ok": False, "error": result.error_msg} - return {"ok": True, "budgets": result.data} - - def get_financial_goals(self) -> dict: - result = self._dashboard.get_financial_goals() - if not result.was_intent_successful or result.data is None: - result.log_message_if_any() - return {"ok": False, "error": result.error_msg} - - kpi_result = self._dashboard.get_kpis() - ytd_revenue = 0.0 - tc = "EUR" - if kpi_result.was_intent_successful and kpi_result.data is not None: - ytd_revenue = float(kpi_result.data.total_revenue_ytd) - tc = kpi_result.data.tax_currency - - goals_out = [] - for g in result.data: - target = float(g.target_amount) - progress = min(ytd_revenue / target, 1.0) if target > 0 else 0.0 - goals_out.append( - { - "id": g.id, - "title": g.title, - "target_amount": target, - "target_amount_fmt": fmt_currency(g.target_amount, tc), - "target_date": g.target_date.isoformat(), - "target_date_fmt": g.target_date.strftime("%b %Y"), - "is_reached": g.is_reached, - "progress": progress, - "ytd_revenue_fmt": fmt_currency(ytd_revenue, tc), - } - ) - return {"ok": True, "goals": goals_out, "currency": tc} - - # ── Timeline ─────────────────────────────────────────────── - - def get_timeline_events(self, category_filter: Optional[str] = None) -> dict: - result = self._timeline.get_timeline_events(category_filter=category_filter) - if not result.was_intent_successful or result.data is None: - result.log_message_if_any() - return {"ok": False, "error": result.error_msg} - - events_out = [ - { - "date": e.date.isoformat(), - "title": e.title, - "description": e.description, - "category": e.category, - "icon": e.icon, - "color": e.color, - "status": self._infer_status(e.title), - "is_future": e.is_future, - "entity_id": e.entity_id, - } - for e in result.data - ] - return {"ok": True, "events": events_out} - - @staticmethod - def _infer_status(title: str) -> str: - t = title.lower() - for keyword in ("cancelled", "overdue", "paid", "completed", "reached", "due"): - if keyword in t: - return keyword - return "default" - - # ── Contacts ─────────────────────────────────────────────── - - def get_all_contacts(self) -> dict: - result = self._contacts.get_all() - if not result.was_intent_successful or result.data is None: - result.log_message_if_any() - return {"ok": False, "error": result.error_msg} - contacts = [] - for c in result.data: - addr = c.address - contacts.append( - { - "id": c.id, - "first_name": c.first_name or "", - "last_name": c.last_name or "", - "company": c.company or "", - "email": c.email or "", - "street": addr.street if addr else "", - "city": addr.city if addr else "", - "postal_code": addr.postal_code if addr else "", - "country": addr.country if addr else "", - } - ) - return {"ok": True, "contacts": contacts} - - def delete_contact(self, contact_id: int) -> dict: - result = self._contacts.delete(contact_id) - if not result.was_intent_successful: - return {"ok": False, "error": result.error_msg} - return {"ok": True} - - # ── Clients ──────────────────────────────────────────────── - - def get_all_clients(self) -> dict: - result = self._clients.get_all() - if not result.was_intent_successful or result.data is None: - result.log_message_if_any() - return {"ok": False, "error": result.error_msg} - clients = [] - for cl in result.data: - contact = cl.invoicing_contact - n_contracts = len(cl.contracts) if cl.contracts else 0 - clients.append( - { - "id": cl.id, - "name": cl.name, - "contact_name": contact.name if contact else "", - "contact_email": contact.email if contact else "", - "contact_company": contact.company if contact else "", - "contact_city": contact.address.city - if contact and contact.address - else "", - "contact_country": contact.address.country - if contact and contact.address - else "", - "num_contracts": n_contracts, - } - ) - return {"ok": True, "clients": clients} - - def delete_client(self, client_id: int) -> dict: - result = self._clients.delete(client_id) - if not result.was_intent_successful: - return {"ok": False, "error": result.error_msg} - return {"ok": True} - - # ── Contracts ────────────────────────────────────────────── - - def get_all_contracts(self) -> dict: - result = self._contracts.get_all() - if not result.was_intent_successful or result.data is None: - result.log_message_if_any() - return {"ok": False, "error": result.error_msg} - contracts = [] - for c in result.data: - client_name = c.client.name if c.client else "—" - contracts.append( - { - "id": c.id, - "title": c.title, - "client_name": client_name, - "status": c.get_status(), - "start_date": c.start_date.isoformat(), - "end_date": c.end_date.isoformat() if c.end_date else None, - "rate": float(c.rate), - "rate_fmt": fmt_currency(c.rate, c.currency), - "currency": c.currency, - "unit": c.unit.value if c.unit else "hour", - "volume": c.volume, - "billing_cycle": c.billing_cycle.value if c.billing_cycle else "", - "is_completed": c.is_completed, - "vat_rate": float(c.VAT_rate), - "num_projects": len(c.projects) if c.projects else 0, - "num_invoices": len(c.invoices) if c.invoices else 0, - } - ) - return {"ok": True, "contracts": contracts} - - def delete_contract(self, contract_id: int) -> dict: - result = self._contracts.delete(contract_id) - if not result.was_intent_successful: - return {"ok": False, "error": result.error_msg} - return {"ok": True} - - def toggle_contract_completed(self, contract_id: int) -> dict: - result = self._contracts.get_by_id(contract_id) - if not result.was_intent_successful or result.data is None: - return {"ok": False, "error": result.error_msg} - toggle_result = self._contracts.toggle_complete_status(result.data) - if not toggle_result.was_intent_successful: - return {"ok": False, "error": toggle_result.error_msg} - return {"ok": True} - - # ── Projects ─────────────────────────────────────────────── - - def get_all_projects(self) -> dict: - result = self._projects.get_all() - if not result.was_intent_successful or result.data is None: - result.log_message_if_any() - return {"ok": False, "error": result.error_msg} - projects = [] - for p in result.data: - client_name = "" - contract_title = "" - if p.contract: - contract_title = p.contract.title - if p.contract.client: - client_name = p.contract.client.name - projects.append( - { - "id": p.id, - "title": p.title, - "tag": p.tag, - "description": p.description, - "client_name": client_name, - "contract_title": contract_title, - "status": p.get_status(), - "start_date": p.start_date.isoformat(), - "end_date": p.end_date.isoformat() if p.end_date else None, - "is_completed": p.is_completed, - "num_invoices": len(p.invoices) if p.invoices else 0, - "num_timesheets": len(p.timesheets) if p.timesheets else 0, - } - ) - return {"ok": True, "projects": projects} - - def delete_project(self, project_id: int) -> dict: - result = self._projects.delete(project_id) - if not result.was_intent_successful: - return {"ok": False, "error": result.error_msg} - return {"ok": True} - - def toggle_project_completed(self, project_id: int) -> dict: - result = self._projects.get_by_id(project_id) - if not result.was_intent_successful or result.data is None: - return {"ok": False, "error": result.error_msg} - toggle_result = self._projects.toggle_project_completed_status(result.data) - if not toggle_result.was_intent_successful: - return {"ok": False, "error": toggle_result.error_msg} - return {"ok": True} - - # ── Invoicing ───────────────────────────────────────────── - - def get_all_invoices(self) -> dict: - result = self._invoicing_ds.get_all_invoices() - if not result.was_intent_successful or result.data is None: - result.log_message_if_any() - return {"ok": False, "error": result.error_msg} - invoices = [] - for inv in result.data: - client_name = "" - if inv.contract and inv.contract.client: - client_name = inv.contract.client.name - project_title = inv.project.title if inv.project else "" - contract_title = inv.contract.title if inv.contract else "" - currency = inv.contract.currency if inv.contract else "EUR" - - status = "draft" - if inv.cancelled: - status = "cancelled" - elif inv.paid: - status = "paid" - elif inv.sent: - due = inv.due_date - if due and due < __import__("datetime").date.today(): - status = "overdue" - else: - status = "sent" - - items_out = [] - for item in inv.items or []: - items_out.append( - { - "id": item.id, - "description": item.description, - "quantity": float(item.quantity), - "unit": item.unit, - "unit_price": float(item.unit_price), - "unit_price_fmt": fmt_currency(item.unit_price, currency), - "vat_rate": float(item.VAT_rate), - "subtotal": float(item.subtotal), - "subtotal_fmt": fmt_currency(item.subtotal, currency), - "start_date": item.start_date.isoformat(), - "end_date": item.end_date.isoformat() - if item.end_date - else None, - } - ) - - invoices.append( - { - "id": inv.id, - "number": inv.number or "", - "date": inv.date.isoformat(), - "client_name": client_name, - "project_title": project_title, - "contract_title": contract_title, - "currency": currency, - "subtotal": float(inv.sum), - "subtotal_fmt": fmt_currency(inv.sum, currency), - "vat_total": float(inv.VAT_total), - "vat_total_fmt": fmt_currency(inv.VAT_total, currency), - "total": float(inv.total), - "total_fmt": fmt_currency(inv.total, currency), - "status": status, - "sent": bool(inv.sent), - "paid": bool(inv.paid), - "cancelled": bool(inv.cancelled), - "rendered": bool(inv.rendered), - "due_date": inv.due_date.isoformat() if inv.due_date else None, - "items": items_out, - } - ) - return {"ok": True, "invoices": invoices} - - def delete_invoice(self, invoice_id: int) -> dict: - try: - self._invoicing_ds.delete_invoice_by_id(invoice_id) - return {"ok": True} - except Exception as e: - logger.exception(e) - return {"ok": False, "error": str(e)} - - def _get_invoice_by_id(self, invoice_id: int): - """Load a single Invoice from DB by primary key.""" - from .model import Invoice - - with self._invoicing_ds.create_session() as session: - inv = session.get(Invoice, invoice_id) - return inv - - def toggle_invoice_sent(self, invoice_id: int) -> dict: - inv = self._get_invoice_by_id(invoice_id) - if inv is None: - return {"ok": False, "error": "Invoice not found"} - inv.sent = not inv.sent - self._invoicing_ds.save_invoice(inv) - return {"ok": True} - - def toggle_invoice_paid(self, invoice_id: int) -> dict: - inv = self._get_invoice_by_id(invoice_id) - if inv is None: - return {"ok": False, "error": "Invoice not found"} - inv.paid = not inv.paid - self._invoicing_ds.save_invoice(inv) - return {"ok": True} - - def toggle_invoice_cancelled(self, invoice_id: int) -> dict: - inv = self._get_invoice_by_id(invoice_id) - if inv is None: - return {"ok": False, "error": "Invoice not found"} - inv.cancelled = not inv.cancelled - self._invoicing_ds.save_invoice(inv) - return {"ok": True} diff --git a/tuttle/model.py b/tuttle/model.py index ec57a4b6..4a475e17 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -659,6 +659,20 @@ def due_date(self) -> Optional[datetime.date]: else: return None + @property + def status(self) -> str: + """Derived invoice status: draft, cancelled, paid, overdue, or sent.""" + if self.cancelled: + return "cancelled" + if self.paid: + return "paid" + if self.sent: + due = self.due_date + if due and due < datetime.date.today(): + return "overdue" + return "sent" + return "draft" + @property def client(self): return self.contract.client From 8f0e1c5712f466912782442991459d6f1f55c312 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sun, 10 May 2026 11:46:10 +0200 Subject: [PATCH 3/4] Add forms for managing clients, contacts, contracts, and projects - Introduced dedicated form sheets for creating and editing clients, contacts, contracts, and projects within the BusinessViewModel. - Enhanced the ClientsView, ContactsView, ContractsView, and ProjectsView to include buttons for adding and editing entities, improving user interaction. - Implemented validation logic in forms to ensure required fields are filled before submission. - Integrated form sheets with the existing view model to handle data saving and loading seamlessly. - Updated UI components to maintain a consistent look and feel across the application. --- .../ViewModels/BusinessViewModel.swift | 189 +++++++++ .../Views/Business/ClientsView.swift | 21 + .../Views/Business/ContactsView.swift | 21 + .../Views/Business/ContractsView.swift | 21 + .../Views/Business/EntityForms.swift | 386 ++++++++++++++++++ .../Views/Business/ProjectsView.swift | 21 + 6 files changed, 659 insertions(+) create mode 100644 TuttleMac/Sources/TuttleMac/Views/Business/EntityForms.swift diff --git a/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift b/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift index b7ad3de3..87b7db1e 100644 --- a/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift +++ b/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift @@ -9,6 +9,7 @@ final class BusinessViewModel { var projects: [Entity] = [] var isLoading = false + var isSaving = false var errorMessage: String? func loadAll() { @@ -144,4 +145,192 @@ final class BusinessViewModel { if ok { self.loadAll() } }) } + + // MARK: - Save (create or update) + + func saveContact( + id: Int?, firstName: String, lastName: String, + company: String, email: String, + street: String, city: String, postalCode: String, country: String + ) { + isSaving = true + PythonBridge.shared.run({ + let intent = PythonBridge.shared.contacts! + let model = Python.import("tuttle.model") + + let contact: PythonObject + if let existingId = id { + let r = intent.get_by_id(existingId) + guard PythonBridge.isOk(r) else { return "Failed to load contact" } + contact = r.data + } else { + contact = model.Contact() + } + + contact.first_name = PythonObject(firstName) + contact.last_name = PythonObject(lastName) + contact.company = PythonObject(company) + contact.email = PythonObject(email.isEmpty ? Python.None : PythonObject(email)) + + if contact.address == Python.None { + contact.address = model.Address() + } + contact.address.street = PythonObject(street) + contact.address.city = PythonObject(city) + contact.address.postal_code = PythonObject(postalCode) + contact.address.country = PythonObject(country) + + let result = intent.save_contact(contact) + if PythonBridge.isOk(result) { return nil as String? } + return String(result.error_msg) ?? "Save failed" + }, completion: { [self] (err: String?) in + self.isSaving = false + self.errorMessage = err + self.loadAll() + }) + } + + func saveClient(id: Int?, name: String, contactId: Int?) { + isSaving = true + PythonBridge.shared.run({ + let intent = PythonBridge.shared.clients! + let model = Python.import("tuttle.model") + + let client: PythonObject + if let existingId = id { + let r = intent.get_by_id(existingId) + guard PythonBridge.isOk(r) else { return "Failed to load client" } + client = r.data + } else { + client = model.Client() + } + + client.name = PythonObject(name) + + if let cId = contactId { + let cr = PythonBridge.shared.contacts!.get_by_id(cId) + if PythonBridge.isOk(cr) { + client.invoicing_contact = cr.data + client.invoicing_contact_id = PythonObject(cId) + } + } + + let result = intent.save_client(client) + if PythonBridge.isOk(result) { return nil as String? } + return String(result.error_msg) ?? "Save failed" + }, completion: { [self] (err: String?) in + self.isSaving = false + self.errorMessage = err + self.loadAll() + }) + } + + func saveContract( + id: Int?, title: String, clientId: Int?, + rate: String, currency: String, unit: String, billingCycle: String, + vatRate: String, startDate: Date, endDate: Date?, volume: String + ) { + isSaving = true + PythonBridge.shared.run({ + let intent = PythonBridge.shared.contracts! + let model = Python.import("tuttle.model") + let decimal = Python.import("decimal") + let timeModule = Python.import("tuttle.time") + let dt = Python.import("datetime") + + let contract: PythonObject + if let existingId = id { + let r = intent.get_by_id(existingId) + guard PythonBridge.isOk(r) else { return "Failed to load contract" } + contract = r.data + } else { + contract = model.Contract() + } + + contract.title = PythonObject(title) + contract.rate = decimal.Decimal(rate.isEmpty ? "0" : rate) + contract.currency = PythonObject(currency.isEmpty ? "EUR" : currency) + contract.unit = timeModule.TimeUnit(unit.isEmpty ? "hour" : unit) + contract.billing_cycle = timeModule.Cycle(billingCycle.isEmpty ? "monthly" : billingCycle) + contract.VAT_rate = decimal.Decimal(vatRate.isEmpty ? "0.19" : vatRate) + + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + contract.start_date = dt.date.fromisoformat(fmt.string(from: startDate)) + if let end = endDate { + contract.end_date = dt.date.fromisoformat(fmt.string(from: end)) + } + contract.signature_date = contract.start_date + + if !volume.isEmpty, let v = Int(volume) { + contract.volume = PythonObject(v) + } + + if let cId = clientId { + let cr = PythonBridge.shared.clients!.get_by_id(cId) + if PythonBridge.isOk(cr) { + contract.client = cr.data + contract.client_id = PythonObject(cId) + } + } + + let result = intent.save_contract(contract) + if PythonBridge.isOk(result) { return nil as String? } + return String(result.error_msg) ?? "Save failed" + }, completion: { [self] (err: String?) in + self.isSaving = false + self.errorMessage = err + self.loadAll() + }) + } + + func saveProject( + id: Int?, title: String, tag: String, description: String, + contractId: Int?, startDate: Date, endDate: Date? + ) { + isSaving = true + PythonBridge.shared.run({ + let intent = PythonBridge.shared.projects! + let model = Python.import("tuttle.model") + let dt = Python.import("datetime") + + let project: PythonObject + if let existingId = id { + let r = intent.get_by_id(existingId) + guard PythonBridge.isOk(r) else { return "Failed to load project" } + project = r.data + } else { + project = model.Project() + } + + project.title = PythonObject(title) + project.tag = PythonObject(tag) + project[dynamicMember: "description"] = PythonObject(description) + + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + project.start_date = dt.date.fromisoformat(fmt.string(from: startDate)) + if let end = endDate { + project.end_date = dt.date.fromisoformat(fmt.string(from: end)) + } else { + project.end_date = Python.None + } + + if let cId = contractId { + let cr = PythonBridge.shared.contracts!.get_by_id(cId) + if PythonBridge.isOk(cr) { + project.contract = cr.data + project.contract_id = PythonObject(cId) + } + } + + let result = intent.save_project(project) + if PythonBridge.isOk(result) { return nil as String? } + return String(result.error_msg) ?? "Save failed" + }, completion: { [self] (err: String?) in + self.isSaving = false + self.errorMessage = err + self.loadAll() + }) + } } diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift index bbdea203..6a43912b 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift @@ -4,6 +4,8 @@ struct ClientsView: View { @State private var viewModel = BusinessViewModel() @State private var selectedClient: Entity? @State private var searchText = "" + @State private var showingForm = false + @State private var editingEntity: Entity? private var filtered: [Entity] { if searchText.isEmpty { return viewModel.clients } @@ -24,6 +26,16 @@ struct ClientsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle("Clients") .searchable(text: $searchText, prompt: "Search clients…") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { editingEntity = nil; showingForm = true } label: { + Label("Add Client", systemImage: "plus") + } + } + } + .sheet(isPresented: $showingForm) { + ClientFormSheet(viewModel: viewModel, editing: editingEntity) + } .onAppear { viewModel.loadAll() } .refreshable { viewModel.loadAll() } } @@ -115,6 +127,15 @@ struct ClientsView: View { StatPill(label: "Contracts", value: "\(client.int("num_contracts"))", icon: "signature") } } + + Button { + editingEntity = client + showingForm = true + } label: { + Label("Edit Client", systemImage: "pencil") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) } .padding(20) } diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift index 8350c7b9..0ff47270 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift @@ -4,6 +4,8 @@ struct ContactsView: View { @State private var viewModel = BusinessViewModel() @State private var selectedContact: Entity? @State private var searchText = "" + @State private var showingForm = false + @State private var editingEntity: Entity? private var filtered: [Entity] { if searchText.isEmpty { return viewModel.contacts } @@ -25,6 +27,16 @@ struct ContactsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle("Contacts") .searchable(text: $searchText, prompt: "Search contacts…") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { editingEntity = nil; showingForm = true } label: { + Label("Add Contact", systemImage: "plus") + } + } + } + .sheet(isPresented: $showingForm) { + ContactFormSheet(viewModel: viewModel, editing: editingEntity) + } .onAppear { viewModel.loadAll() } .refreshable { viewModel.loadAll() } } @@ -111,6 +123,15 @@ struct ContactsView: View { DetailRow(label: "Location", value: contact.location, icon: "mappin") } } + + Button { + editingEntity = contact + showingForm = true + } label: { + Label("Edit Contact", systemImage: "pencil") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) } .padding(20) } diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift index dfd956c8..5c0c20b7 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift @@ -5,6 +5,8 @@ struct ContractsView: View { @State private var selectedContract: Entity? @State private var statusFilter: EntityStatus = .all @State private var searchText = "" + @State private var showingForm = false + @State private var editingEntity: Entity? private var filtered: [Entity] { viewModel.contracts.filter { c in @@ -25,6 +27,16 @@ struct ContractsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle("Contracts") .searchable(text: $searchText, prompt: "Search contracts…") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { editingEntity = nil; showingForm = true } label: { + Label("Add Contract", systemImage: "plus") + } + } + } + .sheet(isPresented: $showingForm) { + ContractFormSheet(viewModel: viewModel, editing: editingEntity) + } .onAppear { viewModel.loadAll() } .refreshable { viewModel.loadAll() } } @@ -141,6 +153,15 @@ struct ContractsView: View { StatPill(label: "Invoices", value: "\(contract.int("num_invoices"))", icon: "doc.text") } } + + Button { + editingEntity = contract + showingForm = true + } label: { + Label("Edit Contract", systemImage: "pencil") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) } .padding(20) } diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/EntityForms.swift b/TuttleMac/Sources/TuttleMac/Views/Business/EntityForms.swift new file mode 100644 index 00000000..0ac893b0 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Business/EntityForms.swift @@ -0,0 +1,386 @@ +import SwiftUI + +// MARK: - Contact Form + +struct ContactFormSheet: View { + @Environment(\.dismiss) private var dismiss + let viewModel: BusinessViewModel + let editing: Entity? + + @State private var firstName = "" + @State private var lastName = "" + @State private var company = "" + @State private var email = "" + @State private var street = "" + @State private var city = "" + @State private var postalCode = "" + @State private var country = "" + + init(viewModel: BusinessViewModel, editing: Entity? = nil) { + self.viewModel = viewModel + self.editing = editing + if let e = editing { + _firstName = State(initialValue: e.str("first_name")) + _lastName = State(initialValue: e.str("last_name")) + _company = State(initialValue: e.str("company")) + _email = State(initialValue: e.str("email")) + _city = State(initialValue: e.str("city")) + _country = State(initialValue: e.str("country")) + } + } + + private var isValid: Bool { + !firstName.isEmpty && !lastName.isEmpty + } + + var body: some View { + VStack(spacing: 0) { + FormHeader(title: editing == nil ? "New Contact" : "Edit Contact", icon: "person.badge.plus") + + Form { + Section("Name") { + HStack(spacing: 10) { + TextField("First name", text: $firstName) + TextField("Last name", text: $lastName) + } + TextField("Company", text: $company) + } + + Section("Contact") { + TextField("Email", text: $email) + } + + Section("Address") { + TextField("Street", text: $street) + HStack(spacing: 10) { + TextField("Postal code", text: $postalCode) + .frame(width: 100) + TextField("City", text: $city) + } + TextField("Country", text: $country) + } + } + .formStyle(.grouped) + + FormActions(isValid: isValid, onCancel: { dismiss() }) { + viewModel.saveContact( + id: editing?.id, + firstName: firstName, lastName: lastName, + company: company, email: email, + street: street, city: city, + postalCode: postalCode, country: country + ) + dismiss() + } + } + .frame(width: 420, height: 400) + } +} + +// MARK: - Client Form + +struct ClientFormSheet: View { + @Environment(\.dismiss) private var dismiss + let viewModel: BusinessViewModel + let editing: Entity? + + @State private var name = "" + @State private var selectedContactId: Int? + + init(viewModel: BusinessViewModel, editing: Entity? = nil) { + self.viewModel = viewModel + self.editing = editing + if let e = editing { + _name = State(initialValue: e.str("name")) + } + } + + private var isValid: Bool { !name.isEmpty } + + var body: some View { + VStack(spacing: 0) { + FormHeader(title: editing == nil ? "New Client" : "Edit Client", icon: "building.2.fill") + + Form { + Section("Client") { + TextField("Client name", text: $name) + } + + Section("Invoicing Contact") { + Picker("Contact", selection: $selectedContactId) { + Text("None").tag(nil as Int?) + ForEach(viewModel.contacts) { contact in + Text(contact.fullName.isEmpty ? contact.str("company") : contact.fullName) + .tag(contact.id as Int?) + } + } + } + } + .formStyle(.grouped) + + FormActions(isValid: isValid, onCancel: { dismiss() }) { + viewModel.saveClient( + id: editing?.id, + name: name, + contactId: selectedContactId + ) + dismiss() + } + } + .frame(width: 380, height: 280) + } +} + +// MARK: - Contract Form + +struct ContractFormSheet: View { + @Environment(\.dismiss) private var dismiss + let viewModel: BusinessViewModel + let editing: Entity? + + @State private var title = "" + @State private var selectedClientId: Int? + @State private var rate = "" + @State private var currency = "EUR" + @State private var unit = "hour" + @State private var billingCycle = "monthly" + @State private var vatRate = "0.19" + @State private var startDate = Date() + @State private var endDate = Date() + @State private var hasEndDate = false + @State private var volume = "" + + private let currencies = ["EUR", "USD", "GBP", "CHF"] + private let units = ["hour", "day"] + private let cycles = ["monthly", "quarterly", "yearly"] + + init(viewModel: BusinessViewModel, editing: Entity? = nil) { + self.viewModel = viewModel + self.editing = editing + if let e = editing { + _title = State(initialValue: e.str("title")) + _rate = State(initialValue: e.num("rate") > 0 ? String(format: "%.2f", e.num("rate")) : "") + _currency = State(initialValue: e.str("currency").isEmpty ? "EUR" : e.str("currency")) + _unit = State(initialValue: e.str("unit_value").isEmpty ? "hour" : e.str("unit_value")) + _billingCycle = State(initialValue: e.str("billing_cycle_value").isEmpty ? "monthly" : e.str("billing_cycle_value")) + let vr = e.num("VAT_rate") + _vatRate = State(initialValue: vr > 0 ? String(format: "%.2f", vr) : "0.19") + if let sd = Self.parseDate(e.str("start_date")) { _startDate = State(initialValue: sd) } + if let ed = Self.parseDate(e.optStr("end_date") ?? "") { + _endDate = State(initialValue: ed) + _hasEndDate = State(initialValue: true) + } + if let v = e.optInt("volume") { _volume = State(initialValue: String(v)) } + } + } + + private var isValid: Bool { !title.isEmpty && !rate.isEmpty } + + var body: some View { + VStack(spacing: 0) { + FormHeader(title: editing == nil ? "New Contract" : "Edit Contract", icon: "signature") + + Form { + Section("Basics") { + TextField("Title", text: $title) + Picker("Client", selection: $selectedClientId) { + Text("None").tag(nil as Int?) + ForEach(viewModel.clients) { client in + Text(client.name).tag(client.id as Int?) + } + } + } + + Section("Terms") { + HStack(spacing: 10) { + TextField("Rate", text: $rate) + .frame(width: 100) + Picker("", selection: $currency) { + ForEach(currencies, id: \.self) { Text($0) } + } + .labelsHidden() + .frame(width: 80) + Text("/") + .foregroundStyle(.secondary) + Picker("", selection: $unit) { + ForEach(units, id: \.self) { Text($0) } + } + .labelsHidden() + .frame(width: 80) + } + HStack(spacing: 10) { + Picker("Billing", selection: $billingCycle) { + ForEach(cycles, id: \.self) { Text($0.capitalized) } + } + TextField("Volume", text: $volume) + .frame(width: 80) + } + HStack { + Text("VAT") + TextField("", text: $vatRate) + .frame(width: 60) + Text("(\(String(format: "%.0f%%", (Double(vatRate) ?? 0) * 100)))") + .foregroundStyle(.secondary) + } + } + + Section("Period") { + DatePicker("Start", selection: $startDate, displayedComponents: .date) + Toggle("Has end date", isOn: $hasEndDate) + if hasEndDate { + DatePicker("End", selection: $endDate, displayedComponents: .date) + } + } + } + .formStyle(.grouped) + + FormActions(isValid: isValid, onCancel: { dismiss() }) { + viewModel.saveContract( + id: editing?.id, + title: title, clientId: selectedClientId, + rate: rate, currency: currency, unit: unit, + billingCycle: billingCycle, vatRate: vatRate, + startDate: startDate, endDate: hasEndDate ? endDate : nil, + volume: volume + ) + dismiss() + } + } + .frame(width: 480, height: 520) + } + + private static func parseDate(_ str: String) -> Date? { + guard !str.isEmpty else { return nil } + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + return fmt.date(from: str) + } +} + +// MARK: - Project Form + +struct ProjectFormSheet: View { + @Environment(\.dismiss) private var dismiss + let viewModel: BusinessViewModel + let editing: Entity? + + @State private var title = "" + @State private var tag = "" + @State private var desc = "" + @State private var selectedContractId: Int? + @State private var startDate = Date() + @State private var endDate = Date() + @State private var hasEndDate = false + + init(viewModel: BusinessViewModel, editing: Entity? = nil) { + self.viewModel = viewModel + self.editing = editing + if let e = editing { + _title = State(initialValue: e.str("title")) + _tag = State(initialValue: e.str("tag")) + _desc = State(initialValue: e.str("description")) + if let sd = Self.parseDate(e.str("start_date")) { _startDate = State(initialValue: sd) } + if let ed = Self.parseDate(e.optStr("end_date") ?? "") { + _endDate = State(initialValue: ed) + _hasEndDate = State(initialValue: true) + } + } + } + + private var isValid: Bool { !title.isEmpty } + + var body: some View { + VStack(spacing: 0) { + FormHeader(title: editing == nil ? "New Project" : "Edit Project", icon: "folder.badge.plus") + + Form { + Section("Project") { + TextField("Title", text: $title) + TextField("Tag (short identifier)", text: $tag) + } + + Section("Contract") { + Picker("Contract", selection: $selectedContractId) { + Text("None").tag(nil as Int?) + ForEach(viewModel.contracts) { contract in + Text(contract.title).tag(contract.id as Int?) + } + } + } + + Section("Period") { + DatePicker("Start", selection: $startDate, displayedComponents: .date) + Toggle("Has end date", isOn: $hasEndDate) + if hasEndDate { + DatePicker("End", selection: $endDate, displayedComponents: .date) + } + } + + Section("Description") { + TextEditor(text: $desc) + .frame(height: 60) + } + } + .formStyle(.grouped) + + FormActions(isValid: isValid, onCancel: { dismiss() }) { + viewModel.saveProject( + id: editing?.id, + title: title, tag: tag, description: desc, + contractId: selectedContractId, + startDate: startDate, endDate: hasEndDate ? endDate : nil + ) + dismiss() + } + } + .frame(width: 420, height: 460) + } + + private static func parseDate(_ str: String) -> Date? { + guard !str.isEmpty else { return nil } + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + return fmt.date(from: str) + } +} + +// MARK: - Shared Form Components + +struct FormHeader: View { + let title: String + let icon: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(.tint) + Text(title) + .font(.headline) + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 4) + } +} + +struct FormActions: View { + let isValid: Bool + let onCancel: () -> Void + let onSave: () -> Void + + var body: some View { + HStack { + Spacer() + Button("Cancel", role: .cancel, action: onCancel) + .keyboardShortcut(.escape, modifiers: []) + Button("Save", action: onSave) + .keyboardShortcut(.return, modifiers: .command) + .buttonStyle(.borderedProminent) + .disabled(!isValid) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift index e4e6f0ee..eda8c760 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift @@ -5,6 +5,8 @@ struct ProjectsView: View { @State private var selectedProject: Entity? @State private var statusFilter: EntityStatus = .all @State private var searchText = "" + @State private var showingForm = false + @State private var editingEntity: Entity? private var filtered: [Entity] { viewModel.projects.filter { p in @@ -26,6 +28,16 @@ struct ProjectsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle("Projects") .searchable(text: $searchText, prompt: "Search projects…") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { editingEntity = nil; showingForm = true } label: { + Label("Add Project", systemImage: "plus") + } + } + } + .sheet(isPresented: $showingForm) { + ProjectFormSheet(viewModel: viewModel, editing: editingEntity) + } .onAppear { viewModel.loadAll() } .refreshable { viewModel.loadAll() } } @@ -141,6 +153,15 @@ struct ProjectsView: View { StatPill(label: "Timesheets", value: "\(project.int("num_timesheets"))", icon: "clock") } } + + Button { + editingEntity = project + showingForm = true + } label: { + Label("Edit Project", systemImage: "pencil") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) } .padding(20) } From eda5800cdcda18717775f48bef5fbfcbe0d1eebc Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sun, 10 May 2026 13:06:10 +0200 Subject: [PATCH 4/4] Add project and invoice management features with Kanban board integration - Introduced a new `setProjectStatus` method in `BusinessViewModel` to toggle project completion status and refresh project data. - Enhanced `ProjectsView` and `InvoicingView` to support a segmented view mode (list and board) for better user experience. - Implemented `KanbanBoardView` for visual project and invoice management, allowing drag-and-drop functionality between columns. - Added `InvoiceColumn` and `ProjectColumn` enums to define invoice and project states, respectively, with associated colors and icons. - Created `InvoiceCardContent` and `ProjectCardContent` views for displaying invoice and project details in the Kanban board layout. - Updated filtering logic for projects and invoices to support search functionality in both list and board views. - Integrated state management for invoice and project movements across different columns, ensuring accurate status updates. - Enhanced UI components for a consistent look and feel across the application. --- .../ViewModels/BusinessViewModel.swift | 16 + .../Views/Business/ProjectsView.swift | 108 ++++++- .../Views/CRM/InvoicePipelineView.swift | 111 +++++++ .../TuttleMac/Views/CRM/KanbanBoardView.swift | 298 ++++++++++++++++++ .../Views/CRM/ProjectPipelineView.swift | 125 ++++++++ .../Views/Invoicing/InvoicingView.swift | 119 ++++++- 6 files changed, 765 insertions(+), 12 deletions(-) create mode 100644 TuttleMac/Sources/TuttleMac/Views/CRM/InvoicePipelineView.swift create mode 100644 TuttleMac/Sources/TuttleMac/Views/CRM/KanbanBoardView.swift create mode 100644 TuttleMac/Sources/TuttleMac/Views/CRM/ProjectPipelineView.swift diff --git a/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift b/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift index 87b7db1e..de3d8cbb 100644 --- a/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift +++ b/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift @@ -146,6 +146,22 @@ final class BusinessViewModel { }) } + func setProjectStatus(_ id: Int, completed: Bool) { + PythonBridge.shared.run({ + let intent = PythonBridge.shared.projects! + let result = intent.get_by_id(id) + guard PythonBridge.isOk(result) else { return false } + let project = result.data + let isCompleted = Bool(project.is_completed) ?? false + if isCompleted != completed { + return PythonBridge.isOk(intent.toggle_project_completed_status(project)) + } + return true + }, completion: { [self] (ok: Bool) in + if ok { self.loadAll() } + }) + } + // MARK: - Save (create or update) func saveContact( diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift index eda8c760..10667267 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift @@ -1,5 +1,13 @@ import SwiftUI +// MARK: - View Mode + +enum ViewMode: String { + case list, board +} + +// MARK: - Projects View + struct ProjectsView: View { @State private var viewModel = BusinessViewModel() @State private var selectedProject: Entity? @@ -7,6 +15,12 @@ struct ProjectsView: View { @State private var searchText = "" @State private var showingForm = false @State private var editingEntity: Entity? + @State private var viewMode: ViewMode = .list + + @State private var stageStore = StageStore( + key: "project", + defaultColumn: ProjectColumn.defaultColumn + ) private var filtered: [Entity] { viewModel.projects.filter { p in @@ -18,20 +32,34 @@ struct ProjectsView: View { } } + private var boardFiltered: [Entity] { + viewModel.projects.filter { p in + searchText.isEmpty + || p.str("title").localizedCaseInsensitiveContains(searchText) + || p.str("client_name").localizedCaseInsensitiveContains(searchText) + || p.str("tag").localizedCaseInsensitiveContains(searchText) + } + } + var body: some View { - HStack(spacing: 0) { - projectList - Divider() - detailPane - .frame(minWidth: 280, maxWidth: 380) + Group { + switch viewMode { + case .list: + listLayout + case .board: + boardLayout + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle("Projects") .searchable(text: $searchText, prompt: "Search projects…") .toolbar { ToolbarItem(placement: .primaryAction) { - Button { editingEntity = nil; showingForm = true } label: { - Label("Add Project", systemImage: "plus") + HStack(spacing: 8) { + viewModeToggle + Button { editingEntity = nil; showingForm = true } label: { + Label("Add Project", systemImage: "plus") + } } } } @@ -42,6 +70,72 @@ struct ProjectsView: View { .refreshable { viewModel.loadAll() } } + // MARK: - View Mode Toggle + + private var viewModeToggle: some View { + Picker("View", selection: $viewMode) { + Image(systemName: "list.bullet").tag(ViewMode.list) + Image(systemName: "rectangle.3.group").tag(ViewMode.board) + } + .pickerStyle(.segmented) + .frame(width: 80) + } + + // MARK: - List Layout + + private var listLayout: some View { + HStack(spacing: 0) { + projectList + Divider() + detailPane + .frame(minWidth: 280, maxWidth: 380) + } + } + + // MARK: - Board Layout + + private var boardLayout: some View { + KanbanBoardView( + entities: boardFiltered, + stageStore: stageStore, + searchText: searchText, + onMove: { id, col in moveProject(id, to: col) }, + onTap: { project in + selectedProject = project + }, + onDelete: { project in + stageStore.removeEntity(project.id) + viewModel.deleteProject(project.id) + } + ) { project, _ in + ProjectCardContent(project: project) + } + .background(Color(nsColor: .windowBackgroundColor)) + .onReceive(NotificationCenter.default.publisher(for: .kanbanMove)) { note in + guard viewMode == .board, + let info = note.userInfo, + let entityId = info["entityId"] as? Int, + let raw = info["column"] as? String, + let col = ProjectColumn(rawValue: raw) + else { return } + moveProject(entityId, to: col) + } + } + + private func moveProject(_ projectId: Int, to column: ProjectColumn) { + withAnimation(.easeInOut(duration: 0.25)) { + stageStore.setColumn(column, for: projectId) + } + if column == .completed { + viewModel.setProjectStatus(projectId, completed: true) + } else { + let project = viewModel.projects.first { $0.id == projectId } + if project?.entityStatus == .completed { + viewModel.setProjectStatus(projectId, completed: false) + } + } + } + // MARK: - List private var projectList: some View { diff --git a/TuttleMac/Sources/TuttleMac/Views/CRM/InvoicePipelineView.swift b/TuttleMac/Sources/TuttleMac/Views/CRM/InvoicePipelineView.swift new file mode 100644 index 00000000..8800b837 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/CRM/InvoicePipelineView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +// MARK: - Invoice Column + +enum InvoiceColumn: String, CaseIterable, BoardColumn { + case draft = "Draft" + case sent = "Sent" + case paid = "Paid" + case overdue = "Overdue" + case cancelled = "Cancelled" + + var color: Color { + switch self { + case .draft: .secondary + case .sent: .blue + case .paid: .green + case .overdue: .red + case .cancelled: .orange + } + } + + var icon: String { + switch self { + case .draft: "doc" + case .sent: "paperplane" + case .paid: "checkmark.circle.fill" + case .overdue: "exclamationmark.triangle" + case .cancelled: "xmark.circle" + } + } + + static func defaultColumn(for invoice: Entity) -> InvoiceColumn { + switch invoice.invoiceStatus { + case .draft: .draft + case .sent: .sent + case .paid: .paid + case .overdue: .overdue + case .cancelled: .cancelled + case .all: .draft + } + } +} + +// MARK: - Invoice Card Content + +struct InvoiceCardContent: View { + let invoice: Entity + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(invoice.str("number").isEmpty ? "Draft" : invoice.number) + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + Text(invoice.str("total_formatted")) + .font(.subheadline) + .fontWeight(.bold) + .monospacedDigit() + } + + if !invoice.str("client_name").isEmpty { + HStack(spacing: 4) { + Image(systemName: "building.2") + .font(.caption2) + .foregroundStyle(.tertiary) + Text(invoice.str("client_name")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if !invoice.str("project_title").isEmpty { + HStack(spacing: 4) { + Image(systemName: "folder") + .font(.caption2) + .foregroundStyle(.tertiary) + Text(invoice.str("project_title")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + HStack(spacing: 12) { + if !invoice.str("date").isEmpty { + HStack(spacing: 3) { + Image(systemName: "calendar") + .font(.caption2) + Text(Entity.formatDateStr(invoice.str("date"))) + .font(.caption2) + } + .foregroundStyle(.tertiary) + } + + Spacer() + + let itemCount = invoice.list("items").count + if itemCount > 0 { + HStack(spacing: 2) { + Image(systemName: "list.bullet") + .font(.caption2) + Text("\(itemCount) items") + .font(.caption2) + } + .foregroundStyle(.tertiary) + } + } + } + } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/CRM/KanbanBoardView.swift b/TuttleMac/Sources/TuttleMac/Views/CRM/KanbanBoardView.swift new file mode 100644 index 00000000..87a308df --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/CRM/KanbanBoardView.swift @@ -0,0 +1,298 @@ +import SwiftUI + +// MARK: - BoardColumn Protocol + +protocol BoardColumn: RawRepresentable, CaseIterable, Identifiable, Hashable, Sendable +where AllCases: RandomAccessCollection { + var color: Color { get } + var icon: String { get } +} + +extension BoardColumn { + var id: String { rawValue } +} + +// MARK: - Draggable Entity ID (Transferable) + +struct DraggableEntityID: Codable, Transferable { + let entityId: Int + + static var transferRepresentation: some TransferRepresentation { + CodableRepresentation(contentType: .json) + } +} + +// MARK: - Stage Store + +/// Persists entity-to-column assignments in UserDefaults, namespaced by a key prefix. +/// Entities without an explicit assignment fall back to a `defaultColumn` closure. +@Observable +final class StageStore { + private let storageKey: String + private let defaultColumn: (Entity) -> C + private(set) var stages: [Int: String] = [:] + + init(key: String, defaultColumn: @escaping (Entity) -> C) { + self.storageKey = "tuttle.board.\(key)" + self.defaultColumn = defaultColumn + if let dict = UserDefaults.standard.dictionary(forKey: storageKey) as? [String: String] { + stages = dict.reduce(into: [:]) { result, pair in + if let id = Int(pair.key) { result[id] = pair.value } + } + } + } + + func column(for entity: Entity) -> C { + if let raw = stages[entity.id], let col = C(rawValue: raw) { + return col + } + return defaultColumn(entity) + } + + func setColumn(_ column: C, for entityId: Int) { + stages[entityId] = column.rawValue + persist() + } + + func removeEntity(_ entityId: Int) { + stages.removeValue(forKey: entityId) + persist() + } + + private func persist() { + let dict = stages.reduce(into: [String: String]()) { $0[String($1.key)] = $1.value } + UserDefaults.standard.set(dict, forKey: storageKey) + } +} + +// MARK: - Generic Kanban Board View + +struct KanbanBoardView: View { + let entities: [Entity] + let stageStore: StageStore + let searchText: String + let onMove: (Int, C) -> Void + let onTap: (Entity) -> Void + let onDelete: (Entity) -> Void + @ViewBuilder let cardContent: (Entity, C) -> CardContent + + private func entities(for column: C) -> [Entity] { + entities.filter { stageStore.column(for: $0) == column } + } + + var body: some View { + VStack(spacing: 0) { + boardHeader + Divider() + boardColumns + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Header + + private var boardHeader: some View { + HStack(spacing: 16) { + ForEach(Array(C.allCases), id: \.id) { column in + let count = entities(for: column).count + HStack(spacing: 6) { + Image(systemName: column.icon) + .foregroundStyle(column.color) + .font(.caption) + Text(column.rawValue) + .font(.subheadline) + .fontWeight(.semibold) + Text("\(count)") + .font(.caption2) + .fontWeight(.bold) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(column.color.opacity(0.7), in: Capsule()) + } + } + Spacer() + Text("\(entities.count) total") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + // MARK: - Columns + + private var boardColumns: some View { + HStack(alignment: .top, spacing: 0) { + let allColumns = Array(C.allCases) + ForEach(allColumns, id: \.id) { column in + KanbanColumnView( + column: column, + entities: entities(for: column), + onDrop: { entityId in onMove(entityId, column) }, + onTap: onTap, + onDelete: onDelete, + allColumns: allColumns, + cardContent: cardContent + ) + + if column != allColumns.last { + Divider() + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Generic Kanban Column + +struct KanbanColumnView: View { + let column: C + let entities: [Entity] + let onDrop: (Int) -> Void + let onTap: (Entity) -> Void + let onDelete: (Entity) -> Void + let allColumns: [C] + @ViewBuilder let cardContent: (Entity, C) -> CardContent + + @State private var isTargeted = false + + var body: some View { + VStack(spacing: 0) { + columnHeader + .padding(.horizontal, 12) + .padding(.top, 12) + .padding(.bottom, 8) + + ScrollView { + LazyVStack(spacing: 8) { + ForEach(entities) { entity in + KanbanCardWrapper( + entity: entity, + column: column, + allColumns: allColumns, + onTap: { onTap(entity) }, + onDelete: { onDelete(entity) } + ) { + cardContent(entity, column) + } + .draggable(DraggableEntityID(entityId: entity.id)) + } + } + .padding(.horizontal, 12) + .padding(.bottom, 12) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(isTargeted ? column.color.opacity(0.06) : Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 0) + .strokeBorder( + isTargeted ? column.color.opacity(0.3) : .clear, + lineWidth: 2 + ) + ) + .dropDestination(for: DraggableEntityID.self) { items, _ in + guard let item = items.first else { return false } + onDrop(item.entityId) + return true + } isTargeted: { targeted in + isTargeted = targeted + } + } + + private var columnHeader: some View { + HStack(spacing: 6) { + Circle() + .fill(column.color) + .frame(width: 8, height: 8) + Text(column.rawValue.uppercased()) + .font(.caption) + .fontWeight(.bold) + .foregroundStyle(.secondary) + .tracking(1) + Spacer() + } + } +} + +// MARK: - Card Wrapper (hover, context menu, chrome) + +struct KanbanCardWrapper: View { + let entity: Entity + let column: C + let allColumns: [C] + let onTap: () -> Void + let onDelete: () -> Void + @ViewBuilder let content: Content + + @State private var isHovered = false + + var body: some View { + content + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(.background) + .shadow( + color: .black.opacity(isHovered ? 0.12 : 0.06), + radius: isHovered ? 6 : 3, + y: isHovered ? 2 : 1 + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder( + column.color.opacity(isHovered ? 0.3 : 0.1), + lineWidth: 1 + ) + ) + .contentShape(Rectangle()) + .onHover { isHovered = $0 } + .onTapGesture(perform: onTap) + .contextMenu { + ForEach(allColumns.filter { $0 != column }, id: \.id) { col in + Button("Move to \(col.rawValue)") { + NotificationCenter.default.post( + name: .kanbanMove, + object: nil, + userInfo: ["entityId": entity.id, "column": col.rawValue] + ) + } + } + Divider() + Button("Delete", role: .destructive) { + onDelete() + } + } + .animation(.easeInOut(duration: 0.15), value: isHovered) + } +} + +// MARK: - Column Badge + +struct ColumnBadge: View { + let column: C + + var body: some View { + HStack(spacing: 4) { + Image(systemName: column.icon) + .font(.caption2) + Text(column.rawValue) + .font(.caption2) + .fontWeight(.semibold) + } + .foregroundStyle(column.color) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(column.color.opacity(0.1), in: Capsule()) + } +} + +// MARK: - Shared Notification + +extension Notification.Name { + static let kanbanMove = Notification.Name("tuttle.kanbanMove") +} diff --git a/TuttleMac/Sources/TuttleMac/Views/CRM/ProjectPipelineView.swift b/TuttleMac/Sources/TuttleMac/Views/CRM/ProjectPipelineView.swift new file mode 100644 index 00000000..67f4ac31 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/CRM/ProjectPipelineView.swift @@ -0,0 +1,125 @@ +import SwiftUI + +// MARK: - Project Column + +enum ProjectColumn: String, CaseIterable, BoardColumn { + case lead = "Lead" + case offer = "Offer" + case upcoming = "Upcoming" + case active = "Active" + case completed = "Completed" + + var color: Color { + switch self { + case .lead: .purple + case .offer: .orange + case .upcoming: .blue + case .active: .green + case .completed: .secondary + } + } + + var icon: String { + switch self { + case .lead: "lightbulb" + case .offer: "envelope.open" + case .upcoming: "clock" + case .active: "circle.fill" + case .completed: "checkmark.circle.fill" + } + } + + static func defaultColumn(for project: Entity) -> ProjectColumn { + switch project.entityStatus { + case .active: .active + case .upcoming: .upcoming + case .completed: .completed + case .all: .active + } + } +} + +// MARK: - Project Card Content + +struct ProjectCardContent: View { + let project: Entity + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(project.title) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(2) + Spacer() + if !project.str("tag").isEmpty { + Text(project.tag) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 4)) + } + } + + if !project.str("client_name").isEmpty { + HStack(spacing: 4) { + Image(systemName: "building.2") + .font(.caption2) + .foregroundStyle(.tertiary) + Text(project.str("client_name")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if !project.str("contract_title").isEmpty { + HStack(spacing: 4) { + Image(systemName: "signature") + .font(.caption2) + .foregroundStyle(.tertiary) + Text(project.str("contract_title")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + HStack(spacing: 12) { + if !project.dateRange.isEmpty { + HStack(spacing: 3) { + Image(systemName: "calendar") + .font(.caption2) + Text(project.dateRange) + .font(.caption2) + } + .foregroundStyle(.tertiary) + } + + Spacer() + + HStack(spacing: 8) { + if project.int("num_invoices") > 0 { + HStack(spacing: 2) { + Image(systemName: "doc.text") + .font(.caption2) + Text("\(project.int("num_invoices"))") + .font(.caption2) + } + .foregroundStyle(.tertiary) + } + if project.int("num_timesheets") > 0 { + HStack(spacing: 2) { + Image(systemName: "clock") + .font(.caption2) + Text("\(project.int("num_timesheets"))") + .font(.caption2) + } + .foregroundStyle(.tertiary) + } + } + } + } + } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift b/TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift index bd9f5ee5..2c0daebb 100644 --- a/TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift +++ b/TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift @@ -5,6 +5,12 @@ struct InvoicingView: View { @State private var selectedInvoice: Entity? @State private var statusFilter: InvoiceStatus = .all @State private var searchText = "" + @State private var viewMode: ViewMode = .list + + @State private var stageStore = StageStore( + key: "invoice", + defaultColumn: InvoiceColumn.defaultColumn + ) private var filtered: [Entity] { viewModel.invoices.filter { inv in @@ -16,24 +22,127 @@ struct InvoicingView: View { } } + private var boardFiltered: [Entity] { + viewModel.invoices.filter { inv in + searchText.isEmpty + || inv.str("number").localizedCaseInsensitiveContains(searchText) + || inv.str("client_name").localizedCaseInsensitiveContains(searchText) + || inv.str("project_title").localizedCaseInsensitiveContains(searchText) + } + } + private var totalFiltered: Double { filtered.reduce(0) { $0 + $1.num("total_value") } } var body: some View { - HStack(spacing: 0) { - invoiceList - Divider() - detailPane - .frame(minWidth: 320, maxWidth: 420) + Group { + switch viewMode { + case .list: + listLayout + case .board: + boardLayout + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle("Invoicing") .searchable(text: $searchText, prompt: "Search invoices…") + .toolbar { + ToolbarItem(placement: .primaryAction) { + viewModeToggle + } + } .onAppear { viewModel.loadInvoices() } .refreshable { viewModel.loadInvoices() } } + // MARK: - View Mode Toggle + + private var viewModeToggle: some View { + Picker("View", selection: $viewMode) { + Image(systemName: "list.bullet").tag(ViewMode.list) + Image(systemName: "rectangle.3.group").tag(ViewMode.board) + } + .pickerStyle(.segmented) + .frame(width: 80) + } + + // MARK: - List Layout + + private var listLayout: some View { + HStack(spacing: 0) { + invoiceList + Divider() + detailPane + .frame(minWidth: 320, maxWidth: 420) + } + } + + // MARK: - Board Layout + + private var boardLayout: some View { + KanbanBoardView( + entities: boardFiltered, + stageStore: stageStore, + searchText: searchText, + onMove: { id, col in moveInvoice(id, to: col) }, + onTap: { invoice in + selectedInvoice = invoice + }, + onDelete: { invoice in + stageStore.removeEntity(invoice.id) + viewModel.deleteInvoice(invoice.id) + } + ) { invoice, _ in + InvoiceCardContent(invoice: invoice) + } + .background(Color(nsColor: .windowBackgroundColor)) + .onReceive(NotificationCenter.default.publisher(for: .kanbanMove)) { note in + guard viewMode == .board, + let info = note.userInfo, + let entityId = info["entityId"] as? Int, + let raw = info["column"] as? String, + let col = InvoiceColumn(rawValue: raw) + else { return } + moveInvoice(entityId, to: col) + } + } + + private func moveInvoice(_ invoiceId: Int, to column: InvoiceColumn) { + guard let invoice = viewModel.invoices.first(where: { $0.id == invoiceId }) else { return } + let current = stageStore.column(for: invoice) + if current == column { return } + + withAnimation(.easeInOut(duration: 0.25)) { + stageStore.setColumn(column, for: invoiceId) + } + + let isSent = invoice.bool("sent") + let isPaid = invoice.bool("paid") + let isCancelled = invoice.bool("cancelled") + + switch column { + case .draft: + if isSent { viewModel.toggleSent(invoiceId) } + if isPaid { viewModel.togglePaid(invoiceId) } + if isCancelled { viewModel.toggleCancelled(invoiceId) } + case .sent: + if isCancelled { viewModel.toggleCancelled(invoiceId) } + if isPaid { viewModel.togglePaid(invoiceId) } + if !isSent { viewModel.toggleSent(invoiceId) } + case .paid: + if isCancelled { viewModel.toggleCancelled(invoiceId) } + if !isSent { viewModel.toggleSent(invoiceId) } + if !isPaid { viewModel.togglePaid(invoiceId) } + case .overdue: + if isCancelled { viewModel.toggleCancelled(invoiceId) } + if !isSent { viewModel.toggleSent(invoiceId) } + if isPaid { viewModel.togglePaid(invoiceId) } + case .cancelled: + if !isCancelled { viewModel.toggleCancelled(invoiceId) } + } + } + // MARK: - List private var invoiceList: some View {