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