pacatui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/generated/prisma/browser.ts +59 -0
  4. package/generated/prisma/client.ts +81 -0
  5. package/generated/prisma/commonInputTypes.ts +402 -0
  6. package/generated/prisma/enums.ts +15 -0
  7. package/generated/prisma/internal/class.ts +260 -0
  8. package/generated/prisma/internal/prismaNamespace.ts +1362 -0
  9. package/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  10. package/generated/prisma/models/Customer.ts +1489 -0
  11. package/generated/prisma/models/Invoice.ts +1837 -0
  12. package/generated/prisma/models/Project.ts +1981 -0
  13. package/generated/prisma/models/Setting.ts +1086 -0
  14. package/generated/prisma/models/Tag.ts +1288 -0
  15. package/generated/prisma/models/Task.ts +1669 -0
  16. package/generated/prisma/models/TaskTag.ts +1340 -0
  17. package/generated/prisma/models/TimeEntry.ts +1602 -0
  18. package/generated/prisma/models.ts +19 -0
  19. package/package.json +71 -0
  20. package/prisma/migrations/20260115051911_init/migration.sql +71 -0
  21. package/prisma/migrations/20260115062427_add_time_tracking/migration.sql +20 -0
  22. package/prisma/migrations/20260117233250_add_customers_invoices/migration.sql +81 -0
  23. package/prisma/migrations/migration_lock.toml +3 -0
  24. package/prisma/schema.prisma +162 -0
  25. package/src/App.tsx +1492 -0
  26. package/src/components/CreateInvoiceModal.tsx +222 -0
  27. package/src/components/CustomerModal.tsx +158 -0
  28. package/src/components/CustomerSelectModal.tsx +142 -0
  29. package/src/components/Dashboard.tsx +242 -0
  30. package/src/components/DateTimePicker.tsx +335 -0
  31. package/src/components/EditTimeEntryModal.tsx +293 -0
  32. package/src/components/Header.tsx +65 -0
  33. package/src/components/HelpView.tsx +109 -0
  34. package/src/components/InputModal.tsx +79 -0
  35. package/src/components/InvoicesView.tsx +297 -0
  36. package/src/components/Modal.tsx +38 -0
  37. package/src/components/ProjectList.tsx +114 -0
  38. package/src/components/ProjectModal.tsx +116 -0
  39. package/src/components/SettingsView.tsx +145 -0
  40. package/src/components/SplashScreen.tsx +25 -0
  41. package/src/components/StatusBar.tsx +93 -0
  42. package/src/components/TaskList.tsx +143 -0
  43. package/src/components/Timer.tsx +95 -0
  44. package/src/components/TimerModals.tsx +120 -0
  45. package/src/components/TimesheetView.tsx +218 -0
  46. package/src/components/index.ts +17 -0
  47. package/src/db.ts +629 -0
  48. package/src/hooks/usePaste.ts +69 -0
  49. package/src/index.tsx +75 -0
  50. package/src/stripe.ts +163 -0
  51. package/src/types.ts +361 -0
package/src/App.tsx ADDED
@@ -0,0 +1,1492 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { useKeyboard, useRenderer } from "@opentui/react";
3
+ import {
4
+ Header,
5
+ StatusBar,
6
+ ProjectList,
7
+ TaskList,
8
+ Dashboard,
9
+ HelpView,
10
+ SettingsView,
11
+ SETTINGS_COUNT,
12
+ InputModal,
13
+ ConfirmModal,
14
+ ProjectModal,
15
+ ProjectSelectModal,
16
+ StopTimerModal,
17
+ SplashScreen,
18
+ TimesheetView,
19
+ CustomerModal,
20
+ CustomerSelectModal,
21
+ EditTimeEntryModal,
22
+ CreateInvoiceModal,
23
+ } from "./components/index.ts";
24
+ import { InvoicesView } from "./components/InvoicesView.tsx";
25
+ import {
26
+ projects,
27
+ tasks,
28
+ stats,
29
+ timeEntries,
30
+ settings,
31
+ database,
32
+ customers,
33
+ invoices,
34
+ } from "./db.ts";
35
+ import { getOrCreateStripeCustomer, createDraftInvoice, listInvoices, type StripeInvoiceItem } from "./stripe.ts";
36
+ import {
37
+ getEffectiveTimezone,
38
+ type View,
39
+ type Panel,
40
+ type InputMode,
41
+ type ProjectWithTaskCounts,
42
+ type Task,
43
+ type DashboardStats,
44
+ type RunningTimer,
45
+ type AppSettings,
46
+ type TimesheetGroup,
47
+ type Customer,
48
+ type TimeEntry,
49
+ type TimeEntryWithProject,
50
+ } from "./types.ts";
51
+
52
+ function formatDuration(ms: number): string {
53
+ const seconds = Math.floor(ms / 1000);
54
+ const hours = Math.floor(seconds / 3600);
55
+ const minutes = Math.floor((seconds % 3600) / 60);
56
+ const secs = seconds % 60;
57
+
58
+ if (hours > 0) {
59
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
60
+ }
61
+ return `${minutes}:${secs.toString().padStart(2, "0")}`;
62
+ }
63
+
64
+ export function App() {
65
+ const renderer = useRenderer();
66
+
67
+ // Splash Screen State
68
+ const [showSplash, setShowSplash] = useState(true);
69
+
70
+ // View & Navigation State
71
+ const [currentView, setCurrentView] = useState<View>("dashboard");
72
+ const [activePanel, setActivePanel] = useState<Panel>("projects");
73
+ const [showArchived, setShowArchived] = useState(false);
74
+
75
+ // Data State
76
+ const [projectList, setProjectList] = useState<ProjectWithTaskCounts[]>([]);
77
+ const [taskList, setTaskList] = useState<Task[]>([]);
78
+ const [dashboardStats, setDashboardStats] = useState<DashboardStats>({
79
+ totalProjects: 0,
80
+ activeProjects: 0,
81
+ archivedProjects: 0,
82
+ totalTasks: 0,
83
+ todoTasks: 0,
84
+ inProgressTasks: 0,
85
+ doneTasks: 0,
86
+ overdueTasks: 0,
87
+ completionRate: 0,
88
+ });
89
+ const [recentTasks, setRecentTasks] = useState<
90
+ (Task & { project: { name: string; color: string } })[]
91
+ >([]);
92
+
93
+ // Timer State
94
+ const [runningTimer, setRunningTimer] = useState<RunningTimer | null>(null);
95
+ const [timerElapsed, setTimerElapsed] = useState(0);
96
+
97
+ // Selection State
98
+ const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
99
+ const [selectedTaskIndex, setSelectedTaskIndex] = useState(0);
100
+ const [selectedSettingsIndex, setSelectedSettingsIndex] = useState(0);
101
+ const [selectedDashboardTaskIndex, setSelectedDashboardTaskIndex] =
102
+ useState(0);
103
+
104
+ // Settings State
105
+ const [appSettings, setAppSettings] = useState<AppSettings>({
106
+ businessName: "",
107
+ stripeApiKey: "",
108
+ timezone: "auto",
109
+ });
110
+
111
+ // Timesheet State
112
+ const [timesheetGroups, setTimesheetGroups] = useState<TimesheetGroup[]>([]);
113
+ const [selectedTimesheetGroupIndex, setSelectedTimesheetGroupIndex] =
114
+ useState(0);
115
+ const [selectedTimeEntryIndex, setSelectedTimeEntryIndex] = useState(0);
116
+ const [selectedTimeEntryIds, setSelectedTimeEntryIds] = useState<Set<string>>(
117
+ new Set(),
118
+ );
119
+ const [editingTimeEntry, setEditingTimeEntry] = useState<
120
+ | (TimeEntry & {
121
+ project: {
122
+ id: string;
123
+ name: string;
124
+ color: string;
125
+ hourlyRate: number | null;
126
+ };
127
+ })
128
+ | null
129
+ >(null);
130
+
131
+ // Customer State
132
+ const [customerList, setCustomerList] = useState<Customer[]>([]);
133
+ const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
134
+
135
+ // Invoices State
136
+ const [stripeInvoices, setStripeInvoices] = useState<StripeInvoiceItem[]>([]);
137
+ const [selectedInvoiceIndex, setSelectedInvoiceIndex] = useState(0);
138
+ const [invoicesLoading, setInvoicesLoading] = useState(false);
139
+ const [invoicesError, setInvoicesError] = useState<string | null>(null);
140
+ const [invoicesPage, setInvoicesPage] = useState(1);
141
+ const [invoicesHasMore, setInvoicesHasMore] = useState(false);
142
+ const [invoicesCursors, setInvoicesCursors] = useState<string[]>([]); // Stack of cursors for pagination
143
+
144
+ // Modal State
145
+ const [inputMode, setInputMode] = useState<InputMode | null>(null);
146
+ const [statusMessage, setStatusMessage] =
147
+ useState<string>("Welcome to Paca!");
148
+ const [confirmAction, setConfirmAction] = useState<(() => void) | null>(null);
149
+ const [confirmMessage, setConfirmMessage] = useState("");
150
+
151
+ // Load data
152
+ const loadProjects = useCallback(async () => {
153
+ const data = await projects.getAll(showArchived);
154
+ setProjectList(data);
155
+ if (selectedProjectIndex >= data.length) {
156
+ setSelectedProjectIndex(Math.max(0, data.length - 1));
157
+ }
158
+ }, [showArchived, selectedProjectIndex]);
159
+
160
+ const loadTasks = useCallback(async () => {
161
+ const selectedProject = projectList[selectedProjectIndex];
162
+ if (selectedProject) {
163
+ const data = await tasks.getByProject(selectedProject.id);
164
+ setTaskList(data);
165
+ if (selectedTaskIndex >= data.length) {
166
+ setSelectedTaskIndex(Math.max(0, data.length - 1));
167
+ }
168
+ } else {
169
+ setTaskList([]);
170
+ }
171
+ }, [projectList, selectedProjectIndex, selectedTaskIndex]);
172
+
173
+ const loadDashboard = useCallback(async () => {
174
+ const [statsData, recent] = await Promise.all([
175
+ stats.getDashboardStats(),
176
+ stats.getRecentActivity(10),
177
+ ]);
178
+ setDashboardStats(statsData);
179
+ setRecentTasks(
180
+ recent as (Task & { project: { name: string; color: string } })[],
181
+ );
182
+ }, []);
183
+
184
+ const loadRunningTimer = useCallback(async () => {
185
+ const running = await timeEntries.getRunning();
186
+ if (running) {
187
+ setRunningTimer({
188
+ id: running.id,
189
+ startTime: running.startTime,
190
+ project: running.project,
191
+ });
192
+ } else {
193
+ setRunningTimer(null);
194
+ }
195
+ }, []);
196
+
197
+ const loadSettings = useCallback(async () => {
198
+ const data = await settings.getAppSettings();
199
+ setAppSettings(data);
200
+ }, []);
201
+
202
+ const loadCustomers = useCallback(async () => {
203
+ const data = await customers.getAll();
204
+ setCustomerList(data);
205
+ }, []);
206
+
207
+ const loadStripeInvoices = useCallback(async (cursor?: string, isNextPage = false) => {
208
+ if (!appSettings.stripeApiKey) {
209
+ setStripeInvoices([]);
210
+ setInvoicesError(null);
211
+ return;
212
+ }
213
+
214
+ setInvoicesLoading(true);
215
+ setInvoicesError(null);
216
+
217
+ try {
218
+ const result = await listInvoices(appSettings.stripeApiKey, 25, cursor);
219
+ setStripeInvoices(result.invoices);
220
+ setInvoicesHasMore(result.hasMore);
221
+ setSelectedInvoiceIndex(0);
222
+
223
+ // Track cursor for pagination
224
+ if (isNextPage && cursor) {
225
+ setInvoicesCursors(prev => [...prev, cursor]);
226
+ }
227
+ } catch (error) {
228
+ setInvoicesError(error instanceof Error ? error.message : "Failed to load invoices");
229
+ setStripeInvoices([]);
230
+ } finally {
231
+ setInvoicesLoading(false);
232
+ }
233
+ }, [appSettings.stripeApiKey]);
234
+
235
+ const loadTimesheets = useCallback(async () => {
236
+ const entries = await timeEntries.getUninvoiced();
237
+
238
+ // Group entries by project (only include projects with hourly rate > 0)
239
+ const groupMap = new Map<string, TimesheetGroup>();
240
+
241
+ for (const entry of entries) {
242
+ // Skip entries for projects without an hourly rate
243
+ if (!entry.project.hourlyRate || entry.project.hourlyRate <= 0) {
244
+ continue;
245
+ }
246
+
247
+ const projectId = entry.project.id;
248
+ let group = groupMap.get(projectId);
249
+
250
+ if (!group) {
251
+ group = {
252
+ project: entry.project as TimesheetGroup["project"],
253
+ entries: [],
254
+ totalMs: 0,
255
+ totalAmount: 0,
256
+ };
257
+ groupMap.set(projectId, group);
258
+ }
259
+
260
+ group.entries.push(entry as TimeEntryWithProject);
261
+
262
+ if (entry.endTime) {
263
+ const duration =
264
+ new Date(entry.endTime).getTime() -
265
+ new Date(entry.startTime).getTime();
266
+ group.totalMs += duration;
267
+ group.totalAmount += (duration / 3600000) * entry.project.hourlyRate;
268
+ }
269
+ }
270
+
271
+ // Convert to array sorted by project name
272
+ const groups = Array.from(groupMap.values()).sort((a, b) =>
273
+ a.project.name.localeCompare(b.project.name),
274
+ );
275
+
276
+ setTimesheetGroups(groups);
277
+
278
+ // Adjust selection if needed
279
+ if (selectedTimesheetGroupIndex >= groups.length) {
280
+ setSelectedTimesheetGroupIndex(Math.max(0, groups.length - 1));
281
+ }
282
+ if (groups[selectedTimesheetGroupIndex]) {
283
+ const currentGroup = groups[selectedTimesheetGroupIndex];
284
+ if (selectedTimeEntryIndex >= currentGroup.entries.length) {
285
+ setSelectedTimeEntryIndex(Math.max(0, currentGroup.entries.length - 1));
286
+ }
287
+ }
288
+ }, [selectedTimesheetGroupIndex, selectedTimeEntryIndex]);
289
+
290
+ // Hide splash screen after 2 seconds
291
+ useEffect(() => {
292
+ const timer = setTimeout(() => setShowSplash(false), 1000);
293
+ return () => clearTimeout(timer);
294
+ }, []);
295
+
296
+ // Initial load
297
+ useEffect(() => {
298
+ loadProjects();
299
+ loadDashboard();
300
+ loadRunningTimer();
301
+ loadSettings();
302
+ loadCustomers();
303
+ }, []);
304
+
305
+ // Reload tasks when project selection changes
306
+ useEffect(() => {
307
+ loadTasks();
308
+ }, [selectedProjectIndex, projectList]);
309
+
310
+ // Reload dashboard when on dashboard view
311
+ useEffect(() => {
312
+ if (currentView === "dashboard") {
313
+ loadDashboard();
314
+ }
315
+ }, [currentView]);
316
+
317
+ // Reload timesheets when on timesheets view
318
+ useEffect(() => {
319
+ if (currentView === "timesheets") {
320
+ loadTimesheets();
321
+ }
322
+ }, [currentView]);
323
+
324
+ // Load invoices when on invoices view
325
+ useEffect(() => {
326
+ if (currentView === "invoices") {
327
+ // Reset pagination when entering the view
328
+ setInvoicesPage(1);
329
+ setInvoicesCursors([]);
330
+ loadStripeInvoices();
331
+ }
332
+ }, [currentView, loadStripeInvoices]);
333
+
334
+ // Timer elapsed update
335
+ useEffect(() => {
336
+ if (!runningTimer) {
337
+ setTimerElapsed(0);
338
+ return;
339
+ }
340
+
341
+ const updateElapsed = () => {
342
+ const start = new Date(runningTimer.startTime).getTime();
343
+ setTimerElapsed(Date.now() - start);
344
+ };
345
+
346
+ updateElapsed();
347
+ const interval = setInterval(updateElapsed, 1000);
348
+
349
+ return () => clearInterval(interval);
350
+ }, [runningTimer]);
351
+
352
+ // Show message temporarily
353
+ const showMessage = (msg: string) => {
354
+ setStatusMessage(msg);
355
+ setTimeout(() => setStatusMessage("Ready"), 3000);
356
+ };
357
+
358
+ // Timer handlers
359
+ const handleStartTimer = async (projectId: string) => {
360
+ const entry = await timeEntries.start(projectId);
361
+ setRunningTimer({
362
+ id: entry.id,
363
+ startTime: entry.startTime,
364
+ project: entry.project,
365
+ });
366
+ setInputMode(null);
367
+ showMessage(`Timer started for ${entry.project.name}`);
368
+ };
369
+
370
+ const handleStopTimer = async (description: string) => {
371
+ if (!runningTimer) return;
372
+
373
+ const entry = await timeEntries.stop(
374
+ runningTimer.id,
375
+ description || undefined,
376
+ );
377
+ const duration =
378
+ new Date(entry.endTime!).getTime() - new Date(entry.startTime).getTime();
379
+
380
+ setRunningTimer(null);
381
+ setInputMode(null);
382
+ showMessage(`Timer stopped: ${formatDuration(duration)}`);
383
+ loadDashboard();
384
+
385
+ // Refresh timesheets if currently viewing them
386
+ if (currentView === "timesheets") {
387
+ loadTimesheets();
388
+ }
389
+ };
390
+
391
+ // Keyboard handling
392
+ useKeyboard((key) => {
393
+ // Handle confirm modal
394
+ if (confirmAction) {
395
+ if (key.name === "y") {
396
+ confirmAction();
397
+ setConfirmAction(null);
398
+ setConfirmMessage("");
399
+ } else if (key.name === "n" || key.name === "escape") {
400
+ setConfirmAction(null);
401
+ setConfirmMessage("");
402
+ }
403
+ return;
404
+ }
405
+
406
+ // Handle input mode escape (except select_timer_project which has its own handler)
407
+ if (inputMode && inputMode !== "select_timer_project") {
408
+ if (key.name === "escape") {
409
+ setInputMode(null);
410
+ }
411
+ return;
412
+ }
413
+
414
+ // Handle select timer project modal
415
+ if (inputMode === "select_timer_project") {
416
+ // The modal has its own keyboard handler
417
+ return;
418
+ }
419
+
420
+ // Global timer shortcuts
421
+ if (key.name === "t") {
422
+ if (runningTimer) {
423
+ showMessage("Timer already running. Press 's' to stop.");
424
+ } else if (projectList.length === 0) {
425
+ showMessage("Create a project first to start a timer");
426
+ } else {
427
+ setInputMode("select_timer_project");
428
+ }
429
+ return;
430
+ }
431
+
432
+ if (key.name === "s" && runningTimer) {
433
+ setInputMode("stop_timer");
434
+ return;
435
+ }
436
+
437
+ // Global navigation
438
+ if (key.name === "1") {
439
+ setCurrentView("dashboard");
440
+ return;
441
+ }
442
+ if (key.name === "2") {
443
+ setCurrentView("tasks");
444
+ setActivePanel("tasks");
445
+ return;
446
+ }
447
+ if (key.name === "3") {
448
+ setCurrentView("timesheets");
449
+ return;
450
+ }
451
+ if (key.name === "4") {
452
+ setCurrentView("invoices");
453
+ return;
454
+ }
455
+ if (key.name === "5") {
456
+ setCurrentView("settings");
457
+ return;
458
+ }
459
+ if (key.name === "?" || key.name === "slash") {
460
+ setCurrentView("help");
461
+ return;
462
+ }
463
+
464
+ // Quit
465
+ if (key.name === "q") {
466
+ // Stop renderer first to restore terminal state
467
+ renderer.stop();
468
+ // Give renderer time to finish, then reset terminal and exit
469
+ setTimeout(() => {
470
+ try {
471
+ require("child_process").spawnSync("reset", [], { stdio: "inherit" });
472
+ } catch {}
473
+ process.exit(0);
474
+ }, 50);
475
+ return;
476
+ }
477
+
478
+ // View-specific handling
479
+ if (currentView === "dashboard") {
480
+ handleDashboardKeyboard(key);
481
+ }
482
+ if (currentView === "projects" || currentView === "tasks") {
483
+ handleProjectsTasksKeyboard(key);
484
+ }
485
+ if (currentView === "settings") {
486
+ handleSettingsKeyboard(key);
487
+ }
488
+ if (currentView === "timesheets") {
489
+ handleTimesheetKeyboard(key);
490
+ }
491
+ if (currentView === "invoices") {
492
+ handleInvoicesKeyboard(key);
493
+ }
494
+ });
495
+
496
+ const handleDashboardKeyboard = (key: { name: string }) => {
497
+ const maxIndex = recentTasks.length - 1;
498
+
499
+ // Navigation
500
+ if (key.name === "j" || key.name === "down") {
501
+ setSelectedDashboardTaskIndex((i) => Math.min(i + 1, maxIndex));
502
+ return;
503
+ }
504
+ if (key.name === "k" || key.name === "up") {
505
+ setSelectedDashboardTaskIndex((i) => Math.max(i - 1, 0));
506
+ return;
507
+ }
508
+
509
+ // Edit task
510
+ if (key.name === "e" && recentTasks[selectedDashboardTaskIndex]) {
511
+ setInputMode("edit_dashboard_task");
512
+ return;
513
+ }
514
+
515
+ // Toggle status
516
+ if (key.name === "space" && recentTasks[selectedDashboardTaskIndex]) {
517
+ const task = recentTasks[selectedDashboardTaskIndex];
518
+ if (task) {
519
+ tasks.toggleStatus(task.id).then(() => {
520
+ loadDashboard();
521
+ });
522
+ }
523
+ return;
524
+ }
525
+
526
+ // Cycle priority
527
+ if (key.name === "p" && recentTasks[selectedDashboardTaskIndex]) {
528
+ const task = recentTasks[selectedDashboardTaskIndex];
529
+ if (task) {
530
+ const priorities = ["low", "medium", "high", "urgent"];
531
+ const currentIndex = priorities.indexOf(task.priority);
532
+ const nextPriority = priorities[(currentIndex + 1) % priorities.length];
533
+ tasks.update(task.id, { priority: nextPriority }).then(() => {
534
+ showMessage(`Priority: ${nextPriority}`);
535
+ loadDashboard();
536
+ });
537
+ }
538
+ return;
539
+ }
540
+
541
+ // Delete task
542
+ if (key.name === "d" && recentTasks[selectedDashboardTaskIndex]) {
543
+ const task = recentTasks[selectedDashboardTaskIndex];
544
+ if (task) {
545
+ setConfirmMessage(`Delete task "${task.title}"?`);
546
+ setConfirmAction(() => () => {
547
+ tasks.delete(task.id).then(() => {
548
+ showMessage(`Deleted: ${task.title}`);
549
+ loadDashboard();
550
+ loadProjects();
551
+ });
552
+ });
553
+ }
554
+ return;
555
+ }
556
+ };
557
+
558
+ const handleProjectsTasksKeyboard = (key: { name: string }) => {
559
+ // Tab or left/right arrows to switch panels
560
+ if (key.name === "tab") {
561
+ setActivePanel((p) => (p === "projects" ? "tasks" : "projects"));
562
+ return;
563
+ }
564
+ if (key.name === "left" || key.name === "h") {
565
+ setActivePanel("projects");
566
+ return;
567
+ }
568
+ if (key.name === "right" || key.name === "l") {
569
+ setActivePanel("tasks");
570
+ return;
571
+ }
572
+
573
+ // Toggle archived
574
+ if (key.name === "A") {
575
+ setShowArchived((s) => !s);
576
+ showMessage(
577
+ showArchived ? "Hiding archived projects" : "Showing archived projects",
578
+ );
579
+ loadProjects();
580
+ return;
581
+ }
582
+
583
+ if (activePanel === "projects") {
584
+ handleProjectKeyboard(key);
585
+ } else {
586
+ handleTaskKeyboard(key);
587
+ }
588
+ };
589
+
590
+ const handleProjectKeyboard = (key: { name: string }) => {
591
+ const maxIndex = projectList.length - 1;
592
+
593
+ // Navigation
594
+ if (key.name === "j" || key.name === "down") {
595
+ setSelectedProjectIndex((i) => Math.min(i + 1, maxIndex));
596
+ return;
597
+ }
598
+ if (key.name === "k" || key.name === "up") {
599
+ setSelectedProjectIndex((i) => Math.max(i - 1, 0));
600
+ return;
601
+ }
602
+
603
+ // Create project
604
+ if (key.name === "n") {
605
+ setInputMode("create_project");
606
+ return;
607
+ }
608
+
609
+ // Edit project
610
+ if (key.name === "e" && projectList[selectedProjectIndex]) {
611
+ setInputMode("edit_project");
612
+ return;
613
+ }
614
+
615
+ // Archive/Unarchive project
616
+ if (key.name === "a" && projectList[selectedProjectIndex]) {
617
+ const project = projectList[selectedProjectIndex];
618
+ if (project) {
619
+ if (project.archived) {
620
+ projects.unarchive(project.id).then(() => {
621
+ showMessage(`Unarchived: ${project.name}`);
622
+ loadProjects();
623
+ });
624
+ } else {
625
+ projects.archive(project.id).then(() => {
626
+ showMessage(`Archived: ${project.name}`);
627
+ loadProjects();
628
+ });
629
+ }
630
+ }
631
+ return;
632
+ }
633
+
634
+ // Delete project
635
+ if (key.name === "d" && projectList[selectedProjectIndex]) {
636
+ const project = projectList[selectedProjectIndex];
637
+ if (project) {
638
+ setConfirmMessage(`Delete project "${project.name}"?`);
639
+ setConfirmAction(() => () => {
640
+ projects.delete(project.id).then(() => {
641
+ showMessage(`Deleted: ${project.name}`);
642
+ loadProjects();
643
+ loadDashboard();
644
+ });
645
+ });
646
+ }
647
+ return;
648
+ }
649
+
650
+ // Select project (Enter) - switch to tasks
651
+ if (key.name === "return" && projectList[selectedProjectIndex]) {
652
+ setActivePanel("tasks");
653
+ setSelectedTaskIndex(0);
654
+ return;
655
+ }
656
+
657
+ // Link customer to project
658
+ if (key.name === "c" && projectList[selectedProjectIndex]) {
659
+ setInputMode("select_customer");
660
+ return;
661
+ }
662
+ };
663
+
664
+ const handleTaskKeyboard = (key: { name: string }) => {
665
+ const maxIndex = taskList.length - 1;
666
+
667
+ // Navigation
668
+ if (key.name === "j" || key.name === "down") {
669
+ setSelectedTaskIndex((i) => Math.min(i + 1, maxIndex));
670
+ return;
671
+ }
672
+ if (key.name === "k" || key.name === "up") {
673
+ setSelectedTaskIndex((i) => Math.max(i - 1, 0));
674
+ return;
675
+ }
676
+
677
+ // Create task
678
+ if (key.name === "n" && projectList[selectedProjectIndex]) {
679
+ setInputMode("create_task");
680
+ return;
681
+ }
682
+
683
+ // Edit task
684
+ if (key.name === "e" && taskList[selectedTaskIndex]) {
685
+ setInputMode("edit_task");
686
+ return;
687
+ }
688
+
689
+ // Toggle status
690
+ if (key.name === "space" && taskList[selectedTaskIndex]) {
691
+ const task = taskList[selectedTaskIndex];
692
+ if (task) {
693
+ tasks.toggleStatus(task.id).then(() => {
694
+ loadTasks();
695
+ loadDashboard();
696
+ });
697
+ }
698
+ return;
699
+ }
700
+
701
+ // Delete task
702
+ if (key.name === "d" && taskList[selectedTaskIndex]) {
703
+ const task = taskList[selectedTaskIndex];
704
+ if (task) {
705
+ setConfirmMessage(`Delete task "${task.title}"?`);
706
+ setConfirmAction(() => () => {
707
+ tasks.delete(task.id).then(() => {
708
+ showMessage(`Deleted: ${task.title}`);
709
+ loadTasks();
710
+ loadDashboard();
711
+ });
712
+ });
713
+ }
714
+ return;
715
+ }
716
+
717
+ // Cycle priority
718
+ if (key.name === "p" && taskList[selectedTaskIndex]) {
719
+ const task = taskList[selectedTaskIndex];
720
+ if (task) {
721
+ const priorities = ["low", "medium", "high", "urgent"];
722
+ const currentIndex = priorities.indexOf(task.priority);
723
+ const nextPriority = priorities[(currentIndex + 1) % priorities.length];
724
+ tasks.update(task.id, { priority: nextPriority }).then(() => {
725
+ showMessage(`Priority: ${nextPriority}`);
726
+ loadTasks();
727
+ });
728
+ }
729
+ return;
730
+ }
731
+ };
732
+
733
+ const handleSettingsKeyboard = (key: { name: string }) => {
734
+ const maxIndex = SETTINGS_COUNT - 1;
735
+
736
+ // Navigation
737
+ if (key.name === "j" || key.name === "down") {
738
+ setSelectedSettingsIndex((i) => Math.min(i + 1, maxIndex));
739
+ return;
740
+ }
741
+ if (key.name === "k" || key.name === "up") {
742
+ setSelectedSettingsIndex((i) => Math.max(i - 1, 0));
743
+ return;
744
+ }
745
+
746
+ // Select/activate setting
747
+ if (key.name === "return") {
748
+ switch (selectedSettingsIndex) {
749
+ case 0: // Business Name
750
+ setInputMode("edit_business_name");
751
+ break;
752
+ case 1: // Stripe API Key
753
+ setInputMode("edit_stripe_key");
754
+ break;
755
+ case 2: // Timezone
756
+ setInputMode("edit_timezone");
757
+ break;
758
+ case 3: // Export Database
759
+ handleExportDatabase();
760
+ break;
761
+ case 4: // Import Database
762
+ setConfirmMessage("Import will replace all data. Continue?");
763
+ setConfirmAction(() => () => handleImportDatabase());
764
+ break;
765
+ }
766
+ return;
767
+ }
768
+
769
+ // Escape to go back
770
+ if (key.name === "escape") {
771
+ setCurrentView("dashboard");
772
+ return;
773
+ }
774
+ };
775
+
776
+ const handleTimesheetKeyboard = (key: { name: string }) => {
777
+ const currentGroup = timesheetGroups[selectedTimesheetGroupIndex];
778
+ if (!currentGroup) return;
779
+
780
+ const maxGroupIndex = timesheetGroups.length - 1;
781
+ const maxEntryIndex = currentGroup.entries.length - 1;
782
+
783
+ // Navigation within entries
784
+ if (key.name === "j" || key.name === "down") {
785
+ if (selectedTimeEntryIndex < maxEntryIndex) {
786
+ setSelectedTimeEntryIndex((i) => i + 1);
787
+ } else if (selectedTimesheetGroupIndex < maxGroupIndex) {
788
+ // Move to next group
789
+ setSelectedTimesheetGroupIndex((i) => i + 1);
790
+ setSelectedTimeEntryIndex(0);
791
+ }
792
+ return;
793
+ }
794
+ if (key.name === "k" || key.name === "up") {
795
+ if (selectedTimeEntryIndex > 0) {
796
+ setSelectedTimeEntryIndex((i) => i - 1);
797
+ } else if (selectedTimesheetGroupIndex > 0) {
798
+ // Move to previous group
799
+ const prevGroup = timesheetGroups[selectedTimesheetGroupIndex - 1];
800
+ if (prevGroup) {
801
+ setSelectedTimesheetGroupIndex((i) => i - 1);
802
+ setSelectedTimeEntryIndex(prevGroup.entries.length - 1);
803
+ }
804
+ }
805
+ return;
806
+ }
807
+
808
+ // Toggle selection for invoicing
809
+ if (key.name === "space") {
810
+ const entry = currentGroup.entries[selectedTimeEntryIndex];
811
+ if (entry) {
812
+ setSelectedTimeEntryIds((prev) => {
813
+ const next = new Set(prev);
814
+ if (next.has(entry.id)) {
815
+ next.delete(entry.id);
816
+ } else {
817
+ next.add(entry.id);
818
+ }
819
+ return next;
820
+ });
821
+ }
822
+ return;
823
+ }
824
+
825
+ // Edit entry
826
+ if (key.name === "e") {
827
+ const entry = currentGroup.entries[selectedTimeEntryIndex];
828
+ if (entry) {
829
+ setEditingTimeEntry(entry as typeof editingTimeEntry);
830
+ setInputMode("edit_time_entry");
831
+ }
832
+ return;
833
+ }
834
+
835
+ // Delete entry
836
+ if (key.name === "d") {
837
+ const entry = currentGroup.entries[selectedTimeEntryIndex];
838
+ if (entry) {
839
+ setConfirmMessage(
840
+ `Delete time entry from ${currentGroup.project.name}?`,
841
+ );
842
+ setConfirmAction(() => () => {
843
+ timeEntries.delete(entry.id).then(() => {
844
+ showMessage("Time entry deleted");
845
+ loadTimesheets();
846
+ });
847
+ });
848
+ }
849
+ return;
850
+ }
851
+
852
+ // Create invoice
853
+ if (key.name === "i") {
854
+ // Get selected entries from current group (or all in group if none selected)
855
+ const selectedInGroup = currentGroup.entries.filter((e) =>
856
+ selectedTimeEntryIds.has(e.id),
857
+ );
858
+ if (selectedInGroup.length > 0 || currentGroup.entries.length > 0) {
859
+ setInputMode("create_invoice");
860
+ }
861
+ return;
862
+ }
863
+ };
864
+
865
+ const handleInvoicesKeyboard = (key: { name: string }) => {
866
+ const maxIndex = stripeInvoices.length - 1;
867
+
868
+ // Navigation
869
+ if (key.name === "j" || key.name === "down") {
870
+ setSelectedInvoiceIndex((i) => Math.min(i + 1, maxIndex));
871
+ return;
872
+ }
873
+ if (key.name === "k" || key.name === "up") {
874
+ setSelectedInvoiceIndex((i) => Math.max(i - 1, 0));
875
+ return;
876
+ }
877
+
878
+ // Open invoice in browser
879
+ if (key.name === "return") {
880
+ const invoice = stripeInvoices[selectedInvoiceIndex];
881
+ if (invoice) {
882
+ // Use hosted URL for finalized invoices, dashboard URL for drafts
883
+ const url = invoice.hostedUrl || invoice.dashboardUrl;
884
+ const { spawn } = require("child_process");
885
+ const platform = process.platform;
886
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
887
+ spawn(cmd, [url], { detached: true, stdio: "ignore" });
888
+ showMessage(invoice.hostedUrl ? "Opening invoice..." : "Opening in Stripe dashboard...");
889
+ }
890
+ return;
891
+ }
892
+
893
+ // Refresh
894
+ if (key.name === "r") {
895
+ setInvoicesPage(1);
896
+ setInvoicesCursors([]);
897
+ loadStripeInvoices();
898
+ showMessage("Refreshing invoices...");
899
+ return;
900
+ }
901
+
902
+ // Next page
903
+ if (key.name === "]" && invoicesHasMore) {
904
+ const lastInvoice = stripeInvoices[stripeInvoices.length - 1];
905
+ if (lastInvoice) {
906
+ setInvoicesPage((p) => p + 1);
907
+ loadStripeInvoices(lastInvoice.id, true);
908
+ }
909
+ return;
910
+ }
911
+
912
+ // Previous page
913
+ if (key.name === "[" && invoicesCursors.length > 0) {
914
+ const newCursors = [...invoicesCursors];
915
+ newCursors.pop(); // Remove current cursor
916
+ const prevCursor = newCursors.pop(); // Get previous cursor
917
+ setInvoicesCursors(newCursors);
918
+ setInvoicesPage((p) => Math.max(1, p - 1));
919
+ loadStripeInvoices(prevCursor, false);
920
+ return;
921
+ }
922
+
923
+ // Escape to go back
924
+ if (key.name === "escape") {
925
+ setCurrentView("dashboard");
926
+ return;
927
+ }
928
+ };
929
+
930
+ // Settings handlers
931
+ const handleExportDatabase = async () => {
932
+ const { existsSync, mkdirSync } = await import("fs");
933
+ const backupDir = `${process.env.HOME}/.paca/backups`;
934
+
935
+ // Ensure backups directory exists
936
+ if (!existsSync(backupDir)) {
937
+ mkdirSync(backupDir, { recursive: true });
938
+ }
939
+
940
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
941
+ const exportPath = `${backupDir}/paca-backup-${timestamp}.db`;
942
+ try {
943
+ await database.exportToFile(exportPath);
944
+ showMessage(`Exported to ${exportPath}`);
945
+ } catch (error) {
946
+ showMessage(`Export failed: ${error}`);
947
+ }
948
+ };
949
+
950
+ const handleImportDatabase = async () => {
951
+ // For now, show a message about how to import
952
+ // In a full implementation, you'd use a file picker
953
+ showMessage("Place backup file at ~/.paca/paca.db and restart");
954
+ };
955
+
956
+ const handleUpdateBusinessName = async (name: string) => {
957
+ await settings.set("businessName", name);
958
+ setAppSettings((prev) => ({ ...prev, businessName: name }));
959
+ setInputMode(null);
960
+ showMessage("Business name updated");
961
+ };
962
+
963
+ const handleUpdateStripeKey = async (key: string) => {
964
+ await settings.set("stripeApiKey", key);
965
+ setAppSettings((prev) => ({ ...prev, stripeApiKey: key }));
966
+ setInputMode(null);
967
+ showMessage("Stripe API key updated");
968
+ };
969
+
970
+ const handleUpdateTimezone = async (tz: string) => {
971
+ const value = tz.trim() || "auto";
972
+ await settings.set("timezone", value);
973
+ setAppSettings((prev) => ({ ...prev, timezone: value }));
974
+ setInputMode(null);
975
+ showMessage(`Timezone set to ${value === "auto" ? "auto-detect" : value}`);
976
+ };
977
+
978
+ // Modal handlers
979
+ const handleCreateProject = async (name: string, rate: number | null) => {
980
+ await projects.create({ name, hourlyRate: rate });
981
+ showMessage(`Created project: ${name}`);
982
+ setInputMode(null);
983
+ loadProjects();
984
+ loadDashboard();
985
+ };
986
+
987
+ const handleEditProject = async (name: string, rate: number | null) => {
988
+ const project = projectList[selectedProjectIndex];
989
+ if (project) {
990
+ await projects.update(project.id, { name, hourlyRate: rate });
991
+ showMessage(`Updated project: ${name}`);
992
+ setInputMode(null);
993
+ loadProjects();
994
+ }
995
+ };
996
+
997
+ const handleCreateTask = async (title: string) => {
998
+ const project = projectList[selectedProjectIndex];
999
+ if (project) {
1000
+ await tasks.create({ title, projectId: project.id });
1001
+ showMessage(`Created task: ${title}`);
1002
+ setInputMode(null);
1003
+ loadTasks();
1004
+ loadDashboard();
1005
+ }
1006
+ };
1007
+
1008
+ const handleEditTask = async (title: string) => {
1009
+ const task = taskList[selectedTaskIndex];
1010
+ if (task) {
1011
+ await tasks.update(task.id, { title });
1012
+ showMessage(`Updated task: ${title}`);
1013
+ setInputMode(null);
1014
+ loadTasks();
1015
+ }
1016
+ };
1017
+
1018
+ const handleEditDashboardTask = async (title: string) => {
1019
+ const task = recentTasks[selectedDashboardTaskIndex];
1020
+ if (task) {
1021
+ await tasks.update(task.id, { title });
1022
+ showMessage(`Updated task: ${title}`);
1023
+ setInputMode(null);
1024
+ loadDashboard();
1025
+ }
1026
+ };
1027
+
1028
+ // Customer handlers
1029
+ const handleCreateCustomer = async (name: string, email: string) => {
1030
+ try {
1031
+ await customers.create({ name, email });
1032
+ showMessage(`Created customer: ${name}`);
1033
+ setInputMode(null);
1034
+ loadCustomers();
1035
+ // Go back to select_customer mode
1036
+ setInputMode("select_customer");
1037
+ } catch (error) {
1038
+ showMessage(`Error creating customer: ${error}`);
1039
+ }
1040
+ };
1041
+
1042
+ const handleEditCustomer = async (
1043
+ name: string,
1044
+ email: string,
1045
+ stripeCustomerId?: string,
1046
+ ) => {
1047
+ if (!editingCustomer) return;
1048
+ try {
1049
+ await customers.update(editingCustomer.id, {
1050
+ name,
1051
+ email,
1052
+ stripeCustomerId: stripeCustomerId || null,
1053
+ });
1054
+ showMessage(`Updated customer: ${name}`);
1055
+ setInputMode(null);
1056
+ setEditingCustomer(null);
1057
+ loadCustomers();
1058
+ // Go back to select_customer mode
1059
+ setInputMode("select_customer");
1060
+ } catch (error) {
1061
+ showMessage(`Error updating customer: ${error}`);
1062
+ }
1063
+ };
1064
+
1065
+ const handleSelectCustomer = async (customerId: string | null) => {
1066
+ const project = projectList[selectedProjectIndex];
1067
+ if (project) {
1068
+ await projects.setCustomer(project.id, customerId);
1069
+ showMessage(
1070
+ customerId
1071
+ ? "Customer linked to project"
1072
+ : "Customer removed from project",
1073
+ );
1074
+ setInputMode(null);
1075
+ loadProjects();
1076
+ }
1077
+ };
1078
+
1079
+ // Timesheet handlers
1080
+ const handleEditTimeEntry = async (
1081
+ startTime: Date,
1082
+ endTime: Date,
1083
+ description: string,
1084
+ ) => {
1085
+ if (!editingTimeEntry) return;
1086
+ await timeEntries.update(editingTimeEntry.id, {
1087
+ startTime,
1088
+ endTime,
1089
+ description,
1090
+ });
1091
+ showMessage("Time entry updated");
1092
+ setInputMode(null);
1093
+ setEditingTimeEntry(null);
1094
+ loadTimesheets();
1095
+ };
1096
+
1097
+ const handleCreateInvoice = async () => {
1098
+ const currentGroup = timesheetGroups[selectedTimesheetGroupIndex];
1099
+ if (!currentGroup || !currentGroup.project.customer) {
1100
+ showMessage("No customer linked to project");
1101
+ return;
1102
+ }
1103
+
1104
+ if (!appSettings.stripeApiKey) {
1105
+ showMessage("No Stripe API key configured");
1106
+ return;
1107
+ }
1108
+
1109
+ // Get selected entries or all entries in current group
1110
+ const selectedInGroup = currentGroup.entries.filter((e) =>
1111
+ selectedTimeEntryIds.has(e.id),
1112
+ );
1113
+ const entriesToInvoice =
1114
+ selectedInGroup.length > 0 ? selectedInGroup : currentGroup.entries;
1115
+
1116
+ // Calculate totals
1117
+ const totalMs = entriesToInvoice.reduce((sum, e) => {
1118
+ if (!e.endTime) return sum;
1119
+ return (
1120
+ sum + (new Date(e.endTime).getTime() - new Date(e.startTime).getTime())
1121
+ );
1122
+ }, 0);
1123
+ const totalHours = totalMs / 3600000;
1124
+ const rate = currentGroup.project.hourlyRate ?? 0;
1125
+ const totalAmount = totalHours * rate;
1126
+
1127
+ try {
1128
+ const customer = currentGroup.project.customer;
1129
+
1130
+ // Get or create Stripe customer
1131
+ const stripeCustomerId = await getOrCreateStripeCustomer(
1132
+ appSettings.stripeApiKey,
1133
+ customer,
1134
+ );
1135
+
1136
+ // Update our customer record with Stripe ID if needed
1137
+ if (stripeCustomerId !== customer.stripeCustomerId) {
1138
+ await customers.updateStripeId(customer.id, stripeCustomerId);
1139
+ }
1140
+
1141
+ // Create line items from time entries
1142
+ const lineItems = entriesToInvoice
1143
+ .filter((e) => e.endTime) // Only include completed entries
1144
+ .map((e) => ({
1145
+ description:
1146
+ e.description ||
1147
+ `Time entry ${new Date(e.startTime).toLocaleDateString()}`,
1148
+ hours:
1149
+ (new Date(e.endTime!).getTime() -
1150
+ new Date(e.startTime).getTime()) /
1151
+ 3600000,
1152
+ rate,
1153
+ startTime: new Date(e.startTime),
1154
+ endTime: new Date(e.endTime!),
1155
+ }));
1156
+
1157
+ // Create Stripe draft invoice
1158
+ const stripeInvoiceId = await createDraftInvoice(
1159
+ appSettings.stripeApiKey,
1160
+ stripeCustomerId,
1161
+ currentGroup.project.name,
1162
+ lineItems,
1163
+ );
1164
+
1165
+ // Create local invoice record
1166
+ const invoice = await invoices.create({
1167
+ projectId: currentGroup.project.id,
1168
+ customerId: currentGroup.project.customer.id,
1169
+ totalHours,
1170
+ totalAmount,
1171
+ timeEntryIds: entriesToInvoice.map((e) => e.id),
1172
+ });
1173
+
1174
+ // Update with Stripe invoice ID
1175
+ await invoices.updateStripeId(invoice.id, stripeInvoiceId);
1176
+
1177
+ showMessage(`Stripe draft invoice created: ${stripeInvoiceId}`);
1178
+
1179
+ // Clear selections and reload
1180
+ setSelectedTimeEntryIds(new Set());
1181
+ setInputMode(null);
1182
+ loadTimesheets();
1183
+ } catch (error) {
1184
+ showMessage(`Error creating invoice: ${error}`);
1185
+ }
1186
+ };
1187
+
1188
+ const selectedProject = projectList[selectedProjectIndex];
1189
+
1190
+ // Show splash screen on boot
1191
+ if (showSplash) {
1192
+ return <SplashScreen />;
1193
+ }
1194
+
1195
+ return (
1196
+ <box
1197
+ style={{
1198
+ width: "100%",
1199
+ height: "100%",
1200
+ flexDirection: "column",
1201
+ }}
1202
+ >
1203
+ <Header
1204
+ currentView={currentView}
1205
+ onViewChange={setCurrentView}
1206
+ runningTimer={runningTimer}
1207
+ onStopTimer={() => setInputMode("stop_timer")}
1208
+ />
1209
+
1210
+ <box style={{ flexGrow: 1, flexDirection: "row" }}>
1211
+ {currentView === "dashboard" && (
1212
+ <Dashboard
1213
+ stats={dashboardStats}
1214
+ recentTasks={recentTasks}
1215
+ selectedIndex={selectedDashboardTaskIndex}
1216
+ focused={true}
1217
+ />
1218
+ )}
1219
+
1220
+ {currentView === "help" && <HelpView />}
1221
+
1222
+ {currentView === "settings" && (
1223
+ <SettingsView
1224
+ settings={appSettings}
1225
+ selectedIndex={selectedSettingsIndex}
1226
+ onEditBusinessName={() => setInputMode("edit_business_name")}
1227
+ onEditStripeKey={() => setInputMode("edit_stripe_key")}
1228
+ onEditTimezone={() => setInputMode("edit_timezone")}
1229
+ onExportDatabase={handleExportDatabase}
1230
+ onImportDatabase={() => {
1231
+ setConfirmMessage("Import will replace all data. Continue?");
1232
+ setConfirmAction(() => () => handleImportDatabase());
1233
+ }}
1234
+ />
1235
+ )}
1236
+
1237
+ {currentView === "timesheets" && (
1238
+ <TimesheetView
1239
+ groups={timesheetGroups}
1240
+ selectedGroupIndex={selectedTimesheetGroupIndex}
1241
+ selectedEntryIndex={selectedTimeEntryIndex}
1242
+ selectedEntryIds={selectedTimeEntryIds}
1243
+ focused={true}
1244
+ timezone={getEffectiveTimezone(appSettings)}
1245
+ />
1246
+ )}
1247
+
1248
+ {currentView === "invoices" && (
1249
+ <InvoicesView
1250
+ invoices={stripeInvoices}
1251
+ selectedIndex={selectedInvoiceIndex}
1252
+ focused={true}
1253
+ loading={invoicesLoading}
1254
+ error={invoicesError}
1255
+ hasStripeKey={!!appSettings.stripeApiKey}
1256
+ currentPage={invoicesPage}
1257
+ hasMore={invoicesHasMore}
1258
+ hasPrevious={invoicesCursors.length > 0}
1259
+ />
1260
+ )}
1261
+
1262
+ {(currentView === "projects" || currentView === "tasks") && (
1263
+ <>
1264
+ <box style={{ width: "40%", flexDirection: "column" }}>
1265
+ <ProjectList
1266
+ projects={projectList}
1267
+ selectedIndex={selectedProjectIndex}
1268
+ focused={activePanel === "projects"}
1269
+ showArchived={showArchived}
1270
+ />
1271
+ </box>
1272
+ <box style={{ width: "60%", flexDirection: "column" }}>
1273
+ <TaskList
1274
+ tasks={taskList}
1275
+ selectedIndex={selectedTaskIndex}
1276
+ focused={activePanel === "tasks"}
1277
+ projectName={selectedProject?.name}
1278
+ />
1279
+ </box>
1280
+ </>
1281
+ )}
1282
+ </box>
1283
+
1284
+ <StatusBar
1285
+ message={statusMessage}
1286
+ mode={inputMode || undefined}
1287
+ timerRunning={!!runningTimer}
1288
+ currentView={currentView}
1289
+ activePanel={activePanel}
1290
+ />
1291
+
1292
+ {/* Modals */}
1293
+ {inputMode === "create_project" && (
1294
+ <ProjectModal
1295
+ mode="create"
1296
+ onSubmit={handleCreateProject}
1297
+ onCancel={() => setInputMode(null)}
1298
+ />
1299
+ )}
1300
+
1301
+ {inputMode === "edit_project" && selectedProject && (
1302
+ <ProjectModal
1303
+ mode="edit"
1304
+ initialName={selectedProject.name}
1305
+ initialRate={selectedProject.hourlyRate}
1306
+ onSubmit={handleEditProject}
1307
+ onCancel={() => setInputMode(null)}
1308
+ />
1309
+ )}
1310
+
1311
+ {inputMode === "create_task" && (
1312
+ <InputModal
1313
+ mode={inputMode}
1314
+ title="Create New Task"
1315
+ placeholder="Task title..."
1316
+ onSubmit={handleCreateTask}
1317
+ onCancel={() => setInputMode(null)}
1318
+ />
1319
+ )}
1320
+
1321
+ {inputMode === "edit_task" && taskList[selectedTaskIndex] && (
1322
+ <InputModal
1323
+ mode={inputMode}
1324
+ title="Edit Task"
1325
+ initialValue={taskList[selectedTaskIndex]?.title || ""}
1326
+ placeholder="Task title..."
1327
+ onSubmit={handleEditTask}
1328
+ onCancel={() => setInputMode(null)}
1329
+ />
1330
+ )}
1331
+
1332
+ {inputMode === "edit_dashboard_task" &&
1333
+ recentTasks[selectedDashboardTaskIndex] && (
1334
+ <InputModal
1335
+ mode={inputMode}
1336
+ title="Edit Task"
1337
+ initialValue={recentTasks[selectedDashboardTaskIndex]?.title || ""}
1338
+ placeholder="Task title..."
1339
+ onSubmit={handleEditDashboardTask}
1340
+ onCancel={() => setInputMode(null)}
1341
+ />
1342
+ )}
1343
+
1344
+ {inputMode === "edit_business_name" && (
1345
+ <InputModal
1346
+ mode={inputMode}
1347
+ title="Business Name"
1348
+ initialValue={appSettings.businessName}
1349
+ placeholder="Enter business name..."
1350
+ onSubmit={handleUpdateBusinessName}
1351
+ onCancel={() => setInputMode(null)}
1352
+ />
1353
+ )}
1354
+
1355
+ {inputMode === "edit_stripe_key" && (
1356
+ <InputModal
1357
+ mode={inputMode}
1358
+ title="Stripe API Key"
1359
+ initialValue={appSettings.stripeApiKey}
1360
+ placeholder="sk_live_..."
1361
+ onSubmit={handleUpdateStripeKey}
1362
+ onCancel={() => setInputMode(null)}
1363
+ />
1364
+ )}
1365
+
1366
+ {inputMode === "edit_timezone" && (
1367
+ <InputModal
1368
+ mode={inputMode}
1369
+ title="Timezone (IANA format or 'auto')"
1370
+ initialValue={appSettings.timezone}
1371
+ placeholder="America/New_York, Europe/London, auto..."
1372
+ onSubmit={handleUpdateTimezone}
1373
+ onCancel={() => setInputMode(null)}
1374
+ />
1375
+ )}
1376
+
1377
+ {inputMode === "select_timer_project" && (
1378
+ <ProjectSelectModal
1379
+ projects={projectList.filter((p) => !p.archived)}
1380
+ onSelect={handleStartTimer}
1381
+ onCancel={() => setInputMode(null)}
1382
+ />
1383
+ )}
1384
+
1385
+ {inputMode === "stop_timer" && runningTimer && (
1386
+ <StopTimerModal
1387
+ projectName={runningTimer.project.name}
1388
+ projectColor={runningTimer.project.color}
1389
+ duration={formatDuration(timerElapsed)}
1390
+ onSubmit={handleStopTimer}
1391
+ onCancel={() => setInputMode(null)}
1392
+ />
1393
+ )}
1394
+
1395
+ {inputMode === "select_customer" && selectedProject && (
1396
+ <CustomerSelectModal
1397
+ customers={customerList}
1398
+ currentCustomerId={(selectedProject as any).customerId}
1399
+ projectName={selectedProject.name}
1400
+ onSelect={handleSelectCustomer}
1401
+ onCreateNew={() => setInputMode("create_customer")}
1402
+ onEdit={(customer) => {
1403
+ setEditingCustomer(customer);
1404
+ setInputMode("edit_customer");
1405
+ }}
1406
+ onCancel={() => setInputMode(null)}
1407
+ />
1408
+ )}
1409
+
1410
+ {inputMode === "create_customer" && (
1411
+ <CustomerModal
1412
+ mode="create"
1413
+ onSubmit={handleCreateCustomer}
1414
+ onCancel={() => setInputMode("select_customer")}
1415
+ />
1416
+ )}
1417
+
1418
+ {inputMode === "edit_customer" && editingCustomer && (
1419
+ <CustomerModal
1420
+ mode="edit"
1421
+ initialName={editingCustomer.name}
1422
+ initialEmail={editingCustomer.email}
1423
+ initialStripeId={editingCustomer.stripeCustomerId || ""}
1424
+ onSubmit={handleEditCustomer}
1425
+ onCancel={() => {
1426
+ setEditingCustomer(null);
1427
+ setInputMode("select_customer");
1428
+ }}
1429
+ />
1430
+ )}
1431
+
1432
+ {inputMode === "edit_time_entry" && editingTimeEntry && (
1433
+ <EditTimeEntryModal
1434
+ entry={editingTimeEntry}
1435
+ timezone={getEffectiveTimezone(appSettings)}
1436
+ onSubmit={handleEditTimeEntry}
1437
+ onCancel={() => {
1438
+ setInputMode(null);
1439
+ setEditingTimeEntry(null);
1440
+ }}
1441
+ />
1442
+ )}
1443
+
1444
+ {inputMode === "create_invoice" &&
1445
+ timesheetGroups[selectedTimesheetGroupIndex] && (
1446
+ <CreateInvoiceModal
1447
+ projectName={
1448
+ timesheetGroups[selectedTimesheetGroupIndex].project.name
1449
+ }
1450
+ projectColor={
1451
+ timesheetGroups[selectedTimesheetGroupIndex].project.color
1452
+ }
1453
+ hourlyRate={
1454
+ timesheetGroups[selectedTimesheetGroupIndex].project.hourlyRate
1455
+ }
1456
+ customer={
1457
+ timesheetGroups[selectedTimesheetGroupIndex].project.customer
1458
+ }
1459
+ entries={(() => {
1460
+ const group = timesheetGroups[selectedTimesheetGroupIndex];
1461
+ const selectedInGroup = group.entries.filter((e) =>
1462
+ selectedTimeEntryIds.has(e.id),
1463
+ );
1464
+ return selectedInGroup.length > 0
1465
+ ? selectedInGroup
1466
+ : group.entries;
1467
+ })()}
1468
+ timezone={getEffectiveTimezone(appSettings)}
1469
+ hasStripeKey={!!appSettings.stripeApiKey}
1470
+ onConfirm={handleCreateInvoice}
1471
+ onCancel={() => setInputMode(null)}
1472
+ />
1473
+ )}
1474
+
1475
+ {confirmAction && (
1476
+ <ConfirmModal
1477
+ title="Confirm Delete"
1478
+ message={confirmMessage}
1479
+ onConfirm={() => {
1480
+ confirmAction();
1481
+ setConfirmAction(null);
1482
+ setConfirmMessage("");
1483
+ }}
1484
+ onCancel={() => {
1485
+ setConfirmAction(null);
1486
+ setConfirmMessage("");
1487
+ }}
1488
+ />
1489
+ )}
1490
+ </box>
1491
+ );
1492
+ }