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..9e7651a4 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Models/AppModels.swift @@ -0,0 +1,320 @@ +import Foundation +import SwiftUI + +// MARK: - Entity (generic wrapper for Python model data) + +@dynamicMemberLookup +struct Entity: Identifiable, Hashable { + let data: [String: Any] + + var id: Int { data["id"] as? Int ?? UUID().hashValue } + + 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) + } + + 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) + } + + 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 + } + + 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 + } + + 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 + } + + func date(_ key: String) -> Date? { + data[key] as? Date + } + + func entity(_ key: String) -> Entity? { + guard let d = data[key] as? [String: Any] else { return nil } + return Entity(data: d) + } + + func list(_ key: String) -> [Entity] { + guard let arr = data[key] as? [[String: Any]] else { return [] } + return arr.map { Entity(data: $0) } + } + + func has(_ key: String) -> Bool { + data[key] != nil + } + + 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 + } + + 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 + } +} + +// MARK: - Entity Display Extensions + +extension Entity { + var fullName: String { + [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 { + [str("city"), str("country")].filter { !$0.isEmpty }.joined(separator: ", ") + } + + 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 "" + } + + 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) + } + + var monthKey: String { + guard let d = date("_date") else { return "" } + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM" + return fmt.string(from: d) + } + + var monthLabel: String { + guard let d = date("_date") else { return "" } + let fmt = DateFormatter() + fmt.dateFormat = "MMMM yyyy" + return fmt.string(from: d) + } + + 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: - 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" + 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" + } + } +} + +// 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: - Timeline Category + +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" + } + } +} + +// 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..e2122232 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Python/PythonBridge.swift @@ -0,0 +1,280 @@ +import Foundation +import PythonKit + +/// 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() + + // 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 + + private init() { + let readySem = DispatchSemaphore(value: 0) + let initSem = DispatchSemaphore(value: 0) + + var capturedRunLoop: CFRunLoop! + + _thread = Thread { + capturedRunLoop = CFRunLoopGetCurrent() + 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 + + 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) + + 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() + } + + /// 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() + DispatchQueue.main.async { + completion(result) + } + } + CFRunLoopWakeUp(_runLoop) + } + + /// 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" + 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: - Python → Swift Conversion + +extension PythonBridge { + /// Check IntentResult success + static func isOk(_ result: PythonObject) -> Bool { + Bool(result.was_intent_successful) ?? false + } + + /// Format a Python numeric value with tuttle's fmt_currency + static func fmtCurrencyStr(_ amount: PythonObject, _ currency: String) -> String { + String(PythonBridge.shared.fmtCurrency(amount, currency)) ?? "—" + } + + /// 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) + } + } + + /// 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/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..de3d8cbb --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/ViewModels/BusinessViewModel.swift @@ -0,0 +1,352 @@ +import Foundation +import PythonKit + +@Observable +final class BusinessViewModel { + var clients: [Entity] = [] + var contacts: [Entity] = [] + var contracts: [Entity] = [] + var projects: [Entity] = [] + + var isLoading = false + var isSaving = false + var errorMessage: String? + + func loadAll() { + isLoading = true + errorMessage = nil + + 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) ?? "" + } + : [] + + 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 + } + : [] + + 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 + } + : [] + + 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 (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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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() } + }) + } + + 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( + 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/ViewModels/DashboardViewModel.swift b/TuttleMac/Sources/TuttleMac/ViewModels/DashboardViewModel.swift new file mode 100644 index 00000000..224c10ce --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/ViewModels/DashboardViewModel.swift @@ -0,0 +1,137 @@ +import Foundation +import PythonKit + +struct DashboardData { + var kpis: Entity? + var revenueData: [MonthlyDataPoint] + var spendableData: [MonthlyDataPoint] + var projectBudgets: [Entity] + var financialGoals: [Entity] +} + +@Observable +final class DashboardViewModel { + var kpis: Entity? + var revenueData: [MonthlyDataPoint] = [] + var spendableData: [MonthlyDataPoint] = [] + var projectBudgets: [Entity] = [] + var financialGoals: [Entity] = [] + var isLoading = false + var errorMessage: String? + + func loadAll() { + isLoading = true + errorMessage = nil + + PythonBridge.shared.run({ + let db = PythonBridge.shared.dashboard! + + // 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.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: Double(Python.float(item["revenue"])) ?? 0 + )) + } + for item in data["spendable"] { + let month = String(item["month"]) ?? "" + sp.append(MonthlyDataPoint( + month: month, + label: Self.shortLabel(month), + value: Double(Python.float(item["spendable"])) ?? 0 + )) + } + } + + // Project budgets + let budgetResult = db.get_project_budgets() + var budgets: [Entity] = [] + if PythonBridge.isOk(budgetResult) { + budgets = PythonBridge.dictListToEntities(budgetResult.data) + } + + // 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)) + } + } + + return DashboardData( + kpis: kpis, + revenueData: rev, + spendableData: sp, + projectBudgets: budgets, + financialGoals: goals + ) + }, completion: { [self] (data: DashboardData) 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 } + return "\(parts[1])/\(parts[0].suffix(2))" + } +} diff --git a/TuttleMac/Sources/TuttleMac/ViewModels/InvoicingViewModel.swift b/TuttleMac/Sources/TuttleMac/ViewModels/InvoicingViewModel.swift new file mode 100644 index 00000000..e74b9044 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/ViewModels/InvoicingViewModel.swift @@ -0,0 +1,87 @@ +import Foundation +import PythonKit + +@Observable +final class InvoicingViewModel { + var invoices: [Entity] = [] + var isLoading = false + var errorMessage: String? + + func loadInvoices() { + isLoading = true + errorMessage = nil + + 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 + } + }, completion: { [self] data in + self.invoices = data + self.isLoading = false + }) + } + + func deleteInvoice(_ id: Int) { + 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) { 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") } + + 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 new file mode 100644 index 00000000..dfc73367 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/ViewModels/TimelineViewModel.swift @@ -0,0 +1,105 @@ +import Foundation +import PythonKit + +@Observable +final class TimelineViewModel { + var events: [Entity] = [] + var activeFilter: TimelineCategory = .all + var searchQuery: String = "" + var isLoading = false + var errorMessage: String? + + var filteredEvents: [Entity] { + var result = events + if activeFilter != .all { + result = result.filter { + TimelineCategory(rawValue: $0.str("category")) == activeFilter + } + } + if !searchQuery.isEmpty { + let q = searchQuery.lowercased() + result = result.filter { + $0.str("title").lowercased().contains(q) + || $0.str("description").lowercased().contains(q) + } + } + return result + } + + var groupedEvents: [(key: String, label: String, events: [Entity])] { + let filtered = filteredEvents + var groups: [(key: String, label: String, events: [Entity])] = [] + var currentKey = "" + var currentEvents: [Entity] = [] + 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({ + 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 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 new file mode 100644 index 00000000..6a43912b --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ClientsView.swift @@ -0,0 +1,197 @@ +import SwiftUI + +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 } + return viewModel.clients.filter { + $0.str("name").localizedCaseInsensitiveContains(searchText) + || $0.str("contact_name").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…") + .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() } + } + + // 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.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.int("num_contracts"))", icon: "signature") + } + } + + Button { + editingEntity = client + showingForm = true + } label: { + Label("Edit Client", systemImage: "pencil") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + .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: Entity + + 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.str("contact_name").isEmpty { + Text(client.str("contact_name")) + .font(.caption) + .foregroundStyle(.secondary) + } + if !client.location.isEmpty { + Text("·") + .foregroundStyle(.quaternary) + Text(client.location) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + Spacer() + + if client.int("num_contracts") > 0 { + Text("\(client.int("num_contracts"))") + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary, in: Capsule()) + } + } + .padding(.vertical, 4) + } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift new file mode 100644 index 00000000..0ff47270 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ContactsView.swift @@ -0,0 +1,190 @@ +import SwiftUI + +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 } + return viewModel.contacts.filter { + $0.fullName.localizedCaseInsensitiveContains(searchText) + || $0.str("company").localizedCaseInsensitiveContains(searchText) + || $0.str("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…") + .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() } + } + + // 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.str("company").isEmpty { + Text(contact.company) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + Divider() + + DetailSection(title: "Contact Info") { + if !contact.str("email").isEmpty { + DetailRow(label: "Email", value: contact.email, icon: "envelope") + } + if !contact.location.isEmpty { + 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) + } + .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: Entity + + 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.str("company").isEmpty { + Text(contact.company) + .font(.caption) + .foregroundStyle(.secondary) + } + if !contact.str("email").isEmpty { + if !contact.str("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) + } +} diff --git a/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift b/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift new file mode 100644 index 00000000..5c0c20b7 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ContractsView.swift @@ -0,0 +1,216 @@ +import SwiftUI + +struct ContractsView: View { + @State private var viewModel = BusinessViewModel() + @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 + (statusFilter == .all || c.entityStatus == statusFilter) + && (searchText.isEmpty + || c.str("title").localizedCaseInsensitiveContains(searchText) + || c.str("client_name").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…") + .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() } + } + + // 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.bool("is_completed") ? "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 { + let status = contract.entityStatus + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 12) { + Image(systemName: "signature") + .font(.title2) + .foregroundStyle(status.color) + .frame(width: 48, height: 48) + .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.str("client_name")) + .font(.subheadline) + .foregroundStyle(.secondary) + StatusBadge(status: status) + } + } + } + + Divider() + + DetailSection(title: "Terms") { + 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.str("billing_cycle_value")) + DetailRow(label: "VAT", value: contract.vatPercent) + DetailRow(label: "Currency", value: contract.str("currency")) + } + + DetailSection(title: "Period") { + DetailRow(label: "Duration", value: contract.dateRange) + } + + DetailSection(title: "Related") { + HStack(spacing: 20) { + StatPill(label: "Projects", value: "\(contract.int("num_projects"))", icon: "folder") + 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) + } + .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: Entity + + var body: some View { + let status = contract.entityStatus + HStack(spacing: 12) { + Image(systemName: "signature") + .font(.body) + .foregroundStyle(status.color) + .frame(width: 34, height: 34) + .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.str("client_name")) + .font(.caption) + .foregroundStyle(.secondary) + Text("·") + .foregroundStyle(.quaternary) + Text(contract.str("rate_formatted") + "/\(contract.str("unit_value"))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + StatusBadge(status: status) + } + .padding(.vertical, 4) + } +} 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 new file mode 100644 index 00000000..10667267 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Business/ProjectsView.swift @@ -0,0 +1,311 @@ +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? + @State private var statusFilter: EntityStatus = .all + @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 + (statusFilter == .all || p.entityStatus == statusFilter) + && (searchText.isEmpty + || p.str("title").localizedCaseInsensitiveContains(searchText) + || p.str("client_name").localizedCaseInsensitiveContains(searchText) + || p.str("tag").localizedCaseInsensitiveContains(searchText)) + } + } + + 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 { + 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) { + HStack(spacing: 8) { + viewModeToggle + 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() } + } + + // 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 { + 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.bool("is_completed") ? "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 { + let status = project.entityStatus + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 12) { + InitialsAvatar( + text: String(project.str("title").prefix(2)).uppercased(), + color: 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: status) + } + } + } + + Divider() + + DetailSection(title: "Details") { + DetailRow(label: "Client", value: project.str("client_name")) + DetailRow(label: "Contract", value: project.str("contract_title")) + DetailRow(label: "Period", value: project.dateRange) + } + + if !project.str("description").isEmpty { + DetailSection(title: "Description") { + Text(project.description) + .font(.body) + .foregroundStyle(.secondary) + } + } + + DetailSection(title: "Activity") { + HStack(spacing: 20) { + StatPill(label: "Invoices", value: "\(project.int("num_invoices"))", icon: "doc.text") + 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) + } + .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: Entity + + var body: some View { + let status = project.entityStatus + HStack(spacing: 12) { + InitialsAvatar( + text: String(project.str("title").prefix(2)).uppercased(), + color: 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.str("client_name").isEmpty ? "No client" : project.str("client_name")) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + StatusBadge(status: status) + } + .padding(.vertical, 4) + } +} 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/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/Dashboard/DashboardView.swift b/TuttleMac/Sources/TuttleMac/Views/Dashboard/DashboardView.swift new file mode 100644 index 00000000..83cbb7c1 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Dashboard/DashboardView.swift @@ -0,0 +1,345 @@ +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: Entity) -> some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 160), spacing: 12)], spacing: 12) { + KPICard( + title: "Revenue (YTD)", + value: kpis.str("total_revenue_ytd_formatted"), + icon: "arrow.up.right", + valueColor: kpis.num("total_revenue_ytd") > 0 ? .green : .primary + ) + KPICard( + title: "Outstanding", + value: kpis.str("outstanding_amount_formatted"), + icon: "wallet.bifold", + valueColor: kpis.num("outstanding_amount") > 0 ? .yellow : .primary + ) + KPICard( + title: "Overdue", + value: kpis.str("overdue_amount_formatted"), + icon: "exclamationmark.triangle", + valueColor: kpis.num("overdue_amount") > 0 ? .red : .primary + ) + KPICard( + title: "Eff. Hourly Rate", + value: kpis.str("effective_hourly_rate_formatted"), + icon: "gauge.with.needle", + valueColor: .blue + ) + KPICard( + title: "Utilization", + value: kpis.str("utilization_rate_formatted"), + icon: "chart.pie", + valueColor: (kpis.optNum("utilization_rate") ?? 0) >= 0.7 ? .blue : .yellow + ) + KPICard( + title: "Active Projects", + value: "\(kpis.int("active_projects"))", + icon: "folder", + valueColor: .primary + ) + KPICard( + title: "Active Contracts", + value: "\(kpis.int("active_contracts"))", + icon: "signature", + valueColor: .primary + ) + KPICard( + title: "Unpaid Invoices", + value: "\(kpis.int("unpaid_invoices"))", + icon: "doc.text", + valueColor: kpis.int("unpaid_invoices") > 0 ? .yellow : .primary + ) + } + } + + private func taxSection(_ kpis: Entity) -> some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 200), spacing: 12)], spacing: 12) { + KPICard( + title: "VAT Reserve", + value: kpis.str("vat_reserve_formatted"), + icon: "building.columns", + valueColor: kpis.num("vat_reserve") > 0 ? .yellow : .primary + ) + KPICard( + title: "Est. Income Tax", + value: kpis.str("income_tax_reserve_formatted"), + icon: "function", + valueColor: kpis.num("income_tax_reserve") > 0 ? .yellow : .primary + ) + KPICard( + title: "Spendable Income", + value: kpis.str("spendable_income_formatted"), + icon: "banknote", + valueColor: kpis.num("spendable_income") > 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 { _ 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: Entity + + private var barColor: Color { + 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.str("project")) + .font(.body) + Spacer() + Text("\(Int(budget.num("hours_tracked"))) / \(Int(budget.num("hours_budget"))) h (\(Int(budget.num("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.num("progress"), 1.0), height: 6) + } + } + .frame(height: 6) + } + } +} + +struct FinancialGoalRow: View { + let goal: Entity + + private var barColor: Color { + goal.bool("is_reached") ? .green : .blue + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(goal.title) + .font(.body) + Spacer() + if goal.bool("is_reached") { + Text("Reached!") + .font(.caption) + .foregroundStyle(.green) + .fontWeight(.semibold) + } else { + Text("\(goal.str("ytd_revenue_formatted")) / \(goal.str("target_amount_formatted"))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Text("Target: \(goal.str("target_amount_formatted")) by \(goal.str("target_date_formatted"))") + .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.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 new file mode 100644 index 00000000..2c0daebb --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Invoicing/InvoicingView.swift @@ -0,0 +1,536 @@ +import SwiftUI + +struct InvoicingView: View { + @State private var viewModel = InvoicingViewModel() + @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 + (statusFilter == .all || inv.invoiceStatus == statusFilter) + && (searchText.isEmpty + || inv.str("number").localizedCaseInsensitiveContains(searchText) + || inv.str("client_name").localizedCaseInsensitiveContains(searchText) + || inv.str("project_title").localizedCaseInsensitiveContains(searchText)) + } + } + + 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 { + 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 { + 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: Entity) -> some View { + if !invoice.bool("cancelled") { + Button(invoice.bool("sent") ? "Mark as Not Sent" : "Mark as Sent") { + viewModel.toggleSent(invoice.id) + } + Button(invoice.bool("paid") ? "Mark as Unpaid" : "Mark as Paid") { + viewModel.togglePaid(invoice.id) + } + } + Button(invoice.bool("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: Entity) -> some View { + let status = invoice.invoiceStatus + return HStack(spacing: 12) { + Image(systemName: "doc.text") + .font(.title2) + .foregroundStyle(status.color) + .frame(width: 48, height: 48) + .background(status.color.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 2) { + Text(invoice.str("number").isEmpty ? "Draft" : invoice.number) + .font(.title2) + .fontWeight(.bold) + HStack(spacing: 6) { + Text(invoice.str("client_name").isEmpty ? "No client" : invoice.str("client_name")) + .font(.subheadline) + .foregroundStyle(.secondary) + InvoiceStatusBadge(status: status) + } + } + Spacer() + } + } + + 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: Entity) -> some View { + let items = invoice.list("items") + if !items.isEmpty { + DetailSection(title: "Line Items") { + VStack(spacing: 0) { + ForEach(items) { item in + InvoiceItemRow(item: item) + if item.id != items.last?.id { + Divider().padding(.vertical, 4) + } + } + } + } + } + } + + 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.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: Entity) -> some View { + DetailSection(title: "Actions") { + HStack(spacing: 10) { + if !invoice.bool("cancelled") { + ActionButton( + label: invoice.bool("sent") ? "Unsend" : "Mark Sent", + icon: "paperplane", + color: .blue, + isActive: invoice.bool("sent") + ) { + viewModel.toggleSent(invoice.id) + } + ActionButton( + label: invoice.bool("paid") ? "Unpay" : "Mark Paid", + icon: "checkmark.circle", + color: .green, + isActive: invoice.bool("paid") + ) { + viewModel.togglePaid(invoice.id) + } + } + ActionButton( + label: invoice.bool("cancelled") ? "Restore" : "Cancel", + icon: invoice.bool("cancelled") ? "arrow.uturn.left" : "xmark.circle", + color: .orange, + isActive: invoice.bool("cancelled") + ) { + viewModel.toggleCancelled(invoice.id) + } + } + } + } +} + +// MARK: - Invoice Row + +struct InvoiceRow: View { + let invoice: Entity + + var body: some View { + let status = invoice.invoiceStatus + HStack(spacing: 12) { + Image(systemName: "doc.text") + .font(.body) + .foregroundStyle(status.color) + .frame(width: 34, height: 34) + .background(status.color.opacity(0.1), in: RoundedRectangle(cornerRadius: 7)) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(invoice.str("number").isEmpty ? "Draft" : invoice.number) + .font(.body) + .fontWeight(.medium) + Text(Entity.formatDateStr(invoice.str("date"))) + .font(.caption) + .foregroundStyle(.tertiary) + } + HStack(spacing: 4) { + Text(invoice.str("client_name").isEmpty ? "No client" : invoice.str("client_name")) + .font(.caption) + .foregroundStyle(.secondary) + if !invoice.str("project_title").isEmpty { + Text("·") + .foregroundStyle(.quaternary) + Text(invoice.str("project_title")) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(invoice.str("total_formatted")) + .font(.subheadline) + .fontWeight(.semibold) + .monospacedDigit() + InvoiceStatusBadge(status: 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: Entity + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(item.description) + .font(.subheadline) + .fontWeight(.medium) + Spacer() + Text(item.str("subtotal_formatted")) + .font(.subheadline) + .fontWeight(.semibold) + .monospacedDigit() + } + HStack(spacing: 12) { + 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() + let itemDateRange = item.dateRange + if !itemDateRange.isEmpty { + Text(itemDateRange) + .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) + } +} + +// 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/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..15692073 --- /dev/null +++ b/TuttleMac/Sources/TuttleMac/Views/Timeline/TimelineView.swift @@ -0,0 +1,269 @@ +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 + 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 + + if !todayInserted && !event.bool("is_future"), + let eventDate = event.date("_date"), eventDate <= today { + let _ = { todayInserted = true }() + TodayMarker() + } + + TimelineEventCard(event: event, isLast: isLast) + } + } + + 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) { + 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: Entity + var isLast: Bool = false + + private var category: TimelineCategory { + TimelineCategory(rawValue: event.str("category")) ?? .invoice + } + + private var dotColor: Color { + switch event.str("status") { + case "paid", "completed": .green + case "overdue", "cancelled": .red + case "due": TimelineView.categoryColor(category) + default: TimelineView.categoryColor(category) + } + } + + private var categoryColor: Color { + TimelineView.categoryColor(category) + } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + 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) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + HStack(spacing: 6) { + Image(systemName: category.systemImage) + .font(.subheadline) + .foregroundStyle(dotColor) + Text(event.title) + .font(.body) + .fontWeight(.semibold) + } + Spacer() + Text(event.str("date_formatted")) + .font(.caption) + .foregroundStyle(.tertiary) + } + + if !event.str("description").isEmpty { + Text(event.description) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Text(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.bool("is_future") ? 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/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/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/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