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
@@ -0,0 +1,222 @@
1
+ import { useKeyboard } from "@opentui/react";
2
+ import {
3
+ type Customer,
4
+ type TimeEntryWithProject,
5
+ formatDateInTimezone,
6
+ } from "../types.ts";
7
+ import Modal from "./Modal.tsx";
8
+
9
+ interface CreateInvoiceModalProps {
10
+ projectName: string;
11
+ projectColor: string;
12
+ hourlyRate: number | null;
13
+ customer: Customer | null;
14
+ entries: TimeEntryWithProject[];
15
+ timezone: string;
16
+ hasStripeKey: boolean;
17
+ onConfirm: () => void;
18
+ onCancel: () => void;
19
+ }
20
+
21
+ function formatDuration(ms: number): string {
22
+ const totalSeconds = Math.floor(ms / 1000);
23
+ const hours = Math.floor(totalSeconds / 3600);
24
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
25
+
26
+ if (hours > 0) {
27
+ return `${hours}h ${minutes}m`;
28
+ }
29
+ return `${minutes}m`;
30
+ }
31
+
32
+ function formatHours(ms: number): string {
33
+ const hours = ms / 3600000;
34
+ return hours.toFixed(2);
35
+ }
36
+
37
+ export function CreateInvoiceModal({
38
+ projectName,
39
+ projectColor,
40
+ hourlyRate,
41
+ customer,
42
+ entries,
43
+ timezone,
44
+ hasStripeKey,
45
+ onConfirm,
46
+ onCancel,
47
+ }: CreateInvoiceModalProps) {
48
+ useKeyboard((key) => {
49
+ if (key.name === "escape") {
50
+ onCancel();
51
+ return;
52
+ }
53
+ if (key.name === "return") {
54
+ if (!customer || !hasStripeKey) {
55
+ return;
56
+ }
57
+ onConfirm();
58
+ return;
59
+ }
60
+ });
61
+
62
+ // Calculate totals
63
+ const totalMs = entries.reduce((sum, e) => {
64
+ if (!e.endTime) return sum;
65
+ return (
66
+ sum + (new Date(e.endTime).getTime() - new Date(e.startTime).getTime())
67
+ );
68
+ }, 0);
69
+
70
+ const totalHours = totalMs / 3600000;
71
+ const totalAmount = hourlyRate ? totalHours * hourlyRate : 0;
72
+
73
+ // Prepare line items
74
+ const lineItems = entries
75
+ .filter((e) => e.endTime)
76
+ .map((e) => {
77
+ const durationMs =
78
+ new Date(e.endTime!).getTime() - new Date(e.startTime).getTime();
79
+ const hours = durationMs / 3600000;
80
+ const amount = hourlyRate ? hours * hourlyRate : 0;
81
+ return {
82
+ date: formatDateInTimezone(e.startTime, timezone),
83
+ description: e.description || "Work",
84
+ duration: formatDuration(durationMs),
85
+ hours: formatHours(durationMs),
86
+ amount,
87
+ };
88
+ });
89
+
90
+ // Calculate modal height based on content
91
+ const baseHeight = 14;
92
+ const lineItemsHeight = Math.min(lineItems.length, 8); // Cap at 8 visible items
93
+ const modalHeight = !customer
94
+ ? 12
95
+ : !hasStripeKey
96
+ ? 14
97
+ : baseHeight + lineItemsHeight;
98
+
99
+ return (
100
+ <Modal title="Create Stripe Invoice" height={Math.max(modalHeight, 20)}>
101
+ {/* Customer info */}
102
+ {customer && (
103
+ <box style={{ flexDirection: "row", marginTop: 1, gap: 1 }}>
104
+ <text fg="#8b5cf6" attributes="bold">
105
+ {customer.name}
106
+ </text>
107
+ <text fg="#64748b">{customer.email}</text>
108
+ </box>
109
+ )}
110
+
111
+ {!customer && (
112
+ <box style={{ marginTop: 1 }}>
113
+ <text fg="#ef4444">
114
+ This project has no customer linked. Please link a customer first
115
+ using the 'c' key in the Projects view.
116
+ </text>
117
+ </box>
118
+ )}
119
+
120
+ {!hasStripeKey && customer && (
121
+ <box style={{ marginTop: 1 }}>
122
+ <text fg="#ef4444">
123
+ No Stripe API key configured. Add one in Settings to create
124
+ invoices.
125
+ </text>
126
+ </box>
127
+ )}
128
+
129
+ {customer && hasStripeKey && (
130
+ <>
131
+ {/* Line items header */}
132
+ <text fg="#334155" style={{ marginTop: 1 }}>
133
+ {"─".repeat(56)}
134
+ </text>
135
+ <box style={{ flexDirection: "row", gap: 1 }}>
136
+ <box style={{ width: 8 }}>
137
+ <text fg="#94a3b8">Date</text>
138
+ </box>
139
+ <box style={{ flexGrow: 1 }}>
140
+ <text fg="#94a3b8">Description</text>
141
+ </box>
142
+ <box style={{ width: 8 }}>
143
+ <text fg="#94a3b8">Hours</text>
144
+ </box>
145
+ {hourlyRate != null && (
146
+ <box style={{ width: 10, alignItems: "flex-end" }}>
147
+ <text fg="#94a3b8">Amount</text>
148
+ </box>
149
+ )}
150
+ </box>
151
+
152
+ {/* Line items */}
153
+ <scrollbox style={{ maxHeight: 8 }}>
154
+ {lineItems.map((item, idx) => (
155
+ <box key={idx} style={{ flexDirection: "row", gap: 1 }}>
156
+ <box style={{ width: 8 }}>
157
+ <text fg="#64748b">{item.date}</text>
158
+ </box>
159
+ <box style={{ flexGrow: 1 }}>
160
+ <text fg="#e2e8f0">
161
+ {item.description.length > 30
162
+ ? `${item.description.slice(0, 27)}...`
163
+ : item.description}
164
+ </text>
165
+ </box>
166
+ <box style={{ width: 8 }}>
167
+ <text fg="#94a3b8">{item.hours}</text>
168
+ </box>
169
+ {hourlyRate != null && (
170
+ <box style={{ width: 10, alignItems: "flex-end" }}>
171
+ <text fg="#10b981">${item.amount.toFixed(2)}</text>
172
+ </box>
173
+ )}
174
+ </box>
175
+ ))}
176
+ </scrollbox>
177
+
178
+ {/* Totals */}
179
+ <text fg="#334155">{"─".repeat(56)}</text>
180
+ <box style={{ flexDirection: "row", gap: 1 }}>
181
+ <box style={{ width: 8 }}>
182
+ <text fg="#ffffff" attributes="bold">
183
+ Total
184
+ </text>
185
+ </box>
186
+ <box style={{ flexGrow: 1 }}>
187
+ <text fg="#94a3b8">
188
+ {entries.length} {entries.length === 1 ? "entry" : "entries"}
189
+ </text>
190
+ </box>
191
+ <box style={{ width: 8 }}>
192
+ <text fg="#ffffff" attributes="bold">
193
+ {totalHours.toFixed(2)}
194
+ </text>
195
+ </box>
196
+ {hourlyRate != null && (
197
+ <box style={{ width: 10, alignItems: "flex-end" }}>
198
+ <text fg="#10b981" attributes="bold">
199
+ ${totalAmount.toFixed(2)}
200
+ </text>
201
+ </box>
202
+ )}
203
+ </box>
204
+
205
+ {hourlyRate != null && (
206
+ <box style={{ marginTop: 1 }}>
207
+ <text fg="#64748b">Rate: ${hourlyRate.toFixed(2)}/hr</text>
208
+ </box>
209
+ )}
210
+ </>
211
+ )}
212
+
213
+ <box style={{ flexGrow: 1 }} />
214
+
215
+ <text fg="#64748b">
216
+ {customer && hasStripeKey
217
+ ? "Enter to create draft invoice, Esc to cancel"
218
+ : "Esc to close"}
219
+ </text>
220
+ </Modal>
221
+ );
222
+ }
@@ -0,0 +1,158 @@
1
+ import { useState } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { COLORS } from "../types";
4
+ import Modal from "./Modal";
5
+ import { useMultiPaste } from "../hooks/usePaste";
6
+
7
+ interface CustomerModalProps {
8
+ mode: "create" | "edit";
9
+ initialName?: string;
10
+ initialEmail?: string;
11
+ initialStripeId?: string;
12
+ onSubmit: (name: string, email: string, stripeCustomerId?: string) => void;
13
+ onCancel: () => void;
14
+ }
15
+
16
+ type Field = "name" | "email" | "stripeId";
17
+
18
+ export function CustomerModal({
19
+ mode,
20
+ initialName = "",
21
+ initialEmail = "",
22
+ initialStripeId = "",
23
+ onSubmit,
24
+ onCancel,
25
+ }: CustomerModalProps) {
26
+ const [name, setName] = useState(initialName);
27
+ const [email, setEmail] = useState(initialEmail);
28
+ const [stripeId, setStripeId] = useState(initialStripeId);
29
+ const [activeField, setActiveField] = useState<Field>("name");
30
+ const { registerInput } = useMultiPaste();
31
+
32
+ const fields: Field[] = mode === "edit" ? ["name", "email", "stripeId"] : ["name", "email"];
33
+
34
+ useKeyboard((key) => {
35
+ if (key.name === "escape") {
36
+ onCancel();
37
+ return;
38
+ }
39
+ if (key.name === "tab" || key.name === "down") {
40
+ setActiveField((f) => {
41
+ const idx = fields.indexOf(f);
42
+ return fields[(idx + 1) % fields.length] as Field;
43
+ });
44
+ return;
45
+ }
46
+ if (key.name === "up") {
47
+ setActiveField((f) => {
48
+ const idx = fields.indexOf(f);
49
+ return fields[(idx - 1 + fields.length) % fields.length] as Field;
50
+ });
51
+ return;
52
+ }
53
+ });
54
+
55
+ const handleSubmit = () => {
56
+ if (!name.trim() || !email.trim()) return;
57
+ onSubmit(name.trim(), email.trim(), stripeId.trim() || undefined);
58
+ };
59
+
60
+ return (
61
+ <Modal
62
+ title={mode === "create" ? "Create New Customer" : "Edit Customer"}
63
+ height={mode === "edit" ? 18 : 14}
64
+ >
65
+ <box
66
+ onClick={() => setActiveField("name")}
67
+ style={{
68
+ flexDirection: "column",
69
+ marginTop: 1,
70
+ }}
71
+ >
72
+ <box>
73
+ <text fg="#94a3b8">Name</text>
74
+ </box>
75
+ <box
76
+ style={{
77
+ border: true,
78
+ borderColor:
79
+ activeField === "name" ? COLORS.border : COLORS.borderOff,
80
+ height: 3,
81
+ width: "100%",
82
+ }}
83
+ >
84
+ <input
85
+ ref={registerInput("name")}
86
+ placeholder="Customer name..."
87
+ focused={activeField === "name"}
88
+ onInput={setName}
89
+ onSubmit={handleSubmit}
90
+ value={name}
91
+ />
92
+ </box>
93
+ </box>
94
+
95
+ <box
96
+ onClick={() => setActiveField("email")}
97
+ style={{
98
+ flexDirection: "column",
99
+ marginTop: 1,
100
+ }}
101
+ >
102
+ <box>
103
+ <text fg="#94a3b8">Email</text>
104
+ </box>
105
+ <box
106
+ style={{
107
+ border: true,
108
+ borderColor:
109
+ activeField === "email" ? COLORS.border : COLORS.borderOff,
110
+ height: 3,
111
+ width: "100%",
112
+ }}
113
+ >
114
+ <input
115
+ ref={registerInput("email")}
116
+ placeholder="customer@example.com"
117
+ focused={activeField === "email"}
118
+ onInput={setEmail}
119
+ onSubmit={handleSubmit}
120
+ value={email}
121
+ />
122
+ </box>
123
+ </box>
124
+
125
+ {mode === "edit" && (
126
+ <box
127
+ onClick={() => setActiveField("stripeId")}
128
+ style={{
129
+ flexDirection: "column",
130
+ marginTop: 1,
131
+ }}
132
+ >
133
+ <box>
134
+ <text fg="#94a3b8">Stripe Customer ID</text>
135
+ </box>
136
+ <box
137
+ style={{
138
+ border: true,
139
+ borderColor:
140
+ activeField === "stripeId" ? COLORS.border : COLORS.borderOff,
141
+ height: 3,
142
+ width: "100%",
143
+ }}
144
+ >
145
+ <input
146
+ ref={registerInput("stripeId")}
147
+ placeholder="cus_... (optional)"
148
+ focused={activeField === "stripeId"}
149
+ onInput={setStripeId}
150
+ onSubmit={handleSubmit}
151
+ value={stripeId}
152
+ />
153
+ </box>
154
+ </box>
155
+ )}
156
+ </Modal>
157
+ );
158
+ }
@@ -0,0 +1,142 @@
1
+ import { useState } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { COLORS, type Customer } from "../types.ts";
4
+
5
+ interface CustomerSelectModalProps {
6
+ customers: Customer[];
7
+ currentCustomerId?: string | null;
8
+ projectName: string;
9
+ onSelect: (customerId: string | null) => void;
10
+ onCreateNew: () => void;
11
+ onEdit: (customer: Customer) => void;
12
+ onCancel: () => void;
13
+ }
14
+
15
+ export function CustomerSelectModal({
16
+ customers,
17
+ currentCustomerId,
18
+ projectName,
19
+ onSelect,
20
+ onCreateNew,
21
+ onEdit,
22
+ onCancel,
23
+ }: CustomerSelectModalProps) {
24
+ // Create items list: customers + "None" option + "Create new" option
25
+ const items: { id: string | null; label: string; isAction: boolean; customer?: Customer }[] = [
26
+ { id: null, label: "(No customer)", isAction: false },
27
+ ...customers.map((c) => ({
28
+ id: c.id,
29
+ label: `${c.name} <${c.email}>${c.stripeCustomerId ? " [Stripe]" : ""}`,
30
+ isAction: false,
31
+ customer: c,
32
+ })),
33
+ { id: "__create__", label: "+ Create new customer", isAction: true },
34
+ ];
35
+
36
+ const currentIndex = currentCustomerId
37
+ ? items.findIndex((i) => i.id === currentCustomerId)
38
+ : 0;
39
+
40
+ const [selectedIndex, setSelectedIndex] = useState(currentIndex >= 0 ? currentIndex : 0);
41
+
42
+ useKeyboard((key) => {
43
+ if (key.name === "escape") {
44
+ onCancel();
45
+ return;
46
+ }
47
+ if (key.name === "return" && items[selectedIndex]) {
48
+ const item = items[selectedIndex];
49
+ if (item.id === "__create__") {
50
+ onCreateNew();
51
+ } else {
52
+ onSelect(item.id);
53
+ }
54
+ return;
55
+ }
56
+ // Edit customer
57
+ if (key.name === "e" && items[selectedIndex]) {
58
+ const item = items[selectedIndex];
59
+ if (item.customer) {
60
+ onEdit(item.customer);
61
+ }
62
+ return;
63
+ }
64
+ // Create new customer shortcut
65
+ if (key.name === "n") {
66
+ onCreateNew();
67
+ return;
68
+ }
69
+ if (key.name === "j" || key.name === "down") {
70
+ setSelectedIndex((i) => Math.min(i + 1, items.length - 1));
71
+ return;
72
+ }
73
+ if (key.name === "k" || key.name === "up") {
74
+ setSelectedIndex((i) => Math.max(i - 1, 0));
75
+ return;
76
+ }
77
+ });
78
+
79
+ const modalHeight = Math.min(items.length + 6, 18);
80
+
81
+ return (
82
+ <box
83
+ style={{
84
+ position: "absolute",
85
+ top: "50%",
86
+ left: "50%",
87
+ width: 60,
88
+ height: modalHeight,
89
+ marginTop: -Math.floor(modalHeight / 2),
90
+ marginLeft: -30,
91
+ border: true,
92
+ borderColor: "#8b5cf6",
93
+ flexDirection: "column",
94
+ backgroundColor: COLORS.bg,
95
+ padding: 1,
96
+ zIndex: 99999,
97
+ }}
98
+ >
99
+ <text fg="#ffffff" attributes="bold">
100
+ Link Customer to Project
101
+ </text>
102
+ <text fg="#94a3b8">{projectName}</text>
103
+ <box style={{ marginTop: 1, flexGrow: 1 }}>
104
+ <scrollbox focused style={{ flexGrow: 1 }}>
105
+ {items.map((item, index) => (
106
+ <box
107
+ key={item.id ?? "none"}
108
+ style={{
109
+ paddingLeft: 1,
110
+ paddingRight: 1,
111
+ backgroundColor:
112
+ index === selectedIndex ? "#1e40af" : "transparent",
113
+ }}
114
+ >
115
+ <text>
116
+ {item.id === currentCustomerId && (
117
+ <span fg="#10b981">[*] </span>
118
+ )}
119
+ {item.id !== currentCustomerId && item.id !== "__create__" && (
120
+ <span fg="#64748b">[ ] </span>
121
+ )}
122
+ <span
123
+ fg={
124
+ item.isAction
125
+ ? "#10b981"
126
+ : index === selectedIndex
127
+ ? "#ffffff"
128
+ : "#e2e8f0"
129
+ }
130
+ attributes={index === selectedIndex ? "bold" : undefined}
131
+ >
132
+ {item.label}
133
+ </span>
134
+ </text>
135
+ </box>
136
+ ))}
137
+ </scrollbox>
138
+ </box>
139
+ <text fg="#64748b">Enter: select | n: new | e: edit | Esc: cancel</text>
140
+ </box>
141
+ );
142
+ }