adds targets

This commit is contained in:
simon.franken
2026-02-18 14:27:44 +01:00
parent a352318e8a
commit 4cce62934e
12 changed files with 1198 additions and 32 deletions

View File

@@ -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;

View File

@@ -18,6 +18,7 @@ model User {
projects Project[] projects Project[]
timeEntries TimeEntry[] timeEntries TimeEntry[]
ongoingTimer OngoingTimer? ongoingTimer OngoingTimer?
clientTargets ClientTarget[]
@@map("users") @@map("users")
} }
@@ -32,6 +33,7 @@ model Client {
userId String @map("user_id") @db.VarChar(255) userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
projects Project[] projects Project[]
clientTargets ClientTarget[]
@@index([userId]) @@index([userId])
@@map("clients") @@map("clients")
@@ -91,3 +93,38 @@ model OngoingTimer {
@@index([userId]) @@index([userId])
@@map("ongoing_timers") @@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")
}

View File

@@ -11,6 +11,7 @@ import clientRoutes from "./routes/client.routes";
import projectRoutes from "./routes/project.routes"; import projectRoutes from "./routes/project.routes";
import timeEntryRoutes from "./routes/timeEntry.routes"; import timeEntryRoutes from "./routes/timeEntry.routes";
import timerRoutes from "./routes/timer.routes"; import timerRoutes from "./routes/timer.routes";
import clientTargetRoutes from "./routes/clientTarget.routes";
async function main() { async function main() {
// Validate configuration // Validate configuration
@@ -60,6 +61,7 @@ async function main() {
app.use("/projects", projectRoutes); app.use("/projects", projectRoutes);
app.use("/time-entries", timeEntryRoutes); app.use("/time-entries", timeEntryRoutes);
app.use("/timer", timerRoutes); app.use("/timer", timerRoutes);
app.use("/client-targets", clientTargetRoutes);
// Error handling // Error handling
app.use(notFoundHandler); app.use(notFoundHandler);

View File

@@ -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;

View File

@@ -69,3 +69,20 @@ export const UpdateTimerSchema = z.object({
export const StopTimerSchema = z.object({ export const StopTimerSchema = z.object({
projectId: z.string().uuid().optional(), 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(),
});

View File

@@ -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<ClientTargetWithBalance[]> {
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<ClientTargetWithBalance> {
// 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<ClientTargetWithBalance> {
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<void> {
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<void> {
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<ClientTargetWithBalance> {
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<TrackedRow[]>(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<string, number>();
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<string, number>();
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: [],
};
}
}

View File

@@ -75,3 +75,20 @@ export interface UpdateTimerInput {
export interface StopTimerInput { export interface StopTimerInput {
projectId?: string; 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;
}

View File

@@ -0,0 +1,41 @@
import apiClient from './client';
import type {
ClientTargetWithBalance,
CreateClientTargetInput,
UpdateClientTargetInput,
CreateCorrectionInput,
BalanceCorrection,
} from '@/types';
export const clientTargetsApi = {
getAll: async (): Promise<ClientTargetWithBalance[]> => {
const { data } = await apiClient.get<ClientTargetWithBalance[]>('/client-targets');
return data;
},
create: async (input: CreateClientTargetInput): Promise<ClientTargetWithBalance> => {
const { data } = await apiClient.post<ClientTargetWithBalance>('/client-targets', input);
return data;
},
update: async (id: string, input: UpdateClientTargetInput): Promise<ClientTargetWithBalance> => {
const { data } = await apiClient.put<ClientTargetWithBalance>(`/client-targets/${id}`, input);
return data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/client-targets/${id}`);
},
addCorrection: async (targetId: string, input: CreateCorrectionInput): Promise<BalanceCorrection> => {
const { data } = await apiClient.post<BalanceCorrection>(
`/client-targets/${targetId}/corrections`,
input,
);
return data;
},
deleteCorrection: async (targetId: string, correctionId: string): Promise<void> => {
await apiClient.delete(`/client-targets/${targetId}/corrections/${correctionId}`);
},
};

View File

@@ -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,
};
}

View File

@@ -1,12 +1,393 @@
import { useState } from 'react'; 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 { useClients } from '@/hooks/useClients';
import { useClientTargets } from '@/hooks/useClientTargets';
import { Modal } from '@/components/Modal'; import { Modal } from '@/components/Modal';
import { Spinner } from '@/components/Spinner'; 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 <input type="week"> 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<void>;
onDeleted: () => Promise<void>;
}) {
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<string | null>(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<string | null>(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 (
<div className="mt-3 pt-3 border-t border-gray-100">
<button
onClick={openCreate}
className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 font-medium"
>
<Target className="h-3.5 w-3.5" />
Set weekly target
</button>
</div>
);
}
if (showForm) {
return (
<div className="mt-3 pt-3 border-t border-gray-100">
<p className="text-xs font-medium text-gray-700 mb-2">
{editing ? 'Edit target' : 'Set weekly target'}
</p>
<form onSubmit={handleFormSubmit} className="space-y-2">
{formError && <p className="text-xs text-red-600">{formError}</p>}
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Hours/week</label>
<input
type="number"
value={formHours}
onChange={e => setFormHours(e.target.value)}
className="input text-sm py-1"
placeholder="e.g. 40"
min="0.5"
max="168"
step="0.5"
required
/>
</div>
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Starting week</label>
<input
type="week"
value={formWeek}
onChange={e => setFormWeek(e.target.value)}
className="input text-sm py-1"
required
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setShowForm(false)}
className="btn-secondary text-xs py-1 px-3"
>
Cancel
</button>
<button
type="submit"
disabled={formSaving}
className="btn-primary text-xs py-1 px-3"
>
{formSaving ? 'Saving...' : editing ? 'Save' : 'Set target'}
</button>
</div>
</form>
</div>
);
}
// Target exists — show summary + expandable details
const balance = balanceLabel(target!.totalBalanceSeconds);
return (
<div className="mt-3 pt-3 border-t border-gray-100">
{/* Target summary row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Target className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<span className="text-xs text-gray-600">
<span className="font-medium">{target!.weeklyHours}h</span>/week
</span>
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={openEdit}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Edit target"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={handleDelete}
className="p-1 text-gray-400 hover:text-red-600 rounded"
title="Delete target"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setExpanded(v => !v)}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Show corrections"
>
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
</div>
</div>
{/* Expanded: corrections list + add form */}
{expanded && (
<div className="mt-2 space-y-1.5">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Corrections</p>
{target!.corrections.length === 0 && !showCorrectionForm && (
<p className="text-xs text-gray-400">No corrections yet</p>
)}
{target!.corrections.map(c => (
<div key={c.id} className="flex items-center justify-between bg-gray-50 rounded px-2 py-1">
<div className="text-xs text-gray-700">
<span className="font-medium">{c.date}</span>
{c.description && <span className="text-gray-500 ml-1"> {c.description}</span>}
</div>
<div className="flex items-center gap-1.5">
<span className={`text-xs font-semibold ${c.hours >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{c.hours >= 0 ? '+' : ''}{c.hours}h
</span>
<button
onClick={() => handleDeleteCorrection(c.id)}
className="text-gray-300 hover:text-red-500"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
))}
{showCorrectionForm ? (
<form onSubmit={handleAddCorrection} className="space-y-1.5 pt-1">
{corrError && <p className="text-xs text-red-600">{corrError}</p>}
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Date</label>
<input
type="date"
value={corrDate}
onChange={e => setCorrDate(e.target.value)}
className="input text-xs py-1"
required
/>
</div>
<div className="w-24">
<label className="block text-xs text-gray-500 mb-0.5">Hours</label>
<input
type="number"
value={corrHours}
onChange={e => setCorrHours(e.target.value)}
className="input text-xs py-1"
placeholder="+8 / -4"
min="-24"
max="24"
step="0.5"
required
/>
</div>
</div>
<div>
<label className="block text-xs text-gray-500 mb-0.5">Description (optional)</label>
<input
type="text"
value={corrDesc}
onChange={e => setCorrDesc(e.target.value)}
className="input text-xs py-1"
placeholder="e.g. Public holiday"
maxLength={255}
/>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setShowCorrectionForm(false)}
className="btn-secondary text-xs py-1 px-3"
>
Cancel
</button>
<button
type="submit"
disabled={corrSaving}
className="btn-primary text-xs py-1 px-3"
>
{corrSaving ? 'Saving...' : 'Add'}
</button>
</div>
</form>
) : (
<button
onClick={() => setShowCorrectionForm(true)}
className="flex items-center gap-1 text-xs text-primary-600 hover:text-primary-700 font-medium pt-0.5"
>
<Plus className="h-3.5 w-3.5" />
Add correction
</button>
)}
</div>
)}
</div>
);
}
export function ClientsPage() { export function ClientsPage() {
const { clients, isLoading, createClient, updateClient, deleteClient } = useClients(); const { clients, isLoading, createClient, updateClient, deleteClient } = useClients();
const { targets, createTarget, deleteTarget } = useClientTargets();
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingClient, setEditingClient] = useState<Client | null>(null); const [editingClient, setEditingClient] = useState<Client | null>(null);
const [formData, setFormData] = useState<CreateClientInput>({ name: '', description: '' }); const [formData, setFormData] = useState<CreateClientInput>({ name: '', description: '' });
@@ -108,7 +489,9 @@ export function ClientsPage() {
</div> </div>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{clients?.map((client) => ( {clients?.map((client) => {
const target = targets?.find(t => t.clientId === client.id);
return (
<div key={client.id} className="card"> <div key={client.id} className="card">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -136,8 +519,20 @@ export function ClientsPage() {
</button> </button>
</div> </div>
</div> </div>
<ClientTargetPanel
client={client}
target={target}
onCreated={async (weeklyHours, startDate) => {
await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate });
}}
onDeleted={async () => {
if (target) await deleteTarget.mutateAsync(target.id);
}}
/>
</div> </div>
))} );
})}
</div> </div>
)} )}

View File

@@ -1,6 +1,7 @@
import { Link } from "react-router-dom"; 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 { useTimeEntries } from "@/hooks/useTimeEntries";
import { useClientTargets } from "@/hooks/useClientTargets";
import { ProjectColorDot } from "@/components/ProjectColorDot"; import { ProjectColorDot } from "@/components/ProjectColorDot";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { import {
@@ -24,11 +25,15 @@ export function DashboardPage() {
limit: 10, limit: 10,
}); });
const { targets } = useClientTargets();
const totalTodaySeconds = const totalTodaySeconds =
todayEntries?.entries.reduce((total, entry) => { todayEntries?.entries.reduce((total, entry) => {
return total + calculateDuration(entry.startTime, entry.endTime); return total + calculateDuration(entry.startTime, entry.endTime);
}, 0) || 0; }, 0) || 0;
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Page Header */} {/* Page Header */}
@@ -71,6 +76,61 @@ export function DashboardPage() {
/> />
</div> </div>
{/* Overtime / Targets Widget */}
{targetsWithData.length > 0 && (
<div className="card">
<div className="flex items-center gap-2 mb-4">
<Target className="h-5 w-5 text-primary-600" />
<h2 className="text-lg font-semibold text-gray-900">Weekly Targets</h2>
</div>
<div className="space-y-3">
{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 (
<div
key={target.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p className="text-sm font-medium text-gray-900">{target.clientName}</p>
<p className="text-xs text-gray-500 mt-0.5">
This week: {currentWeekTracked} / {currentWeekTarget}
</p>
</div>
<div className="text-right">
<p
className={`text-sm font-bold ${
isEven
? 'text-gray-500'
: isOver
? 'text-green-600'
: 'text-red-600'
}`}
>
{isEven
? '±0'
: (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)}
</p>
<p className="text-xs text-gray-400">running balance</p>
</div>
</div>
);
})}
</div>
<p className="mt-3 text-xs text-gray-400">
<Link to="/clients" className="text-primary-600 hover:text-primary-700">
Manage targets
</Link>
</p>
</div>
)}
{/* Recent Activity */} {/* Recent Activity */}
<div className="card"> <div className="card">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">

View File

@@ -138,3 +138,53 @@ export interface UpdateTimeEntryInput {
description?: string; description?: string;
projectId?: 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;
}