This commit is contained in:
268
src/pages/api/invoices/[id]/import-time.ts
Normal file
268
src/pages/api/invoices/[id]/import-time.ts
Normal file
@@ -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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user