Archived
Add time clock feature with MCP access
- New beenvoice_time_entry DB table with migration (startedAt/endedAt, hours, rate, clientId) - tRPC router with clockIn, clockOut, getRunning, getAll, getSummary, create, update, delete - Dashboard page at /dashboard/time-clock with live elapsed timer, entry list, and manual entry form - 5 MCP tools: time_clock_in, time_clock_out, time_get_running, time_entries_list, time_entries_create - Sidebar navigation entry Timer state is stored in PostgreSQL (endedAt IS NULL = running), suitable for serverless/Coolify deployment.
This commit is contained in:
@@ -9,6 +9,7 @@ import { invoiceTemplatesRouter } from "~/server/api/routers/invoiceTemplates";
|
||||
import { paymentsRouter } from "~/server/api/routers/payments";
|
||||
import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices";
|
||||
import { apiKeysRouter } from "~/server/api/routers/apiKeys";
|
||||
import { timeEntriesRouter } from "~/server/api/routers/time-entries";
|
||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
@@ -23,6 +24,7 @@ export const appRouter = createTRPCRouter({
|
||||
payments: paymentsRouter,
|
||||
recurringInvoices: recurringInvoicesRouter,
|
||||
apiKeys: apiKeysRouter,
|
||||
timeEntries: timeEntriesRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { timeEntries, clients } from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const createSchema = z.object({
|
||||
description: z.string().max(500).default(""),
|
||||
clientId: z.string().optional().or(z.literal("")),
|
||||
startedAt: z.date(),
|
||||
endedAt: z.date().optional(),
|
||||
hours: z.number().min(0).optional(),
|
||||
rate: z.number().min(0).optional(),
|
||||
notes: z.string().max(500).optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
const updateSchema = createSchema.partial().extend({ id: z.string() });
|
||||
|
||||
function computeHours(startedAt: Date, endedAt: Date): number {
|
||||
const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
|
||||
}
|
||||
|
||||
export const timeEntriesRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
clientId: z.string().optional(),
|
||||
from: z.date().optional(),
|
||||
to: z.date().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const conditions = [eq(timeEntries.createdById, ctx.session.user.id)];
|
||||
if (input?.clientId) conditions.push(eq(timeEntries.clientId, input.clientId));
|
||||
if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from));
|
||||
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
|
||||
|
||||
return ctx.db.query.timeEntries.findMany({
|
||||
where: and(...conditions),
|
||||
with: { client: true },
|
||||
orderBy: [desc(timeEntries.startedAt)],
|
||||
});
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const entry = await ctx.db.query.timeEntries.findFirst({
|
||||
where: and(
|
||||
eq(timeEntries.id, input.id),
|
||||
eq(timeEntries.createdById, ctx.session.user.id),
|
||||
),
|
||||
with: { client: true },
|
||||
});
|
||||
if (!entry) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
|
||||
return entry;
|
||||
}),
|
||||
|
||||
getRunning: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.query.timeEntries.findFirst({
|
||||
where: and(
|
||||
eq(timeEntries.createdById, ctx.session.user.id),
|
||||
isNull(timeEntries.endedAt),
|
||||
),
|
||||
with: { client: true },
|
||||
});
|
||||
}),
|
||||
|
||||
clockIn: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
description: z.string().max(500).default(""),
|
||||
clientId: z.string().optional().or(z.literal("")),
|
||||
rate: z.number().min(0).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const running = await ctx.db.query.timeEntries.findFirst({
|
||||
where: and(
|
||||
eq(timeEntries.createdById, ctx.session.user.id),
|
||||
isNull(timeEntries.endedAt),
|
||||
),
|
||||
});
|
||||
if (running) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "A timer is already running. Stop it before clocking in.",
|
||||
});
|
||||
}
|
||||
|
||||
const clientId = input.clientId?.trim() || null;
|
||||
if (clientId) {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
||||
});
|
||||
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
||||
}
|
||||
|
||||
const [entry] = await ctx.db
|
||||
.insert(timeEntries)
|
||||
.values({
|
||||
description: input.description,
|
||||
clientId,
|
||||
startedAt: new Date(),
|
||||
rate: input.rate ?? null,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return entry;
|
||||
}),
|
||||
|
||||
clockOut: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
}).optional(),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const conditions = [
|
||||
eq(timeEntries.createdById, ctx.session.user.id),
|
||||
isNull(timeEntries.endedAt),
|
||||
];
|
||||
if (input?.id) conditions.push(eq(timeEntries.id, input.id));
|
||||
|
||||
const entry = await ctx.db.query.timeEntries.findFirst({
|
||||
where: and(...conditions),
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" });
|
||||
}
|
||||
|
||||
const endedAt = new Date();
|
||||
const hours = computeHours(entry.startedAt, endedAt);
|
||||
const description = input?.description?.trim() ?? entry.description;
|
||||
|
||||
const [updated] = await ctx.db
|
||||
.update(timeEntries)
|
||||
.set({ endedAt, hours, description, updatedAt: new Date() })
|
||||
.where(eq(timeEntries.id, entry.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(createSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const clientId = input.clientId?.trim() || null;
|
||||
if (clientId) {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
||||
});
|
||||
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
||||
}
|
||||
|
||||
let hours = input.hours ?? null;
|
||||
if (!hours && input.endedAt) {
|
||||
hours = computeHours(input.startedAt, input.endedAt);
|
||||
}
|
||||
|
||||
const [entry] = await ctx.db
|
||||
.insert(timeEntries)
|
||||
.values({
|
||||
description: input.description,
|
||||
clientId,
|
||||
startedAt: input.startedAt,
|
||||
endedAt: input.endedAt ?? null,
|
||||
hours,
|
||||
rate: input.rate ?? null,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return entry;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(updateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
|
||||
const existing = await ctx.db.query.timeEntries.findFirst({
|
||||
where: and(
|
||||
eq(timeEntries.id, id),
|
||||
eq(timeEntries.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
|
||||
|
||||
const clientId =
|
||||
data.clientId !== undefined ? data.clientId?.trim() || null : undefined;
|
||||
|
||||
if (clientId) {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
||||
});
|
||||
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(timeEntries)
|
||||
.set({
|
||||
...data,
|
||||
clientId,
|
||||
notes: data.notes?.trim() || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(timeEntries.id, id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.timeEntries.findFirst({
|
||||
where: and(
|
||||
eq(timeEntries.id, input.id),
|
||||
eq(timeEntries.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
|
||||
|
||||
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getSummary: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
from: z.date().optional(),
|
||||
to: z.date().optional(),
|
||||
}).optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const conditions = [
|
||||
eq(timeEntries.createdById, ctx.session.user.id),
|
||||
isNotNull(timeEntries.endedAt),
|
||||
];
|
||||
if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from));
|
||||
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
|
||||
|
||||
const entries = await ctx.db.query.timeEntries.findMany({
|
||||
where: and(...conditions),
|
||||
with: { client: true },
|
||||
});
|
||||
|
||||
const totalHours = entries.reduce((sum, e) => sum + (e.hours ?? 0), 0);
|
||||
const totalEarnings = entries.reduce(
|
||||
(sum, e) => sum + (e.hours ?? 0) * (e.rate ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return { totalHours, totalEarnings, count: entries.length };
|
||||
}),
|
||||
});
|
||||
@@ -89,6 +89,7 @@ export const usersRelations = relations(users, ({ many }) => ({
|
||||
expenses: many(expenses),
|
||||
invoiceTemplates: many(invoiceTemplates),
|
||||
recurringInvoices: many(recurringInvoices),
|
||||
timeEntries: many(timeEntries),
|
||||
}));
|
||||
|
||||
export const accounts = createTable(
|
||||
@@ -282,6 +283,7 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
|
||||
references: [users.id],
|
||||
}),
|
||||
invoices: many(invoices),
|
||||
timeEntries: many(timeEntries),
|
||||
}));
|
||||
|
||||
export const businesses = createTable(
|
||||
@@ -679,3 +681,51 @@ export const recurringInvoiceItemsRelations = relations(
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Time Entries ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const timeEntries = createTable(
|
||||
"time_entry",
|
||||
(d) => ({
|
||||
id: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
description: d.varchar({ length: 500 }).notNull().default(""),
|
||||
clientId: d
|
||||
.varchar({ length: 255 })
|
||||
.references(() => clients.id, { onDelete: "set null" }),
|
||||
startedAt: d.timestamp().notNull(),
|
||||
endedAt: d.timestamp(), // null = currently running
|
||||
hours: d.real(), // stored when stopped
|
||||
rate: d.real(),
|
||||
notes: d.varchar({ length: 500 }),
|
||||
createdById: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("time_entry_created_by_idx").on(t.createdById),
|
||||
index("time_entry_client_id_idx").on(t.clientId),
|
||||
index("time_entry_started_at_idx").on(t.startedAt),
|
||||
index("time_entry_ended_at_idx").on(t.endedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const timeEntriesRelations = relations(timeEntries, ({ one }) => ({
|
||||
client: one(clients, {
|
||||
fields: [timeEntries.clientId],
|
||||
references: [clients.id],
|
||||
}),
|
||||
createdBy: one(users, {
|
||||
fields: [timeEntries.createdById],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user