This commit is contained in:
@@ -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(),
|
||||
),
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
54
src/pages/api/tags/[id]/delete.ts
Normal file
54
src/pages/api/tags/[id]/delete.ts
Normal file
@@ -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");
|
||||
};
|
||||
77
src/pages/api/tags/[id]/update.ts
Normal file
77
src/pages/api/tags/[id]/update.ts
Normal file
@@ -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");
|
||||
};
|
||||
72
src/pages/api/tags/create.ts
Normal file
72
src/pages/api/tags/create.ts
Normal file
@@ -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");
|
||||
};
|
||||
@@ -263,6 +263,13 @@ const isDraft = invoice.status === 'draft';
|
||||
|
||||
<!-- Add Item Form (Only if Draft) -->
|
||||
{isDraft && (
|
||||
<div class="flex justify-end mb-4">
|
||||
<button onclick="document.getElementById('import_time_modal').showModal()" class="btn btn-sm btn-outline gap-2">
|
||||
<Icon name="heroicons:clock" class="w-4 h-4" />
|
||||
Import Time
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/items/add`} class="bg-base-200/50 p-4 rounded-lg mb-8 border border-base-300/50">
|
||||
<h4 class="text-sm font-bold mb-3">Add Item</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-12 gap-4 items-end">
|
||||
@@ -374,4 +381,39 @@ const isDraft = invoice.status === 'draft';
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Import Time Modal -->
|
||||
<dialog id="import_time_modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Import Time Entries</h3>
|
||||
<p class="py-4">Import billable time entries for this client.</p>
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/import-time`}>
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="start-date">Start Date</label>
|
||||
<input type="date" id="start-date" name="startDate" class="input input-bordered" required />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="end-date">End Date</label>
|
||||
<input type="date" id="end-date" name="endDate" class="input input-bordered" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-6">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<input type="checkbox" name="groupByDay" class="checkbox" />
|
||||
<span class="label-text">Group entries by day</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="document.getElementById('import_time_modal').close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -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');
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider">Defaults</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="default-tax-rate">
|
||||
Default Tax Rate (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="default-tax-rate"
|
||||
name="defaultTaxRate"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
value={organization.defaultTaxRate || 0}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="default-currency">
|
||||
Default Currency
|
||||
</label>
|
||||
<select
|
||||
id="default-currency"
|
||||
name="defaultCurrency"
|
||||
class="select select-bordered w-full"
|
||||
>
|
||||
<option value="USD" selected={!organization.defaultCurrency || organization.defaultCurrency === 'USD'}>USD ($)</option>
|
||||
<option value="EUR" selected={organization.defaultCurrency === 'EUR'}>EUR (€)</option>
|
||||
<option value="GBP" selected={organization.defaultCurrency === 'GBP'}>GBP (£)</option>
|
||||
<option value="CAD" selected={organization.defaultCurrency === 'CAD'}>CAD ($)</option>
|
||||
<option value="AUD" selected={organization.defaultCurrency === 'AUD'}>AUD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-4 mt-6">
|
||||
<span class="text-xs text-base-content/60 text-center sm:text-left">
|
||||
Address information appears on invoices and quotes
|
||||
@@ -220,6 +262,159 @@ const successType = url.searchParams.get('success');
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags Section -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:tag" class="w-6 h-6" />
|
||||
Tags & Rates
|
||||
</h2>
|
||||
{/* We'll use a simple form submission for now or client-side JS for better UX later */}
|
||||
<button onclick="document.getElementById('new_tag_modal').showModal()" class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||
Add Tag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-base-content/70 mb-4">
|
||||
Tags can be used to categorize time entries. You can also associate an hourly rate with a tag for billing purposes.
|
||||
</p>
|
||||
|
||||
{allTags.length === 0 ? (
|
||||
<div class="alert alert-info">
|
||||
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
||||
<div>
|
||||
<div class="font-bold">No tags yet</div>
|
||||
<div class="text-sm">Create tags to add context and rates to your time entries.</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Rate / Hr</th>
|
||||
<th class="w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allTags.map(tag => (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
{tag.color && (
|
||||
<div class="w-3 h-3 rounded-full" style={`background-color: ${tag.color}`}></div>
|
||||
)}
|
||||
<span class="font-medium">{tag.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{tag.rate ? (
|
||||
<span class="font-mono">{new Intl.NumberFormat('en-US', { style: 'currency', currency: organization.defaultCurrency || 'USD' }).format(tag.rate / 100)}</span>
|
||||
) : (
|
||||
<span class="text-base-content/40 text-xs italic">No rate</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={`document.getElementById('edit_tag_modal_${tag.id}').showModal()`}
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<Icon name="heroicons:pencil" class="w-4 h-4" />
|
||||
</button>
|
||||
<form method="POST" action={`/api/tags/${tag.id}/delete`} onsubmit="return confirm('Are you sure you want to delete this tag?');">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error">
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<dialog id={`edit_tag_modal_${tag.id}`} class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Edit Tag</h3>
|
||||
<form method="POST" action={`/api/tags/${tag.id}/update`}>
|
||||
<div class="form-control w-full mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<input type="text" name="name" value={tag.name} class="input input-bordered w-full" required />
|
||||
</div>
|
||||
<div class="form-control w-full mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Color</span>
|
||||
</label>
|
||||
<input type="color" name="color" value={tag.color || '#3b82f6'} class="input input-bordered w-full h-12 p-1" />
|
||||
</div>
|
||||
<div class="form-control w-full mb-6">
|
||||
<label class="label">
|
||||
<span class="label-text">Hourly Rate (cents)</span>
|
||||
</label>
|
||||
<input type="number" name="rate" value={tag.rate || 0} min="0" class="input input-bordered w-full" />
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-base-content/60">Enter rate in cents (e.g. 5000 = $50.00)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick={`document.getElementById('edit_tag_modal_${tag.id}').close()`}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="new_tag_modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">New Tag</h3>
|
||||
<form method="POST" action="/api/tags/create">
|
||||
<input type="hidden" name="organizationId" value={organization.id} />
|
||||
<div class="form-control w-full mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<input type="text" name="name" class="input input-bordered w-full" required placeholder="e.g. Billable, Rush" />
|
||||
</div>
|
||||
<div class="form-control w-full mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Color</span>
|
||||
</label>
|
||||
<input type="color" name="color" value="#3b82f6" class="input input-bordered w-full h-12 p-1" />
|
||||
</div>
|
||||
<div class="form-control w-full mb-6">
|
||||
<label class="label">
|
||||
<span class="label-text">Hourly Rate (cents)</span>
|
||||
</label>
|
||||
<input type="number" name="rate" value="0" min="0" class="input input-bordered w-full" />
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-base-content/60">Enter rate in cents (e.g. 5000 = $50.00)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="document.getElementById('new_tag_modal').close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Tag</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Categories Section -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
|
||||
Reference in New Issue
Block a user