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,25 @@
1
+ const PACA_TEXT = `
2
+ ██████╗ █████╗ ██████╗ █████╗
3
+ ██╔══██╗██╔══██╗██╔════╝██╔══██╗
4
+ ██████╔╝███████║██║ ███████║
5
+ ██╔═══╝ ██╔══██║██║ ██╔══██║
6
+ ██║ ██║ ██║╚██████╗██║ ██║
7
+ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
8
+ `;
9
+
10
+ export function SplashScreen() {
11
+ return (
12
+ <box
13
+ style={{
14
+ width: "100%",
15
+ height: "100%",
16
+ flexDirection: "column",
17
+ justifyContent: "center",
18
+ alignItems: "center",
19
+ }}
20
+ >
21
+ <text>{PACA_TEXT}</text>
22
+ <text>Task Management for the Terminal</text>
23
+ </box>
24
+ );
25
+ }
@@ -0,0 +1,93 @@
1
+ interface StatusBarProps {
2
+ message?: string;
3
+ mode?: string;
4
+ timerRunning?: boolean;
5
+ currentView?: string;
6
+ activePanel?: string;
7
+ }
8
+
9
+ function getShortCuts(
10
+ currentView: string | undefined,
11
+ timerRunning: boolean | undefined,
12
+ activePanel: string | undefined,
13
+ ) {
14
+ const baseShortcuts = timerRunning
15
+ ? [{ key: "s", action: "Stop Timer", color: "#ef4444" }]
16
+ : [{ key: "t", action: "Timer", color: "#10b981" }];
17
+
18
+ if (currentView === "timesheets") {
19
+ return [
20
+ ...baseShortcuts,
21
+ { key: "⇅", action: "navigate" },
22
+ { key: "e", action: "edit" },
23
+ { key: "d", action: "delete" },
24
+ { key: "_", action: "select" },
25
+ { key: "i", action: "create invoice" },
26
+ ];
27
+ }
28
+
29
+ if (currentView === "invoices") {
30
+ return [
31
+ ...baseShortcuts,
32
+ { key: "⇅", action: "navigate" },
33
+ { key: "↵", action: "open" },
34
+ { key: "r", action: "refresh" },
35
+ { key: "[]", action: "page" },
36
+ ];
37
+ }
38
+
39
+ if (currentView === "settings") {
40
+ return [...baseShortcuts];
41
+ }
42
+
43
+ return [
44
+ ...baseShortcuts,
45
+ { key: "n", action: "New" },
46
+ { key: "e", action: "Edit" },
47
+ { key: "Tab", action: "Panel" },
48
+ { key: "q", action: "Quit" },
49
+ ];
50
+ }
51
+
52
+ export function StatusBar({
53
+ message,
54
+ mode,
55
+ timerRunning,
56
+ currentView,
57
+ activePanel,
58
+ }: StatusBarProps) {
59
+ const shortcuts = getShortCuts(currentView, timerRunning, activePanel);
60
+
61
+ return (
62
+ <box
63
+ style={{
64
+ width: "100%",
65
+ height: 1,
66
+ flexDirection: "row",
67
+ justifyContent: "space-between",
68
+ paddingLeft: 1,
69
+ paddingRight: 1,
70
+ }}
71
+ >
72
+ <box style={{ flexDirection: "row", gap: 2 }}>
73
+ {mode && (
74
+ <text>
75
+ <span fg="#000000" bg="#f59e0b">
76
+ {" "}
77
+ {mode.toUpperCase().replace(/_/g, " ")}{" "}
78
+ </span>
79
+ </text>
80
+ )}
81
+ {shortcuts.map((s) => (
82
+ <text key={s.key}>
83
+ <span fg={s.color || "#3b82f6"}>{s.key}</span>
84
+ <span fg="#64748b"> {s.action}</span>
85
+ </text>
86
+ ))}
87
+ </box>
88
+ <text fg={message?.includes("Error") ? "#ef4444" : "#10b981"}>
89
+ {message || "Ready"}
90
+ </text>
91
+ </box>
92
+ );
93
+ }
@@ -0,0 +1,143 @@
1
+ import type { Task } from "../types.ts";
2
+ import { STATUS_ICONS, type TaskStatus } from "../types.ts";
3
+ import { COLORS } from "../types.ts";
4
+
5
+ interface TaskListProps {
6
+ tasks: Task[];
7
+ selectedIndex: number;
8
+ focused: boolean;
9
+ projectName?: string;
10
+ }
11
+
12
+ export function TaskList({
13
+ tasks,
14
+ selectedIndex,
15
+ focused,
16
+ projectName,
17
+ }: TaskListProps) {
18
+ const formatDueDate = (date: Date | null) => {
19
+ if (!date) return null;
20
+ const d = new Date(date);
21
+ const now = new Date();
22
+ const diffDays = Math.ceil(
23
+ (d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
24
+ );
25
+
26
+ if (diffDays < 0)
27
+ return { text: `${Math.abs(diffDays)}d overdue`, color: "#ef4444" };
28
+ if (diffDays === 0) return { text: "Today", color: "#f59e0b" };
29
+ if (diffDays === 1) return { text: "Tomorrow", color: "#f59e0b" };
30
+ if (diffDays <= 7) return { text: `${diffDays}d`, color: "#3b82f6" };
31
+ return {
32
+ text: d.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
33
+ color: "#64748b",
34
+ };
35
+ };
36
+
37
+ const getPriorityIndicator = (priority: string) => {
38
+ const indicators: Record<string, { symbol: string; color: string }> = {
39
+ urgent: { symbol: "!", color: "#ef4444" },
40
+ high: { symbol: "!", color: "#f59e0b" },
41
+ medium: { symbol: "!", color: "#3b82f6" },
42
+ low: { symbol: " ", color: "#64748b" },
43
+ };
44
+ return indicators[priority] || indicators.medium;
45
+ };
46
+
47
+ return (
48
+ <box
49
+ title={projectName ? `Tasks - ${projectName}` : "Tasks"}
50
+ style={{
51
+ border: true,
52
+ borderColor: focused ? COLORS.border : COLORS.borderOff,
53
+ flexGrow: 2,
54
+ flexDirection: "column",
55
+ }}
56
+ >
57
+ {tasks.length === 0 ? (
58
+ <box
59
+ style={{
60
+ flexGrow: 1,
61
+ alignItems: "center",
62
+ justifyContent: "center",
63
+ }}
64
+ >
65
+ <text fg="#64748b">
66
+ {projectName ? "No tasks in this project" : "Select a project"}
67
+ </text>
68
+ {projectName && <text fg="#475569">Press 'n' to create a task</text>}
69
+ </box>
70
+ ) : (
71
+ <scrollbox focused={focused} style={{ flexGrow: 1 }}>
72
+ {tasks.map((task, index) => {
73
+ const isSelected = index === selectedIndex;
74
+ const statusIcon = STATUS_ICONS[task.status as TaskStatus] || "○";
75
+ const priority = getPriorityIndicator(task.priority);
76
+ const dueInfo = formatDueDate(task.dueDate);
77
+
78
+ return (
79
+ <box
80
+ key={task.id}
81
+ style={{
82
+ width: "100%",
83
+ paddingLeft: 1,
84
+ paddingRight: 1,
85
+ backgroundColor:
86
+ isSelected && focused
87
+ ? COLORS.selectedRowBg
88
+ : "transparent",
89
+ }}
90
+ >
91
+ <box
92
+ style={{
93
+ flexDirection: "row",
94
+ justifyContent: "space-between",
95
+ }}
96
+ >
97
+ <box style={{ width: 2 }}>
98
+ <text
99
+ fg={
100
+ task.status === "done"
101
+ ? "#10b981"
102
+ : task.status === "in_progress"
103
+ ? "#f59e0b"
104
+ : "#64748b"
105
+ }
106
+ >
107
+ {statusIcon}{" "}
108
+ </text>
109
+ </box>
110
+ <box style={{ width: 2 }}>
111
+ <text fg={priority.color}>{priority.symbol}</text>
112
+ </box>
113
+ <box style={{ flexGrow: 1 }}>
114
+ <text
115
+ fg={
116
+ task.status === "done"
117
+ ? "#64748b"
118
+ : isSelected
119
+ ? "#ffffff"
120
+ : "#e2e8f0"
121
+ }
122
+ attributes={
123
+ task.status === "done"
124
+ ? "strikethrough"
125
+ : isSelected
126
+ ? "bold"
127
+ : undefined
128
+ }
129
+ >
130
+ {task.title}
131
+ </text>
132
+ </box>
133
+
134
+ {dueInfo && <text fg={dueInfo.color}>{dueInfo.text}</text>}
135
+ </box>
136
+ </box>
137
+ );
138
+ })}
139
+ </scrollbox>
140
+ )}
141
+ </box>
142
+ );
143
+ }
@@ -0,0 +1,95 @@
1
+ import { useState, useEffect } from "react";
2
+ import type { RunningTimer } from "../types.ts";
3
+
4
+ interface TimerProps {
5
+ runningTimer: RunningTimer | null;
6
+ onStop: () => void;
7
+ }
8
+
9
+ function formatDuration(ms: number): string {
10
+ const seconds = Math.floor(ms / 1000);
11
+ const hours = Math.floor(seconds / 3600);
12
+ const minutes = Math.floor((seconds % 3600) / 60);
13
+ const secs = seconds % 60;
14
+
15
+ if (hours > 0) {
16
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
17
+ }
18
+ return `${minutes}:${secs.toString().padStart(2, "0")}`;
19
+ }
20
+
21
+ export function formatDurationHuman(ms: number): string {
22
+ const seconds = Math.floor(ms / 1000);
23
+ const hours = Math.floor(seconds / 3600);
24
+ const minutes = Math.floor((seconds % 3600) / 60);
25
+
26
+ if (hours > 0 && minutes > 0) {
27
+ return `${hours}h ${minutes}m`;
28
+ } else if (hours > 0) {
29
+ return `${hours}h`;
30
+ } else if (minutes > 0) {
31
+ return `${minutes}m`;
32
+ }
33
+ return "<1m";
34
+ }
35
+
36
+ export function Timer({ runningTimer, onStop }: TimerProps) {
37
+ const [elapsed, setElapsed] = useState(0);
38
+
39
+ useEffect(() => {
40
+ if (!runningTimer) {
41
+ setElapsed(0);
42
+ return;
43
+ }
44
+
45
+ const updateElapsed = () => {
46
+ const start = new Date(runningTimer.startTime).getTime();
47
+ setElapsed(Date.now() - start);
48
+ };
49
+
50
+ updateElapsed();
51
+ const interval = setInterval(updateElapsed, 1000);
52
+
53
+ return () => clearInterval(interval);
54
+ }, [runningTimer]);
55
+
56
+ if (!runningTimer) {
57
+ return (
58
+ <box style={{ flexDirection: "row", alignItems: "center", gap: 1 }}>
59
+ <text fg="#64748b">No timer running</text>
60
+ <text>
61
+ <span fg="#475569">[</span>
62
+ <span fg="#10b981">t</span>
63
+ <span fg="#475569">] Start</span>
64
+ </text>
65
+ </box>
66
+ );
67
+ }
68
+
69
+ const earnings =
70
+ runningTimer.project.hourlyRate != null
71
+ ? (elapsed / 3600000) * runningTimer.project.hourlyRate
72
+ : null;
73
+
74
+ return (
75
+ <box style={{ flexDirection: "row", alignItems: "center", gap: 1 }}>
76
+ <text>
77
+ <span fg="#ef4444">●</span>
78
+ </text>
79
+ <text fg={runningTimer.project.color} attributes="bold">
80
+ {runningTimer.project.name}
81
+ </text>
82
+ <text fg="#f59e0b" attributes="bold">
83
+ {formatDuration(elapsed)}
84
+ </text>
85
+ {earnings !== null && (
86
+ <text fg="#10b981">${earnings.toFixed(2)}</text>
87
+ )}
88
+ <text>
89
+ <span fg="#475569">[</span>
90
+ <span fg="#ef4444">s</span>
91
+ <span fg="#475569">] Stop</span>
92
+ </text>
93
+ </box>
94
+ );
95
+ }
@@ -0,0 +1,120 @@
1
+ import { useState } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { COLORS, type ProjectWithTaskCounts } from "../types.ts";
4
+ import { usePaste } from "../hooks/usePaste";
5
+ import Modal from "./Modal.tsx";
6
+
7
+ interface ProjectSelectModalProps {
8
+ projects: ProjectWithTaskCounts[];
9
+ onSelect: (projectId: string) => void;
10
+ onCancel: () => void;
11
+ }
12
+
13
+ export function ProjectSelectModal({
14
+ projects,
15
+ onSelect,
16
+ onCancel,
17
+ }: ProjectSelectModalProps) {
18
+ const [selectedIndex, setSelectedIndex] = useState(0);
19
+
20
+ useKeyboard((key) => {
21
+ if (key.name === "escape") {
22
+ onCancel();
23
+ return;
24
+ }
25
+ if (key.name === "return" && projects[selectedIndex]) {
26
+ onSelect(projects[selectedIndex].id);
27
+ return;
28
+ }
29
+ if (key.name === "j" || key.name === "down") {
30
+ setSelectedIndex((i) => Math.min(i + 1, projects.length - 1));
31
+ return;
32
+ }
33
+ if (key.name === "k" || key.name === "up") {
34
+ setSelectedIndex((i) => Math.max(i - 1, 0));
35
+ return;
36
+ }
37
+ });
38
+
39
+ return (
40
+ <Modal title="Start Timer" height={20}>
41
+ <box style={{ marginTop: 1, flexGrow: 1 }}>
42
+ <scrollbox focused style={{ flexGrow: 1 }}>
43
+ {projects.map((project, index) => (
44
+ <box
45
+ key={project.id}
46
+ style={{
47
+ paddingLeft: 1,
48
+ paddingRight: 1,
49
+ backgroundColor:
50
+ index === selectedIndex ? "#1e40af" : "transparent",
51
+ }}
52
+ >
53
+ <text>
54
+ <span fg={project.color}>[*] </span>
55
+ <span
56
+ fg={index === selectedIndex ? "#ffffff" : "#e2e8f0"}
57
+ attributes={index === selectedIndex ? "bold" : undefined}
58
+ >
59
+ {project.name}
60
+ </span>
61
+ {project.hourlyRate != null && (
62
+ <span fg="#10b981"> ${project.hourlyRate}/hr</span>
63
+ )}
64
+ </text>
65
+ </box>
66
+ ))}
67
+ </scrollbox>
68
+ </box>
69
+ <text fg="#64748b">Enter to select, Esc to cancel</text>
70
+ </Modal>
71
+ );
72
+ }
73
+
74
+ interface StopTimerModalProps {
75
+ projectName: string;
76
+ projectColor: string;
77
+ duration: string;
78
+ onSubmit: (description: string) => void;
79
+ onCancel: () => void;
80
+ }
81
+
82
+ export function StopTimerModal({
83
+ projectName,
84
+ projectColor,
85
+ duration,
86
+ onSubmit,
87
+ onCancel,
88
+ }: StopTimerModalProps) {
89
+ const [description, setDescription] = useState("");
90
+ const inputRef = usePaste();
91
+
92
+ return (
93
+ <Modal title="Stop Timer" height={20}>
94
+ <box style={{ flexDirection: "row", gap: 1, marginTop: 0 }}>
95
+ <text fg="#ff0000">{projectName}</text>
96
+
97
+ <text fg="#f59e0b">{duration}</text>
98
+ </box>
99
+ <text fg="#94a3b8" style={{ marginTop: 1 }}>
100
+ What did you work on?
101
+ </text>
102
+ <box
103
+ style={{
104
+ border: true,
105
+ borderColor: "#475569",
106
+ height: 3,
107
+ }}
108
+ >
109
+ <input
110
+ ref={inputRef}
111
+ placeholder="Description (optional)..."
112
+ focused
113
+ onInput={setDescription}
114
+ onSubmit={() => onSubmit(description)}
115
+ />
116
+ </box>
117
+ <text fg="#64748b">Enter to save, Esc to cancel</text>
118
+ </Modal>
119
+ );
120
+ }
@@ -0,0 +1,218 @@
1
+ import {
2
+ COLORS,
3
+ type TimesheetGroup,
4
+ formatDateInTimezone,
5
+ formatTimeInTimezone,
6
+ } from "../types.ts";
7
+
8
+ interface TimesheetViewProps {
9
+ groups: TimesheetGroup[];
10
+ selectedGroupIndex: number;
11
+ selectedEntryIndex: number;
12
+ selectedEntryIds: Set<string>;
13
+ focused: boolean;
14
+ timezone: string;
15
+ }
16
+
17
+ function formatDuration(ms: number): string {
18
+ const totalSeconds = Math.floor(ms / 1000);
19
+ const hours = Math.floor(totalSeconds / 3600);
20
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
21
+
22
+ if (hours > 0) {
23
+ return `${hours}h ${minutes}m`;
24
+ }
25
+ return `${minutes}m`;
26
+ }
27
+
28
+ function formatCurrency(amount: number): string {
29
+ return `$${amount.toFixed(2)}`;
30
+ }
31
+
32
+ // Column widths for table layout
33
+ const COL = {
34
+ checkbox: 4, // [x] + space
35
+ date: 7, // "Jan 17" + space
36
+ time: 22, // "9:00 AM - 5:00 PM" + space
37
+ duration: 9, // "10h 30m" + space
38
+ amount: 10, // "$1000.00" + space
39
+ };
40
+
41
+ export function TimesheetView({
42
+ groups,
43
+ selectedGroupIndex,
44
+ selectedEntryIndex,
45
+ selectedEntryIds,
46
+ focused,
47
+ timezone,
48
+ }: TimesheetViewProps) {
49
+ if (groups.length === 0) {
50
+ return (
51
+ <box
52
+ style={{
53
+ flexGrow: 1,
54
+ flexDirection: "column",
55
+ padding: 1,
56
+ }}
57
+ >
58
+ <box
59
+ style={{
60
+ flexDirection: "row",
61
+ justifyContent: "space-between",
62
+ }}
63
+ >
64
+ <text fg="#ffffff">Timesheets</text>
65
+ <text fg="#94a3b8">Uninvoiced Time</text>
66
+ </box>
67
+ <text fg={COLORS.borderOff}>{"─".repeat(56)}</text>
68
+ <box
69
+ style={{
70
+ flexGrow: 1,
71
+ justifyContent: "center",
72
+ alignItems: "center",
73
+ }}
74
+ >
75
+ <text fg="#64748b">No uninvoiced time entries</text>
76
+ </box>
77
+ </box>
78
+ );
79
+ }
80
+
81
+ return (
82
+ <box
83
+ style={{
84
+ flexGrow: 1,
85
+ flexDirection: "column",
86
+ padding: 1,
87
+ }}
88
+ >
89
+ <box
90
+ style={{
91
+ flexDirection: "row",
92
+ justifyContent: "space-between",
93
+ }}
94
+ >
95
+ <text fg="#ffffff">Timesheets</text>
96
+ <text fg="#94a3b8">
97
+ {selectedEntryIds.size > 0
98
+ ? `${selectedEntryIds.size} selected`
99
+ : "Uninvoiced Time"}
100
+ </text>
101
+ </box>
102
+ <text fg={COLORS.borderOff}>{"─".repeat(56)}</text>
103
+
104
+ <scrollbox focused={focused} style={{ flexGrow: 1 }}>
105
+ {groups.map((group, groupIndex) => {
106
+ const isSelectedGroup = groupIndex === selectedGroupIndex;
107
+
108
+ return (
109
+ <box key={group.project.id} style={{ flexDirection: "column" }}>
110
+ {/* Project Header */}
111
+ <box
112
+ style={{
113
+ flexDirection: "row",
114
+ justifyContent: "space-between",
115
+ backgroundColor: isSelectedGroup ? "#1e293b" : "transparent",
116
+ paddingLeft: 1,
117
+ paddingRight: 1,
118
+ marginTop: groupIndex > 0 ? 1 : 0,
119
+ }}
120
+ >
121
+ <text>
122
+ <span fg={group.project.color} attributes="bold">
123
+ {group.project.name}
124
+ </span>
125
+ {group.project.customer && (
126
+ <span fg="#8b5cf6"> ({group.project.customer.name})</span>
127
+ )}
128
+ {!group.project.customer && (
129
+ <span fg="#ef4444"> (No customer)</span>
130
+ )}
131
+ </text>
132
+ <text>
133
+ <span fg="#94a3b8">{formatDuration(group.totalMs)}</span>
134
+ {group.project.hourlyRate != null && (
135
+ <span fg="#10b981">
136
+ {" "}
137
+ {formatCurrency(group.totalAmount)}
138
+ </span>
139
+ )}
140
+ </text>
141
+ </box>
142
+
143
+ {/* Time Entries */}
144
+ {group.entries.map((entry, entryIndex) => {
145
+ const isSelected =
146
+ isSelectedGroup && entryIndex === selectedEntryIndex;
147
+ const isChecked = selectedEntryIds.has(entry.id);
148
+ const duration = entry.endTime
149
+ ? new Date(entry.endTime).getTime() -
150
+ new Date(entry.startTime).getTime()
151
+ : 0;
152
+ const rate = group.project.hourlyRate ?? 0;
153
+ const amount = (duration / 3600000) * rate;
154
+
155
+ const dateStr = formatDateInTimezone(entry.startTime, timezone);
156
+ const timeStart = formatTimeInTimezone(
157
+ entry.startTime,
158
+ timezone,
159
+ );
160
+ const timeEnd = entry.endTime
161
+ ? formatTimeInTimezone(entry.endTime, timezone)
162
+ : "";
163
+ const timeRange = timeEnd
164
+ ? `${timeStart} - ${timeEnd}`
165
+ : timeStart;
166
+ const durationStr = formatDuration(duration);
167
+ const amountStr = rate > 0 ? formatCurrency(amount) : "";
168
+
169
+ return (
170
+ <box
171
+ key={entry.id}
172
+ style={{
173
+ flexDirection: "row",
174
+ backgroundColor: isSelected
175
+ ? COLORS.selectedRowBg
176
+ : "transparent",
177
+ paddingLeft: 2,
178
+ paddingRight: 1,
179
+ }}
180
+ >
181
+ {/* Checkbox */}
182
+ <box style={{ width: COL.checkbox }}>
183
+ <text fg={isChecked ? "#10b981" : "#64748b"}>
184
+ {isChecked ? "[x]" : "[ ]"}
185
+ </text>
186
+ </box>
187
+ {/* Date */}
188
+ <box style={{ width: COL.date }}>
189
+ <text fg="#94a3b8">{dateStr}</text>
190
+ </box>
191
+ {/* Time Range */}
192
+ <box style={{ width: COL.time }}>
193
+ <text fg="#64748b">{timeRange}</text>
194
+ </box>
195
+ {/* Duration */}
196
+ <box style={{ width: COL.duration }}>
197
+ <text fg="#94a3b8">{durationStr}</text>
198
+ </box>
199
+ {/* Amount */}
200
+ <box style={{ width: COL.amount }}>
201
+ <text fg="#10b981">{amountStr}</text>
202
+ </box>
203
+ {/* Description */}
204
+ <box style={{ flexGrow: 1 }}>
205
+ <text fg={isSelected ? "#ffffff" : "#e2e8f0"}>
206
+ {entry.description || ""}
207
+ </text>
208
+ </box>
209
+ </box>
210
+ );
211
+ })}
212
+ </box>
213
+ );
214
+ })}
215
+ </scrollbox>
216
+ </box>
217
+ );
218
+ }