diff --git a/backend/prisma/migrations/20260218130348_add_client_targets_and_corrections/migration.sql b/backend/prisma/migrations/20260218130348_add_client_targets_and_corrections/migration.sql new file mode 100644 index 0000000..39eab7a --- /dev/null +++ b/backend/prisma/migrations/20260218130348_add_client_targets_and_corrections/migration.sql @@ -0,0 +1,46 @@ +-- CreateTable +CREATE TABLE "client_targets" ( + "id" TEXT NOT NULL, + "weekly_hours" DOUBLE PRECISION NOT NULL, + "start_date" DATE NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "user_id" VARCHAR(255) NOT NULL, + "client_id" TEXT NOT NULL, + + CONSTRAINT "client_targets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "balance_corrections" ( + "id" TEXT NOT NULL, + "date" DATE NOT NULL, + "hours" DOUBLE PRECISION NOT NULL, + "description" VARCHAR(255), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "client_target_id" TEXT NOT NULL, + + CONSTRAINT "balance_corrections_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "client_targets_user_id_idx" ON "client_targets"("user_id"); + +-- CreateIndex +CREATE INDEX "client_targets_client_id_idx" ON "client_targets"("client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "client_targets_user_id_client_id_key" ON "client_targets"("user_id", "client_id"); + +-- CreateIndex +CREATE INDEX "balance_corrections_client_target_id_idx" ON "balance_corrections"("client_target_id"); + +-- AddForeignKey +ALTER TABLE "client_targets" ADD CONSTRAINT "client_targets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "client_targets" ADD CONSTRAINT "client_targets_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "balance_corrections" ADD CONSTRAINT "balance_corrections_client_target_id_fkey" FOREIGN KEY ("client_target_id") REFERENCES "client_targets"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2062670..385c4be 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -18,6 +18,7 @@ model User { projects Project[] timeEntries TimeEntry[] ongoingTimer OngoingTimer? + clientTargets ClientTarget[] @@map("users") } @@ -32,6 +33,7 @@ model Client { userId String @map("user_id") @db.VarChar(255) user User @relation(fields: [userId], references: [id], onDelete: Cascade) projects Project[] + clientTargets ClientTarget[] @@index([userId]) @@map("clients") @@ -90,4 +92,39 @@ model OngoingTimer { @@index([userId]) @@map("ongoing_timers") +} + +model ClientTarget { + id String @id @default(uuid()) + weeklyHours Float @map("weekly_hours") + startDate DateTime @map("start_date") @db.Date // Always a Monday + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + userId String @map("user_id") @db.VarChar(255) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + clientId String @map("client_id") + client Client @relation(fields: [clientId], references: [id], onDelete: Cascade) + + corrections BalanceCorrection[] + + @@unique([userId, clientId]) + @@index([userId]) + @@index([clientId]) + @@map("client_targets") +} + +model BalanceCorrection { + id String @id @default(uuid()) + date DateTime @map("date") @db.Date + hours Float + description String? @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + clientTargetId String @map("client_target_id") + clientTarget ClientTarget @relation(fields: [clientTargetId], references: [id], onDelete: Cascade) + + @@index([clientTargetId]) + @@map("balance_corrections") } \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 4307a30..88f43c6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,7 @@ import clientRoutes from "./routes/client.routes"; import projectRoutes from "./routes/project.routes"; import timeEntryRoutes from "./routes/timeEntry.routes"; import timerRoutes from "./routes/timer.routes"; +import clientTargetRoutes from "./routes/clientTarget.routes"; async function main() { // Validate configuration @@ -60,6 +61,7 @@ async function main() { app.use("/projects", projectRoutes); app.use("/time-entries", timeEntryRoutes); app.use("/timer", timerRoutes); + app.use("/client-targets", clientTargetRoutes); // Error handling app.use(notFoundHandler); diff --git a/backend/src/routes/clientTarget.routes.ts b/backend/src/routes/clientTarget.routes.ts new file mode 100644 index 0000000..ade43ed --- /dev/null +++ b/backend/src/routes/clientTarget.routes.ts @@ -0,0 +1,109 @@ +import { Router } from 'express'; +import { requireAuth } from '../middleware/auth'; +import { validateBody, validateParams } from '../middleware/validation'; +import { ClientTargetService } from '../services/clientTarget.service'; +import { + CreateClientTargetSchema, + UpdateClientTargetSchema, + CreateCorrectionSchema, + IdSchema, +} from '../schemas'; +import { z } from 'zod'; +import type { AuthenticatedRequest } from '../types'; + +const router = Router(); +const service = new ClientTargetService(); + +const TargetAndCorrectionParamsSchema = z.object({ + id: z.string().uuid(), + correctionId: z.string().uuid(), +}); + +// GET /api/client-targets — list all targets with balance for current user +router.get('/', requireAuth, async (req: AuthenticatedRequest, res, next) => { + try { + const targets = await service.findAll(req.user!.id); + res.json(targets); + } catch (error) { + next(error); + } +}); + +// POST /api/client-targets — create a target +router.post( + '/', + requireAuth, + validateBody(CreateClientTargetSchema), + async (req: AuthenticatedRequest, res, next) => { + try { + const target = await service.create(req.user!.id, req.body); + res.status(201).json(target); + } catch (error) { + next(error); + } + } +); + +// PUT /api/client-targets/:id — update a target +router.put( + '/:id', + requireAuth, + validateParams(IdSchema), + validateBody(UpdateClientTargetSchema), + async (req: AuthenticatedRequest, res, next) => { + try { + const target = await service.update(req.params.id, req.user!.id, req.body); + res.json(target); + } catch (error) { + next(error); + } + } +); + +// DELETE /api/client-targets/:id — delete a target (cascades corrections) +router.delete( + '/:id', + requireAuth, + validateParams(IdSchema), + async (req: AuthenticatedRequest, res, next) => { + try { + await service.delete(req.params.id, req.user!.id); + res.status(204).send(); + } catch (error) { + next(error); + } + } +); + +// POST /api/client-targets/:id/corrections — add a correction +router.post( + '/:id/corrections', + requireAuth, + validateParams(IdSchema), + validateBody(CreateCorrectionSchema), + async (req: AuthenticatedRequest, res, next) => { + try { + const correction = await service.addCorrection(req.params.id, req.user!.id, req.body); + res.status(201).json(correction); + } catch (error) { + next(error); + } + } +); + +// DELETE /api/client-targets/:id/corrections/:correctionId — delete a correction +router.delete( + '/:id/corrections/:correctionId', + requireAuth, + validateParams(TargetAndCorrectionParamsSchema), + async (req: AuthenticatedRequest, res, next) => { + try { + await service.deleteCorrection(req.params.id, req.params.correctionId, req.user!.id); + res.status(204).send(); + } catch (error) { + next(error); + } + } +); + +export default router; diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts index dcb95ff..589b1da 100644 --- a/backend/src/schemas/index.ts +++ b/backend/src/schemas/index.ts @@ -69,3 +69,20 @@ export const UpdateTimerSchema = z.object({ export const StopTimerSchema = z.object({ projectId: z.string().uuid().optional(), }); + +export const CreateClientTargetSchema = z.object({ + clientId: z.string().uuid(), + weeklyHours: z.number().positive().max(168), + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format'), +}); + +export const UpdateClientTargetSchema = z.object({ + weeklyHours: z.number().positive().max(168).optional(), + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format').optional(), +}); + +export const CreateCorrectionSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be in YYYY-MM-DD format'), + hours: z.number().min(-24).max(24), + description: z.string().max(255).optional(), +}); diff --git a/backend/src/services/clientTarget.service.ts b/backend/src/services/clientTarget.service.ts new file mode 100644 index 0000000..e7f7a12 --- /dev/null +++ b/backend/src/services/clientTarget.service.ts @@ -0,0 +1,327 @@ +import { prisma } from '../prisma/client'; +import { NotFoundError, BadRequestError } from '../errors/AppError'; +import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types'; +import { Prisma } from '@prisma/client'; + +// Returns the Monday of the week containing the given date +function getMondayOfWeek(date: Date): Date { + const d = new Date(date); + const day = d.getUTCDay(); // 0 = Sunday, 1 = Monday, ... + const diff = day === 0 ? -6 : 1 - day; + d.setUTCDate(d.getUTCDate() + diff); + d.setUTCHours(0, 0, 0, 0); + return d; +} + +// Returns the Sunday (end of week) for a given Monday +function getSundayOfWeek(monday: Date): Date { + const d = new Date(monday); + d.setUTCDate(d.getUTCDate() + 6); + d.setUTCHours(23, 59, 59, 999); + return d; +} + +// Returns all Mondays from startDate up to and including the current week's Monday +function getWeekMondays(startDate: Date): Date[] { + const mondays: Date[] = []; + const currentMonday = getMondayOfWeek(new Date()); + let cursor = new Date(startDate); + cursor.setUTCHours(0, 0, 0, 0); + while (cursor <= currentMonday) { + mondays.push(new Date(cursor)); + cursor.setUTCDate(cursor.getUTCDate() + 7); + } + return mondays; +} + +interface WeekBalance { + weekStart: string; // ISO date string (Monday) + weekEnd: string; // ISO date string (Sunday) + trackedSeconds: number; + targetSeconds: number; + correctionHours: number; + balanceSeconds: number; // positive = overtime, negative = undertime +} + +export interface ClientTargetWithBalance { + id: string; + clientId: string; + clientName: string; + userId: string; + weeklyHours: number; + startDate: string; + createdAt: string; + updatedAt: string; + corrections: Array<{ + id: string; + date: string; + hours: number; + description: string | null; + createdAt: string; + }>; + totalBalanceSeconds: number; // running total across all weeks + currentWeekTrackedSeconds: number; + currentWeekTargetSeconds: number; + weeks: WeekBalance[]; +} + +export class ClientTargetService { + async findAll(userId: string): Promise { + const targets = await prisma.clientTarget.findMany({ + where: { userId }, + include: { + client: { select: { id: true, name: true } }, + corrections: { orderBy: { date: 'asc' } }, + }, + orderBy: { client: { name: 'asc' } }, + }); + + return Promise.all(targets.map(t => this.computeBalance(t))); + } + + async findById(id: string, userId: string) { + return prisma.clientTarget.findFirst({ + where: { id, userId }, + include: { + client: { select: { id: true, name: true } }, + corrections: { orderBy: { date: 'asc' } }, + }, + }); + } + + async create(userId: string, data: CreateClientTargetInput): Promise { + // Validate startDate is a Monday + const startDate = new Date(data.startDate + 'T00:00:00Z'); + const dayOfWeek = startDate.getUTCDay(); + if (dayOfWeek !== 1) { + throw new BadRequestError('startDate must be a Monday'); + } + + // Ensure the client belongs to this user + const client = await prisma.client.findFirst({ where: { id: data.clientId, userId } }); + if (!client) { + throw new NotFoundError('Client not found'); + } + + // Check for existing target (unique per user+client) + const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } }); + if (existing) { + throw new BadRequestError('A target already exists for this client. Delete the existing one first or update it.'); + } + + const target = await prisma.clientTarget.create({ + data: { + userId, + clientId: data.clientId, + weeklyHours: data.weeklyHours, + startDate, + }, + include: { + client: { select: { id: true, name: true } }, + corrections: { orderBy: { date: 'asc' } }, + }, + }); + + return this.computeBalance(target); + } + + async update(id: string, userId: string, data: UpdateClientTargetInput): Promise { + const existing = await this.findById(id, userId); + if (!existing) throw new NotFoundError('Client target not found'); + + const updateData: { weeklyHours?: number; startDate?: Date } = {}; + + if (data.weeklyHours !== undefined) { + updateData.weeklyHours = data.weeklyHours; + } + + if (data.startDate !== undefined) { + const startDate = new Date(data.startDate + 'T00:00:00Z'); + if (startDate.getUTCDay() !== 1) { + throw new BadRequestError('startDate must be a Monday'); + } + updateData.startDate = startDate; + } + + const updated = await prisma.clientTarget.update({ + where: { id }, + data: updateData, + include: { + client: { select: { id: true, name: true } }, + corrections: { orderBy: { date: 'asc' } }, + }, + }); + + return this.computeBalance(updated); + } + + async delete(id: string, userId: string): Promise { + const existing = await this.findById(id, userId); + if (!existing) throw new NotFoundError('Client target not found'); + await prisma.clientTarget.delete({ where: { id } }); + } + + async addCorrection(targetId: string, userId: string, data: CreateCorrectionInput) { + const target = await this.findById(targetId, userId); + if (!target) throw new NotFoundError('Client target not found'); + + const correctionDate = new Date(data.date + 'T00:00:00Z'); + const startDate = new Date(target.startDate); + startDate.setUTCHours(0, 0, 0, 0); + + if (correctionDate < startDate) { + throw new BadRequestError('Correction date cannot be before the target start date'); + } + + return prisma.balanceCorrection.create({ + data: { + clientTargetId: targetId, + date: correctionDate, + hours: data.hours, + description: data.description, + }, + }); + } + + async deleteCorrection(targetId: string, correctionId: string, userId: string): Promise { + const target = await this.findById(targetId, userId); + if (!target) throw new NotFoundError('Client target not found'); + + const correction = await prisma.balanceCorrection.findFirst({ + where: { id: correctionId, clientTargetId: targetId }, + }); + if (!correction) throw new NotFoundError('Correction not found'); + + await prisma.balanceCorrection.delete({ where: { id: correctionId } }); + } + + private async computeBalance(target: { + id: string; + clientId: string; + userId: string; + weeklyHours: number; + startDate: Date; + createdAt: Date; + updatedAt: Date; + client: { id: string; name: string }; + corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>; + }): Promise { + const mondays = getWeekMondays(target.startDate); + + if (mondays.length === 0) { + return this.emptyBalance(target); + } + + // Fetch all tracked time for this user on this client's projects in one query + // covering startDate to end of current week + const periodStart = mondays[0]; + const periodEnd = getSundayOfWeek(mondays[mondays.length - 1]); + + type TrackedRow = { week_start: Date; tracked_seconds: bigint }; + + const rows = await prisma.$queryRaw(Prisma.sql` + SELECT + DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start, + COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS tracked_seconds + FROM time_entries te + JOIN projects p ON p.id = te.project_id + WHERE te.user_id = ${target.userId} + AND p.client_id = ${target.clientId} + AND te.start_time >= ${periodStart} + AND te.start_time <= ${periodEnd} + GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') + `); + + // Index tracked seconds by week start (ISO Monday string) + const trackedByWeek = new Map(); + for (const row of rows) { + // DATE_TRUNC with 'week' gives Monday in Postgres (ISO week) + const monday = getMondayOfWeek(new Date(row.week_start)); + const key = monday.toISOString().split('T')[0]; + trackedByWeek.set(key, Number(row.tracked_seconds)); + } + + // Index corrections by week + const correctionsByWeek = new Map(); + for (const c of target.corrections) { + const monday = getMondayOfWeek(new Date(c.date)); + const key = monday.toISOString().split('T')[0]; + correctionsByWeek.set(key, (correctionsByWeek.get(key) ?? 0) + c.hours); + } + + const targetSecondsPerWeek = target.weeklyHours * 3600; + const weeks: WeekBalance[] = []; + let totalBalanceSeconds = 0; + + for (const monday of mondays) { + const key = monday.toISOString().split('T')[0]; + const sunday = getSundayOfWeek(monday); + const trackedSeconds = trackedByWeek.get(key) ?? 0; + const correctionHours = correctionsByWeek.get(key) ?? 0; + const effectiveTargetSeconds = targetSecondsPerWeek - correctionHours * 3600; + const balanceSeconds = trackedSeconds - effectiveTargetSeconds; + totalBalanceSeconds += balanceSeconds; + + weeks.push({ + weekStart: key, + weekEnd: sunday.toISOString().split('T')[0], + trackedSeconds, + targetSeconds: effectiveTargetSeconds, + correctionHours, + balanceSeconds, + }); + } + + const currentWeek = weeks[weeks.length - 1]; + + return { + id: target.id, + clientId: target.clientId, + clientName: target.client.name, + userId: target.userId, + weeklyHours: target.weeklyHours, + startDate: target.startDate.toISOString().split('T')[0], + createdAt: target.createdAt.toISOString(), + updatedAt: target.updatedAt.toISOString(), + corrections: target.corrections.map(c => ({ + id: c.id, + date: c.date.toISOString().split('T')[0], + hours: c.hours, + description: c.description, + createdAt: c.createdAt.toISOString(), + })), + totalBalanceSeconds, + currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0, + currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek, + weeks, + }; + } + + private emptyBalance(target: { + id: string; + clientId: string; + userId: string; + weeklyHours: number; + startDate: Date; + createdAt: Date; + updatedAt: Date; + client: { id: string; name: string }; + corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>; + }): ClientTargetWithBalance { + return { + id: target.id, + clientId: target.clientId, + clientName: target.client.name, + userId: target.userId, + weeklyHours: target.weeklyHours, + startDate: target.startDate.toISOString().split('T')[0], + createdAt: target.createdAt.toISOString(), + updatedAt: target.updatedAt.toISOString(), + corrections: [], + totalBalanceSeconds: 0, + currentWeekTrackedSeconds: 0, + currentWeekTargetSeconds: target.weeklyHours * 3600, + weeks: [], + }; + } +} diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index bd1cbb6..40cfb6a 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -75,3 +75,20 @@ export interface UpdateTimerInput { export interface StopTimerInput { projectId?: string; } + +export interface CreateClientTargetInput { + clientId: string; + weeklyHours: number; + startDate: string; // YYYY-MM-DD, always a Monday +} + +export interface UpdateClientTargetInput { + weeklyHours?: number; + startDate?: string; // YYYY-MM-DD, always a Monday +} + +export interface CreateCorrectionInput { + date: string; // YYYY-MM-DD + hours: number; + description?: string; +} diff --git a/frontend/src/api/clientTargets.ts b/frontend/src/api/clientTargets.ts new file mode 100644 index 0000000..6611a57 --- /dev/null +++ b/frontend/src/api/clientTargets.ts @@ -0,0 +1,41 @@ +import apiClient from './client'; +import type { + ClientTargetWithBalance, + CreateClientTargetInput, + UpdateClientTargetInput, + CreateCorrectionInput, + BalanceCorrection, +} from '@/types'; + +export const clientTargetsApi = { + getAll: async (): Promise => { + const { data } = await apiClient.get('/client-targets'); + return data; + }, + + create: async (input: CreateClientTargetInput): Promise => { + const { data } = await apiClient.post('/client-targets', input); + return data; + }, + + update: async (id: string, input: UpdateClientTargetInput): Promise => { + const { data } = await apiClient.put(`/client-targets/${id}`, input); + return data; + }, + + delete: async (id: string): Promise => { + await apiClient.delete(`/client-targets/${id}`); + }, + + addCorrection: async (targetId: string, input: CreateCorrectionInput): Promise => { + const { data } = await apiClient.post( + `/client-targets/${targetId}/corrections`, + input, + ); + return data; + }, + + deleteCorrection: async (targetId: string, correctionId: string): Promise => { + await apiClient.delete(`/client-targets/${targetId}/corrections/${correctionId}`); + }, +}; diff --git a/frontend/src/hooks/useClientTargets.ts b/frontend/src/hooks/useClientTargets.ts new file mode 100644 index 0000000..4c243e9 --- /dev/null +++ b/frontend/src/hooks/useClientTargets.ts @@ -0,0 +1,65 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { clientTargetsApi } from '@/api/clientTargets'; +import type { + CreateClientTargetInput, + UpdateClientTargetInput, + CreateCorrectionInput, +} from '@/types'; + +export function useClientTargets() { + const queryClient = useQueryClient(); + + const { data: targets, isLoading, error } = useQuery({ + queryKey: ['clientTargets'], + queryFn: clientTargetsApi.getAll, + }); + + const createTarget = useMutation({ + mutationFn: (input: CreateClientTargetInput) => clientTargetsApi.create(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['clientTargets'] }); + }, + }); + + const updateTarget = useMutation({ + mutationFn: ({ id, input }: { id: string; input: UpdateClientTargetInput }) => + clientTargetsApi.update(id, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['clientTargets'] }); + }, + }); + + const deleteTarget = useMutation({ + mutationFn: (id: string) => clientTargetsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['clientTargets'] }); + }, + }); + + const addCorrection = useMutation({ + mutationFn: ({ targetId, input }: { targetId: string; input: CreateCorrectionInput }) => + clientTargetsApi.addCorrection(targetId, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['clientTargets'] }); + }, + }); + + const deleteCorrection = useMutation({ + mutationFn: ({ targetId, correctionId }: { targetId: string; correctionId: string }) => + clientTargetsApi.deleteCorrection(targetId, correctionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['clientTargets'] }); + }, + }); + + return { + targets, + isLoading, + error, + createTarget, + updateTarget, + deleteTarget, + addCorrection, + deleteCorrection, + }; +} diff --git a/frontend/src/pages/ClientsPage.tsx b/frontend/src/pages/ClientsPage.tsx index 475cea2..d5f6cb9 100644 --- a/frontend/src/pages/ClientsPage.tsx +++ b/frontend/src/pages/ClientsPage.tsx @@ -1,12 +1,393 @@ import { useState } from 'react'; -import { Plus, Edit2, Trash2, Building2 } from 'lucide-react'; +import { Plus, Edit2, Trash2, Building2, Target, ChevronDown, ChevronUp, X } from 'lucide-react'; import { useClients } from '@/hooks/useClients'; +import { useClientTargets } from '@/hooks/useClientTargets'; import { Modal } from '@/components/Modal'; import { Spinner } from '@/components/Spinner'; -import type { Client, CreateClientInput, UpdateClientInput } from '@/types'; +import { formatDurationHoursMinutes } from '@/utils/dateUtils'; +import type { + Client, + CreateClientInput, + UpdateClientInput, + ClientTargetWithBalance, + CreateCorrectionInput, +} from '@/types'; + +// Convert a value like "2026-W07" to the Monday date "2026-02-16" +function weekInputToMonday(weekValue: string): string { + const [yearStr, weekStr] = weekValue.split('-W'); + const year = parseInt(yearStr, 10); + const week = parseInt(weekStr, 10); + // ISO week 1 is the week containing the first Thursday of January + const jan4 = new Date(Date.UTC(year, 0, 4)); + const jan4Day = jan4.getUTCDay() || 7; // Mon=1..Sun=7 + const monday = new Date(jan4); + monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7); + return monday.toISOString().split('T')[0]; +} + +// Convert a YYYY-MM-DD Monday to "YYYY-Www" for the week input +function mondayToWeekInput(dateStr: string): string { + const date = new Date(dateStr + 'T00:00:00Z'); + // ISO week number calculation + const jan4 = new Date(Date.UTC(date.getUTCFullYear(), 0, 4)); + const jan4Day = jan4.getUTCDay() || 7; + const firstMonday = new Date(jan4); + firstMonday.setUTCDate(jan4.getUTCDate() - jan4Day + 1); + const diff = date.getTime() - firstMonday.getTime(); + const week = Math.floor(diff / (7 * 24 * 3600 * 1000)) + 1; + // Handle year boundary: if week > 52 we might be in week 1 of next year + const year = date.getUTCFullYear(); + return `${year}-W${week.toString().padStart(2, '0')}`; +} + +function balanceLabel(seconds: number): { text: string; color: string } { + if (seconds === 0) return { text: '±0', color: 'text-gray-500' }; + const abs = Math.abs(seconds); + const text = (seconds > 0 ? '+' : '−') + formatDurationHoursMinutes(abs); + const color = seconds > 0 ? 'text-green-600' : 'text-red-600'; + return { text, color }; +} + +// Inline target panel shown inside each client card +function ClientTargetPanel({ + target, + onCreated, + onDeleted, +}: { + client: Client; + target: ClientTargetWithBalance | undefined; + onCreated: (weeklyHours: number, startDate: string) => Promise; + onDeleted: () => Promise; +}) { + const { addCorrection, deleteCorrection, updateTarget } = useClientTargets(); + + const [expanded, setExpanded] = useState(false); + const [showForm, setShowForm] = useState(false); + const [editing, setEditing] = useState(false); + + // Create/edit form state + const [formHours, setFormHours] = useState(''); + const [formWeek, setFormWeek] = useState(''); + const [formError, setFormError] = useState(null); + const [formSaving, setFormSaving] = useState(false); + + // Correction form state + const [showCorrectionForm, setShowCorrectionForm] = useState(false); + const [corrDate, setCorrDate] = useState(''); + const [corrHours, setCorrHours] = useState(''); + const [corrDesc, setCorrDesc] = useState(''); + const [corrError, setCorrError] = useState(null); + const [corrSaving, setCorrSaving] = useState(false); + + const openCreate = () => { + setFormHours(''); + const today = new Date(); + const day = today.getUTCDay() || 7; + const monday = new Date(today); + monday.setUTCDate(today.getUTCDate() - day + 1); + setFormWeek(mondayToWeekInput(monday.toISOString().split('T')[0])); + setFormError(null); + setEditing(false); + setShowForm(true); + }; + + const openEdit = () => { + if (!target) return; + setFormHours(String(target.weeklyHours)); + setFormWeek(mondayToWeekInput(target.startDate)); + setFormError(null); + setEditing(true); + setShowForm(true); + }; + + const handleFormSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + const hours = parseFloat(formHours); + if (isNaN(hours) || hours <= 0 || hours > 168) { + setFormError('Weekly hours must be between 0 and 168'); + return; + } + if (!formWeek) { + setFormError('Please select a start week'); + return; + } + const startDate = weekInputToMonday(formWeek); + setFormSaving(true); + try { + if (editing && target) { + await updateTarget.mutateAsync({ id: target.id, input: { weeklyHours: hours, startDate } }); + } else { + await onCreated(hours, startDate); + } + setShowForm(false); + } catch (err) { + setFormError(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setFormSaving(false); + } + }; + + const handleDelete = async () => { + if (!confirm('Delete this target? All corrections will also be deleted.')) return; + try { + await onDeleted(); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to delete'); + } + }; + + const handleAddCorrection = async (e: React.FormEvent) => { + e.preventDefault(); + setCorrError(null); + if (!target) return; + const hours = parseFloat(corrHours); + if (isNaN(hours) || hours < -24 || hours > 24) { + setCorrError('Hours must be between -24 and 24'); + return; + } + if (!corrDate) { + setCorrError('Please select a date'); + return; + } + setCorrSaving(true); + try { + const input: CreateCorrectionInput = { date: corrDate, hours, description: corrDesc || undefined }; + await addCorrection.mutateAsync({ targetId: target.id, input }); + setCorrDate(''); + setCorrHours(''); + setCorrDesc(''); + setShowCorrectionForm(false); + } catch (err) { + setCorrError(err instanceof Error ? err.message : 'Failed to add correction'); + } finally { + setCorrSaving(false); + } + }; + + const handleDeleteCorrection = async (correctionId: string) => { + if (!target) return; + try { + await deleteCorrection.mutateAsync({ targetId: target.id, correctionId }); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to delete correction'); + } + }; + + if (!target && !showForm) { + return ( +
+ +
+ ); + } + + if (showForm) { + return ( +
+

+ {editing ? 'Edit target' : 'Set weekly target'} +

+
+ {formError &&

{formError}

} +
+
+ + setFormHours(e.target.value)} + className="input text-sm py-1" + placeholder="e.g. 40" + min="0.5" + max="168" + step="0.5" + required + /> +
+
+ + setFormWeek(e.target.value)} + className="input text-sm py-1" + required + /> +
+
+
+ + +
+
+
+ ); + } + + // Target exists — show summary + expandable details + const balance = balanceLabel(target!.totalBalanceSeconds); + + return ( +
+ {/* Target summary row */} +
+
+ + + {target!.weeklyHours}h/week + + {balance.text} +
+
+ + + +
+
+ + {/* Expanded: corrections list + add form */} + {expanded && ( +
+

Corrections

+ + {target!.corrections.length === 0 && !showCorrectionForm && ( +

No corrections yet

+ )} + + {target!.corrections.map(c => ( +
+
+ {c.date} + {c.description && — {c.description}} +
+
+ = 0 ? 'text-green-600' : 'text-red-600'}`}> + {c.hours >= 0 ? '+' : ''}{c.hours}h + + +
+
+ ))} + + {showCorrectionForm ? ( +
+ {corrError &&

{corrError}

} +
+
+ + setCorrDate(e.target.value)} + className="input text-xs py-1" + required + /> +
+
+ + setCorrHours(e.target.value)} + className="input text-xs py-1" + placeholder="+8 / -4" + min="-24" + max="24" + step="0.5" + required + /> +
+
+
+ + setCorrDesc(e.target.value)} + className="input text-xs py-1" + placeholder="e.g. Public holiday" + maxLength={255} + /> +
+
+ + +
+
+ ) : ( + + )} +
+ )} +
+ ); +} export function ClientsPage() { const { clients, isLoading, createClient, updateClient, deleteClient } = useClients(); + const { targets, createTarget, deleteTarget } = useClientTargets(); + const [isModalOpen, setIsModalOpen] = useState(false); const [editingClient, setEditingClient] = useState(null); const [formData, setFormData] = useState({ name: '', description: '' }); @@ -108,36 +489,50 @@ export function ClientsPage() { ) : (
- {clients?.map((client) => ( -
-
-
-

- {client.name} -

- {client.description && ( -

- {client.description} -

- )} -
-
- - + {clients?.map((client) => { + const target = targets?.find(t => t.clientId === client.id); + return ( +
+
+
+

+ {client.name} +

+ {client.description && ( +

+ {client.description} +

+ )} +
+
+ + +
+ + { + await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate }); + }} + onDeleted={async () => { + if (target) await deleteTarget.mutateAsync(target.id); + }} + />
-
- ))} + ); + })}
)} @@ -202,4 +597,4 @@ export function ClientsPage() { )}
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 9e31629..f4d881e 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,6 +1,7 @@ import { Link } from "react-router-dom"; -import { Clock, Calendar, Briefcase, TrendingUp } from "lucide-react"; +import { Clock, Calendar, Briefcase, TrendingUp, Target } from "lucide-react"; import { useTimeEntries } from "@/hooks/useTimeEntries"; +import { useClientTargets } from "@/hooks/useClientTargets"; import { ProjectColorDot } from "@/components/ProjectColorDot"; import { StatCard } from "@/components/StatCard"; import { @@ -24,11 +25,15 @@ export function DashboardPage() { limit: 10, }); + const { targets } = useClientTargets(); + const totalTodaySeconds = todayEntries?.entries.reduce((total, entry) => { return total + calculateDuration(entry.startTime, entry.endTime); }, 0) || 0; + const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? []; + return (
{/* Page Header */} @@ -71,6 +76,61 @@ export function DashboardPage() { />
+ {/* Overtime / Targets Widget */} + {targetsWithData.length > 0 && ( +
+
+ +

Weekly Targets

+
+
+ {targetsWithData.map(target => { + const balance = target.totalBalanceSeconds; + const absBalance = Math.abs(balance); + const isOver = balance > 0; + const isEven = balance === 0; + const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds); + const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds); + + return ( +
+
+

{target.clientName}

+

+ This week: {currentWeekTracked} / {currentWeekTarget} +

+
+
+

+ {isEven + ? '±0' + : (isOver ? '+' : '−') + formatDurationHoursMinutes(absBalance)} +

+

running balance

+
+
+ ); + })} +
+

+ + Manage targets → + +

+
+ )} + {/* Recent Activity */}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e06525a..2a0ecb9 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -138,3 +138,53 @@ export interface UpdateTimeEntryInput { description?: string; projectId?: string; } + +export interface BalanceCorrection { + id: string; + date: string; // YYYY-MM-DD + hours: number; + description: string | null; + createdAt: string; +} + +export interface WeekBalance { + weekStart: string; // YYYY-MM-DD (Monday) + weekEnd: string; // YYYY-MM-DD (Sunday) + trackedSeconds: number; + targetSeconds: number; + correctionHours: number; + balanceSeconds: number; +} + +export interface ClientTargetWithBalance { + id: string; + clientId: string; + clientName: string; + userId: string; + weeklyHours: number; + startDate: string; // YYYY-MM-DD + createdAt: string; + updatedAt: string; + corrections: BalanceCorrection[]; + totalBalanceSeconds: number; + currentWeekTrackedSeconds: number; + currentWeekTargetSeconds: number; + weeks: WeekBalance[]; +} + +export interface CreateClientTargetInput { + clientId: string; + weeklyHours: number; + startDate: string; // YYYY-MM-DD +} + +export interface UpdateClientTargetInput { + weeklyHours?: number; + startDate?: string; +} + +export interface CreateCorrectionInput { + date: string; // YYYY-MM-DD + hours: number; + description?: string; +}