All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m2s
269 lines
7.0 KiB
TypeScript
269 lines
7.0 KiB
TypeScript
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 });
|
|
}
|
|
};
|