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
|
@@ -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
|
+
}
|