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.
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/generated/prisma/browser.ts +59 -0
- package/generated/prisma/client.ts +81 -0
- package/generated/prisma/commonInputTypes.ts +402 -0
- package/generated/prisma/enums.ts +15 -0
- package/generated/prisma/internal/class.ts +260 -0
- package/generated/prisma/internal/prismaNamespace.ts +1362 -0
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/generated/prisma/models/Customer.ts +1489 -0
- package/generated/prisma/models/Invoice.ts +1837 -0
- package/generated/prisma/models/Project.ts +1981 -0
- package/generated/prisma/models/Setting.ts +1086 -0
- package/generated/prisma/models/Tag.ts +1288 -0
- package/generated/prisma/models/Task.ts +1669 -0
- package/generated/prisma/models/TaskTag.ts +1340 -0
- package/generated/prisma/models/TimeEntry.ts +1602 -0
- package/generated/prisma/models.ts +19 -0
- package/package.json +71 -0
- package/prisma/migrations/20260115051911_init/migration.sql +71 -0
- package/prisma/migrations/20260115062427_add_time_tracking/migration.sql +20 -0
- package/prisma/migrations/20260117233250_add_customers_invoices/migration.sql +81 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +162 -0
- package/src/App.tsx +1492 -0
- package/src/components/CreateInvoiceModal.tsx +222 -0
- package/src/components/CustomerModal.tsx +158 -0
- package/src/components/CustomerSelectModal.tsx +142 -0
- package/src/components/Dashboard.tsx +242 -0
- package/src/components/DateTimePicker.tsx +335 -0
- package/src/components/EditTimeEntryModal.tsx +293 -0
- package/src/components/Header.tsx +65 -0
- package/src/components/HelpView.tsx +109 -0
- package/src/components/InputModal.tsx +79 -0
- package/src/components/InvoicesView.tsx +297 -0
- package/src/components/Modal.tsx +38 -0
- package/src/components/ProjectList.tsx +114 -0
- package/src/components/ProjectModal.tsx +116 -0
- package/src/components/SettingsView.tsx +145 -0
- package/src/components/SplashScreen.tsx +25 -0
- package/src/components/StatusBar.tsx +93 -0
- package/src/components/TaskList.tsx +143 -0
- package/src/components/Timer.tsx +95 -0
- package/src/components/TimerModals.tsx +120 -0
- package/src/components/TimesheetView.tsx +218 -0
- package/src/components/index.ts +17 -0
- package/src/db.ts +629 -0
- package/src/hooks/usePaste.ts +69 -0
- package/src/index.tsx +75 -0
- package/src/stripe.ts +163 -0
- 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
|
+
}
|