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/index.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createCliRenderer, type CliRenderer } from "@opentui/core";
|
|
3
|
+
import { createRoot } from "@opentui/react";
|
|
4
|
+
import { App } from "./App.tsx";
|
|
5
|
+
import { initDatabase, closeDatabase, cleanupOldCompletedTasks, DB_PATH } from "./db.ts";
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import { dirname } from "path";
|
|
9
|
+
|
|
10
|
+
let renderer: CliRenderer | null = null;
|
|
11
|
+
|
|
12
|
+
async function main() {
|
|
13
|
+
// Check if database exists, if not run migration
|
|
14
|
+
if (!existsSync(DB_PATH)) {
|
|
15
|
+
console.log("Initializing Paca database...");
|
|
16
|
+
try {
|
|
17
|
+
// Run prisma migration
|
|
18
|
+
const projectDir = dirname(dirname(import.meta.path));
|
|
19
|
+
execSync(`cd "${projectDir}" && bunx prisma migrate deploy`, {
|
|
20
|
+
stdio: "inherit",
|
|
21
|
+
env: {
|
|
22
|
+
...process.env,
|
|
23
|
+
DATABASE_URL: `file:${DB_PATH}`,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
console.log("Database initialized successfully!");
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error("Failed to initialize database:", error);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Initialize database connection
|
|
34
|
+
const connected = await initDatabase();
|
|
35
|
+
if (!connected) {
|
|
36
|
+
console.error("Failed to connect to database");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Clean up tasks that have been done for more than 3 days
|
|
41
|
+
await cleanupOldCompletedTasks(3);
|
|
42
|
+
|
|
43
|
+
// Handle cleanup on exit - must stop renderer to restore terminal state
|
|
44
|
+
const cleanup = async () => {
|
|
45
|
+
if (renderer) {
|
|
46
|
+
renderer.stop();
|
|
47
|
+
}
|
|
48
|
+
await closeDatabase();
|
|
49
|
+
// Give renderer time to finish, then reset terminal and exit
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
try {
|
|
52
|
+
require("child_process").spawnSync("reset", [], { stdio: "inherit" });
|
|
53
|
+
} catch {}
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}, 50);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
process.on("SIGINT", cleanup);
|
|
59
|
+
process.on("SIGTERM", cleanup);
|
|
60
|
+
|
|
61
|
+
// Create and start the TUI
|
|
62
|
+
try {
|
|
63
|
+
renderer = await createCliRenderer({
|
|
64
|
+
exitOnCtrlC: false,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
createRoot(renderer).render(<App />);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error("Failed to start Paca:", error);
|
|
70
|
+
await closeDatabase();
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
main();
|
package/src/stripe.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import Stripe from "stripe";
|
|
2
|
+
|
|
3
|
+
// Get or create Stripe customer, returns Stripe customer ID
|
|
4
|
+
export async function getOrCreateStripeCustomer(
|
|
5
|
+
apiKey: string,
|
|
6
|
+
customer: { name: string; email: string; stripeCustomerId: string | null },
|
|
7
|
+
): Promise<string> {
|
|
8
|
+
const stripe = new Stripe(apiKey);
|
|
9
|
+
|
|
10
|
+
// If we already have a Stripe customer ID, verify it exists
|
|
11
|
+
if (customer.stripeCustomerId) {
|
|
12
|
+
try {
|
|
13
|
+
await stripe.customers.retrieve(customer.stripeCustomerId);
|
|
14
|
+
return customer.stripeCustomerId;
|
|
15
|
+
} catch {
|
|
16
|
+
// Customer was deleted in Stripe, create new one
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Search by email first
|
|
21
|
+
const existing = await stripe.customers.list({ email: customer.email, limit: 1 });
|
|
22
|
+
if (existing.data[0]) {
|
|
23
|
+
return existing.data[0].id;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Create new customer
|
|
27
|
+
const newCustomer = await stripe.customers.create({
|
|
28
|
+
name: customer.name,
|
|
29
|
+
email: customer.email,
|
|
30
|
+
});
|
|
31
|
+
return newCustomer.id;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Line item for invoice creation
|
|
35
|
+
export interface InvoiceLineItem {
|
|
36
|
+
description: string;
|
|
37
|
+
hours: number;
|
|
38
|
+
rate: number;
|
|
39
|
+
startTime: Date;
|
|
40
|
+
endTime: Date;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Create draft invoice with line items
|
|
44
|
+
export async function createDraftInvoice(
|
|
45
|
+
apiKey: string,
|
|
46
|
+
stripeCustomerId: string,
|
|
47
|
+
projectName: string,
|
|
48
|
+
lineItems: InvoiceLineItem[],
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
const stripe = new Stripe(apiKey);
|
|
51
|
+
|
|
52
|
+
// Create pending invoice items first (they'll be collected into the invoice)
|
|
53
|
+
for (const item of lineItems) {
|
|
54
|
+
const amount = Math.round(item.hours * item.rate * 100); // Stripe uses cents
|
|
55
|
+
if (amount <= 0) continue; // Skip zero-amount items
|
|
56
|
+
|
|
57
|
+
// Format hours with 2 decimal places
|
|
58
|
+
const hoursFormatted = item.hours.toFixed(2);
|
|
59
|
+
|
|
60
|
+
// Format start and end times
|
|
61
|
+
const formatTime = (date: Date) => date.toLocaleString("en-US", {
|
|
62
|
+
month: "short",
|
|
63
|
+
day: "numeric",
|
|
64
|
+
hour: "numeric",
|
|
65
|
+
minute: "2-digit",
|
|
66
|
+
hour12: true,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const timeRange = `${formatTime(item.startTime)} - ${formatTime(item.endTime)}`;
|
|
70
|
+
|
|
71
|
+
// Format: "4.75 hour(s) :: Project Name :: Description"
|
|
72
|
+
const description = `${hoursFormatted} hour(s) :: ${projectName} :: ${item.description}`;
|
|
73
|
+
|
|
74
|
+
await stripe.invoiceItems.create({
|
|
75
|
+
customer: stripeCustomerId,
|
|
76
|
+
amount,
|
|
77
|
+
currency: "usd",
|
|
78
|
+
description,
|
|
79
|
+
metadata: {
|
|
80
|
+
project: projectName,
|
|
81
|
+
start_time: item.startTime.toISOString(),
|
|
82
|
+
end_time: item.endTime.toISOString(),
|
|
83
|
+
time_range: timeRange,
|
|
84
|
+
hours: hoursFormatted,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Create invoice - this will automatically include all pending invoice items
|
|
90
|
+
const invoice = await stripe.invoices.create({
|
|
91
|
+
customer: stripeCustomerId,
|
|
92
|
+
auto_advance: false, // Keep as draft
|
|
93
|
+
collection_method: "send_invoice",
|
|
94
|
+
days_until_due: 30,
|
|
95
|
+
pending_invoice_items_behavior: "include",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return invoice.id;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Invoice display type
|
|
102
|
+
export interface StripeInvoiceItem {
|
|
103
|
+
id: string;
|
|
104
|
+
number: string | null;
|
|
105
|
+
customerName: string | null;
|
|
106
|
+
customerEmail: string | null;
|
|
107
|
+
status: string;
|
|
108
|
+
amount: number;
|
|
109
|
+
currency: string;
|
|
110
|
+
created: Date;
|
|
111
|
+
dueDate: Date | null;
|
|
112
|
+
hostedUrl: string | null;
|
|
113
|
+
dashboardUrl: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ListInvoicesResult {
|
|
117
|
+
invoices: StripeInvoiceItem[];
|
|
118
|
+
hasMore: boolean;
|
|
119
|
+
nextCursor: string | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// List invoices from Stripe with pagination
|
|
123
|
+
export async function listInvoices(
|
|
124
|
+
apiKey: string,
|
|
125
|
+
limit = 25,
|
|
126
|
+
startingAfter?: string,
|
|
127
|
+
): Promise<ListInvoicesResult> {
|
|
128
|
+
const stripe = new Stripe(apiKey);
|
|
129
|
+
|
|
130
|
+
const params: Stripe.InvoiceListParams = {
|
|
131
|
+
limit,
|
|
132
|
+
expand: ["data.customer"],
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (startingAfter) {
|
|
136
|
+
params.starting_after = startingAfter;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const response = await stripe.invoices.list(params);
|
|
140
|
+
|
|
141
|
+
const invoices: StripeInvoiceItem[] = response.data.map((inv) => {
|
|
142
|
+
const customer = inv.customer as Stripe.Customer | null;
|
|
143
|
+
return {
|
|
144
|
+
id: inv.id,
|
|
145
|
+
number: inv.number,
|
|
146
|
+
customerName: customer?.name ?? null,
|
|
147
|
+
customerEmail: customer?.email ?? null,
|
|
148
|
+
status: inv.status ?? "unknown",
|
|
149
|
+
amount: inv.amount_due / 100, // Convert from cents
|
|
150
|
+
currency: inv.currency,
|
|
151
|
+
created: new Date(inv.created * 1000),
|
|
152
|
+
dueDate: inv.due_date ? new Date(inv.due_date * 1000) : null,
|
|
153
|
+
hostedUrl: inv.hosted_invoice_url ?? null,
|
|
154
|
+
dashboardUrl: `https://dashboard.stripe.com/invoices/${inv.id}`,
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
invoices,
|
|
160
|
+
hasMore: response.has_more,
|
|
161
|
+
nextCursor: response.data.length > 0 ? response.data[response.data.length - 1]?.id ?? null : null,
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Project,
|
|
3
|
+
Task,
|
|
4
|
+
Tag,
|
|
5
|
+
TimeEntry,
|
|
6
|
+
Customer,
|
|
7
|
+
Invoice,
|
|
8
|
+
} from "../generated/prisma/client.ts";
|
|
9
|
+
|
|
10
|
+
export type { Project, Task, Tag, TimeEntry, Customer, Invoice };
|
|
11
|
+
|
|
12
|
+
export type TaskStatus = "todo" | "in_progress" | "done";
|
|
13
|
+
export type TaskPriority = "low" | "medium" | "high" | "urgent";
|
|
14
|
+
|
|
15
|
+
export interface ProjectWithTasks extends Project {
|
|
16
|
+
tasks: Task[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProjectWithTaskCounts extends Project {
|
|
20
|
+
tasks: { id: string; status: string }[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TaskWithTags extends Task {
|
|
24
|
+
tags: { tag: Tag }[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TaskWithProject extends Task {
|
|
28
|
+
project: Project;
|
|
29
|
+
tags: { tag: Tag }[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type View =
|
|
33
|
+
| "dashboard"
|
|
34
|
+
| "projects"
|
|
35
|
+
| "tasks"
|
|
36
|
+
| "settings"
|
|
37
|
+
| "help"
|
|
38
|
+
| "timesheets"
|
|
39
|
+
| "invoices";
|
|
40
|
+
|
|
41
|
+
export type Panel = "projects" | "tasks" | "details";
|
|
42
|
+
|
|
43
|
+
export interface AppState {
|
|
44
|
+
currentView: View;
|
|
45
|
+
activePanel: Panel;
|
|
46
|
+
selectedProjectId: string | null;
|
|
47
|
+
selectedTaskId: string | null;
|
|
48
|
+
showArchived: boolean;
|
|
49
|
+
inputMode: InputMode | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type InputMode =
|
|
53
|
+
| "create_project"
|
|
54
|
+
| "edit_project"
|
|
55
|
+
| "create_task"
|
|
56
|
+
| "edit_task"
|
|
57
|
+
| "edit_dashboard_task"
|
|
58
|
+
| "confirm_delete"
|
|
59
|
+
| "stop_timer"
|
|
60
|
+
| "select_timer_project"
|
|
61
|
+
| "search"
|
|
62
|
+
| "edit_business_name"
|
|
63
|
+
| "edit_stripe_key"
|
|
64
|
+
| "edit_timezone"
|
|
65
|
+
| "confirm_import"
|
|
66
|
+
| "edit_time_entry"
|
|
67
|
+
| "create_invoice"
|
|
68
|
+
| "confirm_delete_time_entry"
|
|
69
|
+
| "create_customer"
|
|
70
|
+
| "edit_customer"
|
|
71
|
+
| "select_customer";
|
|
72
|
+
|
|
73
|
+
export interface RunningTimer {
|
|
74
|
+
id: string;
|
|
75
|
+
startTime: Date;
|
|
76
|
+
project: {
|
|
77
|
+
id: string;
|
|
78
|
+
name: string;
|
|
79
|
+
color: string;
|
|
80
|
+
hourlyRate: number | null;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface TimeEntryWithProject extends TimeEntry {
|
|
85
|
+
project: {
|
|
86
|
+
id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
color: string;
|
|
89
|
+
hourlyRate: number | null;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface TimeStats {
|
|
94
|
+
todayMs: number;
|
|
95
|
+
weekMs: number;
|
|
96
|
+
monthMs: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface AppSettings {
|
|
100
|
+
businessName: string;
|
|
101
|
+
stripeApiKey: string;
|
|
102
|
+
timezone: string; // IANA timezone (e.g., "America/New_York") or "auto" for system detection
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Get the system's detected timezone
|
|
106
|
+
export function getSystemTimezone(): string {
|
|
107
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Get the effective timezone (resolves "auto" to system timezone)
|
|
111
|
+
export function getEffectiveTimezone(settings: AppSettings): string {
|
|
112
|
+
if (!settings.timezone || settings.timezone === "auto") {
|
|
113
|
+
return getSystemTimezone();
|
|
114
|
+
}
|
|
115
|
+
return settings.timezone;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Format a date for display in the given timezone
|
|
119
|
+
export function formatDateInTimezone(
|
|
120
|
+
date: Date | string,
|
|
121
|
+
timezone: string,
|
|
122
|
+
): string {
|
|
123
|
+
if (!date) return "";
|
|
124
|
+
const dateObj = date instanceof Date ? date : new Date(date);
|
|
125
|
+
if (isNaN(dateObj.getTime())) return "";
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
return dateObj.toLocaleDateString("en-US", {
|
|
129
|
+
timeZone: timezone,
|
|
130
|
+
month: "short",
|
|
131
|
+
day: "numeric",
|
|
132
|
+
});
|
|
133
|
+
} catch {
|
|
134
|
+
return dateObj.toLocaleDateString("en-US", {
|
|
135
|
+
month: "short",
|
|
136
|
+
day: "numeric",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Format a time for display in the given timezone
|
|
142
|
+
export function formatTimeInTimezone(
|
|
143
|
+
date: Date | string,
|
|
144
|
+
timezone: string,
|
|
145
|
+
): string {
|
|
146
|
+
if (!date) return "";
|
|
147
|
+
const dateObj = date instanceof Date ? date : new Date(date);
|
|
148
|
+
if (isNaN(dateObj.getTime())) return "";
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
return dateObj.toLocaleTimeString("en-US", {
|
|
152
|
+
timeZone: timezone,
|
|
153
|
+
hour: "numeric",
|
|
154
|
+
minute: "2-digit",
|
|
155
|
+
hour12: true,
|
|
156
|
+
});
|
|
157
|
+
} catch {
|
|
158
|
+
return dateObj.toLocaleTimeString("en-US", {
|
|
159
|
+
hour: "numeric",
|
|
160
|
+
minute: "2-digit",
|
|
161
|
+
hour12: true,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Format a date-time for editing (YYYY-MM-DD HH:MM) in the given timezone
|
|
167
|
+
export function formatDateTimeForEdit(
|
|
168
|
+
date: Date | string,
|
|
169
|
+
timezone: string,
|
|
170
|
+
): string {
|
|
171
|
+
if (!date) return "";
|
|
172
|
+
|
|
173
|
+
const dateObj = date instanceof Date ? date : new Date(date);
|
|
174
|
+
if (isNaN(dateObj.getTime())) return "";
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
178
|
+
timeZone: timezone,
|
|
179
|
+
year: "numeric",
|
|
180
|
+
month: "2-digit",
|
|
181
|
+
day: "2-digit",
|
|
182
|
+
hour: "2-digit",
|
|
183
|
+
minute: "2-digit",
|
|
184
|
+
hour12: false,
|
|
185
|
+
});
|
|
186
|
+
const parts = formatter.formatToParts(dateObj);
|
|
187
|
+
const get = (type: string) =>
|
|
188
|
+
parts.find((p) => p.type === type)?.value || "";
|
|
189
|
+
return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}`;
|
|
190
|
+
} catch {
|
|
191
|
+
// Fallback to local time if timezone is invalid
|
|
192
|
+
const year = dateObj.getFullYear();
|
|
193
|
+
const month = String(dateObj.getMonth() + 1).padStart(2, "0");
|
|
194
|
+
const day = String(dateObj.getDate()).padStart(2, "0");
|
|
195
|
+
const hours = String(dateObj.getHours()).padStart(2, "0");
|
|
196
|
+
const minutes = String(dateObj.getMinutes()).padStart(2, "0");
|
|
197
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Parse a date-time string (YYYY-MM-DD HH:MM) in the given timezone
|
|
202
|
+
export function parseDateTimeInTimezone(
|
|
203
|
+
input: string,
|
|
204
|
+
timezone: string,
|
|
205
|
+
): Date | null {
|
|
206
|
+
const match = input.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})$/);
|
|
207
|
+
if (!match || !match[1] || !match[2] || !match[3] || !match[4] || !match[5]) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const [, year, month, day, hour, minute] = match;
|
|
212
|
+
const dateStr = `${year}-${month}-${day}T${hour.padStart(2, "0")}:${minute}:00`;
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
// Use Intl to get the offset for this date in the target timezone
|
|
216
|
+
const testDate = new Date(dateStr + "Z"); // Parse as UTC first
|
|
217
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
218
|
+
timeZone: timezone,
|
|
219
|
+
timeZoneName: "shortOffset",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Get parts to find the offset
|
|
223
|
+
const parts = formatter.formatToParts(testDate);
|
|
224
|
+
const offsetPart =
|
|
225
|
+
parts.find((p) => p.type === "timeZoneName")?.value || "";
|
|
226
|
+
|
|
227
|
+
// Parse offset like "GMT-5" or "GMT+5:30"
|
|
228
|
+
const offsetMatch = offsetPart.match(/GMT([+-]?)(\d+)(?::(\d+))?/);
|
|
229
|
+
let offsetMinutes = 0;
|
|
230
|
+
if (offsetMatch) {
|
|
231
|
+
const sign = offsetMatch[1] === "-" ? -1 : 1;
|
|
232
|
+
const hours = parseInt(offsetMatch[2] || "0", 10);
|
|
233
|
+
const minutes = parseInt(offsetMatch[3] || "0", 10);
|
|
234
|
+
offsetMinutes = sign * (hours * 60 + minutes);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Create the date by adjusting for the timezone offset
|
|
238
|
+
const utcDate = new Date(dateStr + "Z");
|
|
239
|
+
utcDate.setMinutes(utcDate.getMinutes() - offsetMinutes);
|
|
240
|
+
|
|
241
|
+
if (isNaN(utcDate.getTime())) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
return utcDate;
|
|
245
|
+
} catch {
|
|
246
|
+
// Fallback: parse as local time if timezone is invalid
|
|
247
|
+
const localDate = new Date(
|
|
248
|
+
parseInt(year),
|
|
249
|
+
parseInt(month) - 1,
|
|
250
|
+
parseInt(day),
|
|
251
|
+
parseInt(hour),
|
|
252
|
+
parseInt(minute),
|
|
253
|
+
);
|
|
254
|
+
if (isNaN(localDate.getTime())) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
return localDate;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface DashboardStats {
|
|
262
|
+
totalProjects: number;
|
|
263
|
+
activeProjects: number;
|
|
264
|
+
archivedProjects: number;
|
|
265
|
+
totalTasks: number;
|
|
266
|
+
todoTasks: number;
|
|
267
|
+
inProgressTasks: number;
|
|
268
|
+
doneTasks: number;
|
|
269
|
+
overdueTasks: number;
|
|
270
|
+
completionRate: number;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface TimesheetGroup {
|
|
274
|
+
project: {
|
|
275
|
+
id: string;
|
|
276
|
+
name: string;
|
|
277
|
+
color: string;
|
|
278
|
+
hourlyRate: number | null;
|
|
279
|
+
customer: Customer | null;
|
|
280
|
+
};
|
|
281
|
+
entries: TimeEntryWithProject[];
|
|
282
|
+
totalMs: number;
|
|
283
|
+
totalAmount: number;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface ProjectWithCustomer extends Project {
|
|
287
|
+
customer: Customer | null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface TimeEntryWithInvoice extends TimeEntry {
|
|
291
|
+
invoiceId: string | null;
|
|
292
|
+
project: {
|
|
293
|
+
id: string;
|
|
294
|
+
name: string;
|
|
295
|
+
color: string;
|
|
296
|
+
hourlyRate: number | null;
|
|
297
|
+
customer: Customer | null;
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export const STATUS_LABELS: Record<TaskStatus, string> = {
|
|
302
|
+
todo: "To Do",
|
|
303
|
+
in_progress: "In Progress",
|
|
304
|
+
done: "Done",
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
export const STATUS_ICONS: Record<TaskStatus, string> = {
|
|
308
|
+
todo: "○",
|
|
309
|
+
in_progress: "◐",
|
|
310
|
+
done: "●",
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
export const PRIORITY_LABELS: Record<TaskPriority, string> = {
|
|
314
|
+
low: "Low",
|
|
315
|
+
medium: "Medium",
|
|
316
|
+
high: "High",
|
|
317
|
+
urgent: "Urgent",
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
|
|
321
|
+
low: "gray",
|
|
322
|
+
medium: "blue",
|
|
323
|
+
high: "yellow",
|
|
324
|
+
urgent: "red",
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export const PROJECT_COLORS = [
|
|
328
|
+
"#3b82f6", // Blue
|
|
329
|
+
"#10b981", // Green
|
|
330
|
+
"#f59e0b", // Amber
|
|
331
|
+
"#ef4444", // Red
|
|
332
|
+
"#8b5cf6", // Purple
|
|
333
|
+
"#ec4899", // Pink
|
|
334
|
+
"#06b6d4", // Cyan
|
|
335
|
+
"#f97316", // Orange
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
export const COLORS = {
|
|
339
|
+
bg: "#171623",
|
|
340
|
+
border: "#777777",
|
|
341
|
+
borderOff: "#444444",
|
|
342
|
+
selectedRowBg: "#3325b4",
|
|
343
|
+
textPrimary: "#e0e0e0",
|
|
344
|
+
textSecondary: "#94a3b8",
|
|
345
|
+
accent: "#3b82f6",
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Stripe invoice data for display
|
|
349
|
+
export interface StripeInvoiceDisplay {
|
|
350
|
+
id: string;
|
|
351
|
+
number: string | null;
|
|
352
|
+
customerName: string | null;
|
|
353
|
+
customerEmail: string | null;
|
|
354
|
+
status: string;
|
|
355
|
+
amount: number;
|
|
356
|
+
currency: string;
|
|
357
|
+
created: Date;
|
|
358
|
+
dueDate: Date | null;
|
|
359
|
+
hostedUrl: string | null;
|
|
360
|
+
pdfUrl: string | null;
|
|
361
|
+
}
|