From 82b45fdfe46ced2def6cfa96f8b1ae9de9f091fd Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Tue, 20 Jan 2026 10:32:14 -0700 Subject: [PATCH] O_O --- src/db/schema.ts | 5 + src/pages/api/invoices/[id]/import-time.ts | 268 +++++++++++++++++++++ src/pages/api/organizations/update-name.ts | 4 + src/pages/api/tags/[id]/delete.ts | 54 +++++ src/pages/api/tags/[id]/update.ts | 77 ++++++ src/pages/api/tags/create.ts | 72 ++++++ src/pages/dashboard/invoices/[id].astro | 42 ++++ src/pages/dashboard/team/settings.astro | 197 ++++++++++++++- 8 files changed, 718 insertions(+), 1 deletion(-) create mode 100644 src/pages/api/invoices/[id]/import-time.ts create mode 100644 src/pages/api/tags/[id]/delete.ts create mode 100644 src/pages/api/tags/[id]/update.ts create mode 100644 src/pages/api/tags/create.ts diff --git a/src/db/schema.ts b/src/db/schema.ts index 3fb3a03..8d2c61a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -33,6 +33,8 @@ export const organizations = sqliteTable("organizations", { state: text("state"), zip: text("zip"), country: text("country"), + defaultTaxRate: real("default_tax_rate").default(0), + defaultCurrency: text("default_currency").default("USD"), createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( () => new Date(), ), @@ -132,6 +134,7 @@ export const timeEntries = sqliteTable( startTime: integer("start_time", { mode: "timestamp" }).notNull(), endTime: integer("end_time", { mode: "timestamp" }), description: text("description"), + invoiceId: text("invoice_id"), isManual: integer("is_manual", { mode: "boolean" }).default(false), createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( () => new Date(), @@ -160,6 +163,7 @@ export const timeEntries = sqliteTable( ), clientIdIdx: index("time_entries_client_id_idx").on(table.clientId), startTimeIdx: index("time_entries_start_time_idx").on(table.startTime), + invoiceIdIdx: index("time_entries_invoice_id_idx").on(table.invoiceId), }), ); @@ -172,6 +176,7 @@ export const tags = sqliteTable( organizationId: text("organization_id").notNull(), name: text("name").notNull(), color: text("color"), + rate: integer("rate").default(0), createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( () => new Date(), ), diff --git a/src/pages/api/invoices/[id]/import-time.ts b/src/pages/api/invoices/[id]/import-time.ts new file mode 100644 index 0000000..817c59f --- /dev/null +++ b/src/pages/api/invoices/[id]/import-time.ts @@ -0,0 +1,268 @@ +import type { APIRoute } from "astro"; +import { db } from "../../../../db"; +import { + invoices, + invoiceItems, + timeEntries, + members, + timeEntryTags, + tags, +} from "../../../../db/schema"; +import { + eq, + and, + gte, + lte, + isNull, + isNotNull, + inArray, + sql, + desc, +} from "drizzle-orm"; +import { nanoid } from "nanoid"; + +export const POST: APIRoute = async ({ request, params, locals, redirect }) => { + const user = locals.user; + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + const { id } = params; + if (!id) { + return new Response("Invoice ID is required", { status: 400 }); + } + + const formData = await request.formData(); + const startDateStr = formData.get("startDate") as string; + const endDateStr = formData.get("endDate") as string; + const groupByDay = formData.get("groupByDay") === "on"; + + if (!startDateStr || !endDateStr) { + return new Response("Start date and end date are required", { + status: 400, + }); + } + + const startDate = new Date(startDateStr); + const endDate = new Date(endDateStr); + // Set end date to end of day + endDate.setHours(23, 59, 59, 999); + + const invoice = await db.select().from(invoices).where(eq(invoices.id, id)).get(); + + if (!invoice) { + return new Response("Invoice not found", { status: 404 }); + } + + const membership = await db + .select() + .from(members) + .where( + and( + eq(members.userId, user.id), + eq(members.organizationId, invoice.organizationId) + ) + ) + .get(); + + if (!membership) { + return new Response("Not authorized", { status: 403 }); + } + + if (invoice.status !== "draft") { + return new Response("Can only import time into draft invoices", { status: 400 }); + } + + const entries = await db + .select({ + entry: timeEntries, + tag: tags, + }) + .from(timeEntries) + .leftJoin(timeEntryTags, eq(timeEntries.id, timeEntryTags.timeEntryId)) + .leftJoin(tags, eq(timeEntryTags.tagId, tags.id)) + .where( + and( + eq(timeEntries.organizationId, invoice.organizationId), + eq(timeEntries.clientId, invoice.clientId), + isNull(timeEntries.invoiceId), + isNotNull(timeEntries.endTime), + gte(timeEntries.startTime, startDate), + lte(timeEntries.startTime, endDate) + ) + ) + .orderBy(desc(timeEntries.startTime)); + + const processedEntries = new Map< + string, + { + entry: typeof timeEntries.$inferSelect; + rates: number[]; + tagNames: string[]; + } + >(); + + for (const { entry, tag } of entries) { + if (!processedEntries.has(entry.id)) { + processedEntries.set(entry.id, { + entry, + rates: [], + tagNames: [], + }); + } + const current = processedEntries.get(entry.id)!; + if (tag) { + if (tag.rate && tag.rate > 0) { + current.rates.push(tag.rate); + } + current.tagNames.push(tag.name); + } + } + + const newItems: { + id: string; + invoiceId: string; + description: string; + quantity: number; + unitPrice: number; + amount: number; + }[] = []; + + const entryIdsToUpdate: string[] = []; + + if (groupByDay) { + // Group by YYYY-MM-DD + const days = new Map< + string, + { + date: string; + totalDuration: number; // milliseconds + totalAmount: number; // cents + entries: string[]; // ids + } + >(); + + for (const { entry, rates } of processedEntries.values()) { + if (!entry.endTime) continue; + const dateKey = entry.startTime.toISOString().split("T")[0]; + const duration = entry.endTime.getTime() - entry.startTime.getTime(); + const hours = duration / (1000 * 60 * 60); + + // Determine rate: max of tags, or 0 + const rate = rates.length > 0 ? Math.max(...rates) : 0; + const amount = Math.round(hours * rate); + + if (!days.has(dateKey)) { + days.set(dateKey, { + date: dateKey, + totalDuration: 0, + totalAmount: 0, + entries: [], + }); + } + + const day = days.get(dateKey)!; + day.totalDuration += duration; + day.totalAmount += amount; + day.entries.push(entry.id); + entryIdsToUpdate.push(entry.id); + } + + for (const day of days.values()) { + const hours = day.totalDuration / (1000 * 60 * 60); + // Avoid division by zero + const unitPrice = hours > 0 ? Math.round(day.totalAmount / hours) : 0; + + newItems.push({ + id: nanoid(), + invoiceId: invoice.id, + description: `Time entries for ${day.date} (${day.entries.length} entries)`, + quantity: parseFloat(hours.toFixed(2)), + unitPrice, + amount: day.totalAmount, + }); + } + } else { + // Individual items + for (const { entry, rates, tagNames } of processedEntries.values()) { + if (!entry.endTime) continue; + const duration = entry.endTime.getTime() - entry.startTime.getTime(); + const hours = duration / (1000 * 60 * 60); + + // Determine rate: max of tags, or 0 + const rate = rates.length > 0 ? Math.max(...rates) : 0; + const amount = Math.round(hours * rate); + + let description = entry.description || "Time Entry"; + const dateStr = entry.startTime.toLocaleDateString(); + description = `[${dateStr}] ${description}`; + + if (tagNames.length > 0) { + description += ` (${tagNames.join(", ")})`; + } + + newItems.push({ + id: nanoid(), + invoiceId: invoice.id, + description, + quantity: parseFloat(hours.toFixed(2)), + unitPrice: rate, + amount, + }); + + entryIdsToUpdate.push(entry.id); + } + } + + if (newItems.length === 0) { + return redirect(`/dashboard/invoices/${id}?error=no-entries`); + } + + // Transaction-like operations + try { + await db.insert(invoiceItems).values(newItems); + + if (entryIdsToUpdate.length > 0) { + await db + .update(timeEntries) + .set({ invoiceId: invoice.id }) + .where(inArray(timeEntries.id, entryIdsToUpdate)); + } + + const allItems = await db + .select() + .from(invoiceItems) + .where(eq(invoiceItems.invoiceId, invoice.id)); + + const subtotal = allItems.reduce((sum, item) => sum + item.amount, 0); + + let discountAmount = 0; + if (invoice.discountType === "percentage") { + discountAmount = Math.round(subtotal * ((invoice.discountValue || 0) / 100)); + } else { + discountAmount = Math.round((invoice.discountValue || 0) * 100); + if (invoice.discountValue && invoice.discountValue > 0) { + discountAmount = Math.round((invoice.discountValue || 0) * 100); + } + } + + const taxableAmount = Math.max(0, subtotal - discountAmount); + const taxAmount = Math.round(taxableAmount * ((invoice.taxRate || 0) / 100)); + const total = subtotal - discountAmount + taxAmount; + + await db + .update(invoices) + .set({ + subtotal, + discountAmount, + taxAmount, + total, + }) + .where(eq(invoices.id, invoice.id)); + + return redirect(`/dashboard/invoices/${id}?success=imported`); + } catch (error) { + console.error("Error importing time entries:", error); + return new Response("Failed to import time entries", { status: 500 }); + } +}; diff --git a/src/pages/api/organizations/update-name.ts b/src/pages/api/organizations/update-name.ts index d98882a..139dba0 100644 --- a/src/pages/api/organizations/update-name.ts +++ b/src/pages/api/organizations/update-name.ts @@ -19,6 +19,8 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => { const state = formData.get("state") as string | null; const zip = formData.get("zip") as string | null; const country = formData.get("country") as string | null; + const defaultTaxRate = formData.get("defaultTaxRate") as string | null; + const defaultCurrency = formData.get("defaultCurrency") as string | null; const logo = formData.get("logo") as File | null; if (!organizationId || !name || name.trim().length === 0) { @@ -96,6 +98,8 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => { state: state?.trim() || null, zip: zip?.trim() || null, country: country?.trim() || null, + defaultTaxRate: defaultTaxRate ? parseFloat(defaultTaxRate) : 0, + defaultCurrency: defaultCurrency || "USD", }; if (logoUrl) { diff --git a/src/pages/api/tags/[id]/delete.ts b/src/pages/api/tags/[id]/delete.ts new file mode 100644 index 0000000..d0e8546 --- /dev/null +++ b/src/pages/api/tags/[id]/delete.ts @@ -0,0 +1,54 @@ +import type { APIRoute } from "astro"; +import { db } from "../../../../db"; +import { tags, members, timeEntryTags } from "../../../../db/schema"; +import { eq, and } from "drizzle-orm"; + +export const POST: APIRoute = async ({ params, locals, redirect }) => { + const user = locals.user; + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + const { id } = params; + if (!id) { + return new Response("Tag ID is required", { status: 400 }); + } + + // Get the tag to check organization + const tag = await db.select().from(tags).where(eq(tags.id, id)).get(); + + if (!tag) { + return new Response("Tag not found", { status: 404 }); + } + + // Verify membership and permissions + const membership = await db + .select() + .from(members) + .where( + and( + eq(members.userId, user.id), + eq(members.organizationId, tag.organizationId), + ), + ) + .get(); + + if (!membership) { + return new Response("Not authorized", { status: 403 }); + } + + const isAdmin = membership.role === "owner" || membership.role === "admin"; + if (!isAdmin) { + return new Response("Only owners and admins can manage tags", { + status: 403, + }); + } + + // Delete associations first + await db.delete(timeEntryTags).where(eq(timeEntryTags.tagId, id)); + + // Delete the tag + await db.delete(tags).where(eq(tags.id, id)); + + return redirect("/dashboard/team/settings?success=tags"); +}; diff --git a/src/pages/api/tags/[id]/update.ts b/src/pages/api/tags/[id]/update.ts new file mode 100644 index 0000000..350ab12 --- /dev/null +++ b/src/pages/api/tags/[id]/update.ts @@ -0,0 +1,77 @@ +import type { APIRoute } from "astro"; +import { db } from "../../../../db"; +import { tags, members } from "../../../../db/schema"; +import { eq, and } from "drizzle-orm"; + +export const POST: APIRoute = async ({ + request, + params, + locals, + redirect, +}) => { + const user = locals.user; + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + const { id } = params; + if (!id) { + return new Response("Tag ID is required", { status: 400 }); + } + + let name: string | undefined; + let color: string | undefined; + let rate: number | undefined; + + if (request.headers.get("Content-Type")?.includes("application/json")) { + const body = await request.json(); + name = body.name; + color = body.color; + rate = body.rate !== undefined ? parseInt(body.rate) : undefined; + } else { + const formData = await request.formData(); + name = formData.get("name")?.toString(); + color = formData.get("color")?.toString(); + const rateStr = formData.get("rate")?.toString(); + rate = rateStr ? parseInt(rateStr) : undefined; + } + + // Get the tag to check organization + const tag = await db.select().from(tags).where(eq(tags.id, id)).get(); + + if (!tag) { + return new Response("Tag not found", { status: 404 }); + } + + // Verify membership and permissions + const membership = await db + .select() + .from(members) + .where( + and( + eq(members.userId, user.id), + eq(members.organizationId, tag.organizationId), + ), + ) + .get(); + + if (!membership) { + return new Response("Not authorized", { status: 403 }); + } + + const isAdmin = membership.role === "owner" || membership.role === "admin"; + if (!isAdmin) { + return new Response("Only owners and admins can manage tags", { + status: 403, + }); + } + + const updateData: any = {}; + if (name) updateData.name = name; + if (color) updateData.color = color; + if (rate !== undefined) updateData.rate = rate; + + await db.update(tags).set(updateData).where(eq(tags.id, id)); + + return redirect("/dashboard/team/settings?success=tags"); +}; diff --git a/src/pages/api/tags/create.ts b/src/pages/api/tags/create.ts new file mode 100644 index 0000000..b068dff --- /dev/null +++ b/src/pages/api/tags/create.ts @@ -0,0 +1,72 @@ +import type { APIRoute } from "astro"; +import { db } from "../../../db"; +import { tags, members } from "../../../db/schema"; +import { eq, and } from "drizzle-orm"; +import { nanoid } from "nanoid"; + +export const POST: APIRoute = async ({ request, locals, redirect }) => { + const user = locals.user; + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + let name: string | undefined; + let color: string | undefined; + let rate: number | undefined; + let organizationId: string | undefined; + + if (request.headers.get("Content-Type")?.includes("application/json")) { + const body = await request.json(); + name = body.name; + color = body.color; + rate = body.rate ? parseInt(body.rate) : 0; + organizationId = body.organizationId; + } else { + const formData = await request.formData(); + name = formData.get("name")?.toString(); + color = formData.get("color")?.toString(); + const rateStr = formData.get("rate")?.toString(); + rate = rateStr ? parseInt(rateStr) : 0; + organizationId = formData.get("organizationId")?.toString(); + } + + if (!name || !organizationId) { + return new Response("Name and Organization ID are required", { + status: 400, + }); + } + + // Verify membership and permissions + const membership = await db + .select() + .from(members) + .where( + and( + eq(members.userId, user.id), + eq(members.organizationId, organizationId), + ), + ) + .get(); + + if (!membership) { + return new Response("Not authorized", { status: 403 }); + } + + const isAdmin = membership.role === "owner" || membership.role === "admin"; + if (!isAdmin) { + return new Response("Only owners and admins can manage tags", { + status: 403, + }); + } + + const id = nanoid(); + await db.insert(tags).values({ + id, + organizationId, + name, + color: color || null, + rate: rate || 0, + }); + + return redirect("/dashboard/team/settings?success=tags"); +}; diff --git a/src/pages/dashboard/invoices/[id].astro b/src/pages/dashboard/invoices/[id].astro index cfe3302..e678317 100644 --- a/src/pages/dashboard/invoices/[id].astro +++ b/src/pages/dashboard/invoices/[id].astro @@ -263,6 +263,13 @@ const isDraft = invoice.status === 'draft'; {isDraft && ( +
+ +
+

Add Item

@@ -374,4 +381,39 @@ const isDraft = invoice.status === 'draft'; + + + + + + diff --git a/src/pages/dashboard/team/settings.astro b/src/pages/dashboard/team/settings.astro index d969c7a..0eeca03 100644 --- a/src/pages/dashboard/team/settings.astro +++ b/src/pages/dashboard/team/settings.astro @@ -2,7 +2,7 @@ import DashboardLayout from '../../../layouts/DashboardLayout.astro'; import { Icon } from 'astro-icon/components'; import { db } from '../../../db'; -import { categories, members, organizations } from '../../../db/schema'; +import { categories, members, organizations, tags } from '../../../db/schema'; import { eq } from 'drizzle-orm'; const user = Astro.locals.user; @@ -40,6 +40,11 @@ const allCategories = await db.select() .where(eq(categories.organizationId, orgId)) .all(); +const allTags = await db.select() + .from(tags) + .where(eq(tags.organizationId, orgId)) + .all(); + const url = new URL(Astro.request.url); const successType = url.searchParams.get('success'); --- @@ -206,6 +211,43 @@ const successType = url.searchParams.get('success');
+
Defaults
+ +
+
+ + +
+ +
+ + +
+
+
Address information appears on invoices and quotes @@ -220,6 +262,159 @@ const successType = url.searchParams.get('success');
+ +
+
+
+

+ + Tags & Rates +

+ {/* We'll use a simple form submission for now or client-side JS for better UX later */} + +
+ +

+ Tags can be used to categorize time entries. You can also associate an hourly rate with a tag for billing purposes. +

+ + {allTags.length === 0 ? ( +
+ +
+
No tags yet
+
Create tags to add context and rates to your time entries.
+
+
+ ) : ( +
+ + + + + + + + + + {allTags.map(tag => ( + + + + + + ))} + +
NameRate / Hr
+
+ {tag.color && ( +
+ )} + {tag.name} +
+
+ {tag.rate ? ( + {new Intl.NumberFormat('en-US', { style: 'currency', currency: organization.defaultCurrency || 'USD' }).format(tag.rate / 100)} + ) : ( + No rate + )} + +
+ +
+ +
+
+ + {/* Edit Modal */} + + + + +
+
+ )} +
+
+ + + + + +