From 4f23c1c65314ed58cdfc31d7143e6abe3e7c7927 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Tue, 24 Feb 2026 18:11:45 +0100 Subject: [PATCH] feat: implement monthly targets and work-day-aware balance calculation Add support for monthly and weekly targets with work-day selection: - Users can now set targets as 'monthly' or 'weekly' - Users select which days of the week they work - Balance is calculated per working day, evenly distributed - Target hours = (periodHours / workingDaysInPeriod) - Corrections are applied at period level - Daily balance tracking replaces weekly granularity Database changes: - Rename weekly_hours -> target_hours - Add period_type (weekly|monthly, default=weekly) - Add work_days (integer array of ISO weekdays, default=[1,2,3,4,5]) - Add constraints for valid period_type and non-empty work_days Backend: - Rewrite balance calculation in ClientTargetService - Support monthly period enumeration - Calculate per-day targets based on selected working days - Update Zod schemas for new fields - Update TypeScript types Frontend: - Add period type selector in target form (weekly/monthly) - Add work days multi-checkbox selector - Conditional start date input (week vs month) - Update DashboardPage to show 'this period' instead of 'this week' - Update ClientsPage to display working days and period type - Support both weekly and monthly targets in UI Migration: - Add migration to extend client_targets table with new columns - Backfill existing targets with default values (weekly, Mon-Fri) --- .../migration.sql | 17 + backend/prisma/schema.prisma | 14 +- backend/src/schemas/index.ts | 8 +- backend/src/services/clientTarget.service.ts | 331 ++++++++++++++---- backend/src/types/index.ts | 12 +- frontend/src/pages/ClientsPage.tsx | 206 ++++++++--- frontend/src/pages/DashboardPage.tsx | 13 +- frontend/src/types/index.ts | 25 +- 8 files changed, 481 insertions(+), 145 deletions(-) create mode 100644 backend/prisma/migrations/20260224120000_extend_targets_with_period_and_workdays/migration.sql diff --git a/backend/prisma/migrations/20260224120000_extend_targets_with_period_and_workdays/migration.sql b/backend/prisma/migrations/20260224120000_extend_targets_with_period_and_workdays/migration.sql new file mode 100644 index 0000000..276741e --- /dev/null +++ b/backend/prisma/migrations/20260224120000_extend_targets_with_period_and_workdays/migration.sql @@ -0,0 +1,17 @@ +-- Extend client_targets with period_type and work_days, rename weekly_hours to target_hours +-- This migration adds support for monthly targets and work-day-aware balance calculation + +-- Rename weekly_hours to target_hours +ALTER TABLE "client_targets" RENAME COLUMN "weekly_hours" TO "target_hours"; + +-- Add period_type column with default 'weekly' for backwards compatibility +ALTER TABLE "client_targets" ADD COLUMN "period_type" VARCHAR(10) NOT NULL DEFAULT 'weekly'; + +-- Add work_days column with default Mon-Fri (1-5) for backwards compatibility +ALTER TABLE "client_targets" ADD COLUMN "work_days" INTEGER[] NOT NULL DEFAULT '{1,2,3,4,5}'; + +-- Add check constraint to ensure period_type is valid +ALTER TABLE "client_targets" ADD CONSTRAINT "period_type_check" CHECK ("period_type" IN ('weekly', 'monthly')); + +-- Add check constraint to ensure at least one work day is selected +ALTER TABLE "client_targets" ADD CONSTRAINT "work_days_not_empty_check" CHECK (array_length("work_days", 1) > 0); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 83b49fb..64ee284 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -100,12 +100,14 @@ model OngoingTimer { } 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") - deletedAt DateTime? @map("deleted_at") + id String @id @default(uuid()) + targetHours Float @map("target_hours") + periodType String @default("weekly") @map("period_type") @db.VarChar(10) // 'weekly' | 'monthly' + workDays Int[] @default([1, 2, 3, 4, 5]) @map("work_days") // ISO weekday numbers (1=Mon, 7=Sun) + startDate DateTime @map("start_date") @db.Date // Monday for weekly, 1st of month for monthly + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") userId String @map("user_id") @db.VarChar(255) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts index a0b73e8..32bf046 100644 --- a/backend/src/schemas/index.ts +++ b/backend/src/schemas/index.ts @@ -75,12 +75,16 @@ export const StopTimerSchema = z.object({ export const CreateClientTargetSchema = z.object({ clientId: z.string().uuid(), - weeklyHours: z.number().positive().max(168), + periodType: z.enum(['weekly', 'monthly']).default('weekly'), + targetHours: z.number().positive().max(744), // 31 days * 24h for monthly max + workDays: z.array(z.number().int().min(1).max(7)).min(1).max(7).default([1, 2, 3, 4, 5]), 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(), + periodType: z.enum(['weekly', 'monthly']).optional(), + targetHours: z.number().positive().max(744).optional(), + workDays: z.array(z.number().int().min(1).max(7)).min(1).max(7).optional(), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format').optional(), }); diff --git a/backend/src/services/clientTarget.service.ts b/backend/src/services/clientTarget.service.ts index e2e0419..62eb387 100644 --- a/backend/src/services/clientTarget.service.ts +++ b/backend/src/services/clientTarget.service.ts @@ -13,10 +13,18 @@ function getMondayOfWeek(date: Date): Date { 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); +// Returns the first day of the month for a given date +function getFirstOfMonth(date: Date): Date { + const d = new Date(date); + d.setUTCDate(1); + d.setUTCHours(0, 0, 0, 0); + return d; +} + +// Returns the last day of the month for a given date +function getLastOfMonth(date: Date): Date { + const d = new Date(date); + d.setUTCMonth(d.getUTCMonth() + 1, 0); d.setUTCHours(23, 59, 59, 999); return d; } @@ -34,13 +42,56 @@ function getWeekMondays(startDate: Date): Date[] { return mondays; } -interface WeekBalance { - weekStart: string; // ISO date string (Monday) - weekEnd: string; // ISO date string (Sunday) +// Returns all month starts from startDate up to and including the current month +function getMonthStarts(startDate: Date): Date[] { + const months: Date[] = []; + const currentMonthStart = getFirstOfMonth(new Date()); + let cursor = getFirstOfMonth(new Date(startDate)); + while (cursor <= currentMonthStart) { + months.push(new Date(cursor)); + cursor.setUTCMonth(cursor.getUTCMonth() + 1); + } + return months; +} + +// Get all working days in a period that fall on the given weekdays +function getWorkingDaysInPeriod( + periodStart: Date, + periodEnd: Date, + workDays: number[] +): Date[] { + const workingDays: Date[] = []; + const current = new Date(periodStart); + current.setUTCHours(0, 0, 0, 0); + + while (current <= periodEnd) { + const dayOfWeek = current.getUTCDay() === 0 ? 7 : current.getUTCDay(); // Convert to ISO (1=Mon, 7=Sun) + if (workDays.includes(dayOfWeek)) { + workingDays.push(new Date(current)); + } + current.setUTCDate(current.getUTCDate() + 1); + } + + return workingDays; +} + +// Get working days in a period up to and including today +function getWorkingDaysUpToToday( + periodStart: Date, + periodEnd: Date, + workDays: number[] +): Date[] { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const endDate = periodEnd < today ? periodEnd : today; + return getWorkingDaysInPeriod(periodStart, endDate, workDays); +} + +interface DayBalance { + date: string; // YYYY-MM-DD trackedSeconds: number; targetSeconds: number; - correctionHours: number; - balanceSeconds: number; // positive = overtime, negative = undertime + balanceSeconds: number; } export interface ClientTargetWithBalance { @@ -48,7 +99,9 @@ export interface ClientTargetWithBalance { clientId: string; clientName: string; userId: string; - weeklyHours: number; + periodType: 'weekly' | 'monthly'; + targetHours: number; + workDays: number[]; startDate: string; createdAt: string; updatedAt: string; @@ -59,10 +112,10 @@ export interface ClientTargetWithBalance { description: string | null; createdAt: string; }>; - totalBalanceSeconds: number; // running total across all weeks - currentWeekTrackedSeconds: number; - currentWeekTargetSeconds: number; - weeks: WeekBalance[]; + totalBalanceSeconds: number; + currentPeriodTrackedSeconds: number; + currentPeriodTargetSeconds: number; + days: DayBalance[]; } export class ClientTargetService { @@ -90,11 +143,30 @@ export class ClientTargetService { } async create(userId: string, data: CreateClientTargetInput): Promise { - // Validate startDate is a Monday + // Validate startDate format const startDate = new Date(data.startDate + 'T00:00:00Z'); - const dayOfWeek = startDate.getUTCDay(); - if (dayOfWeek !== 1) { - throw new BadRequestError('startDate must be a Monday'); + if (isNaN(startDate.getTime())) { + throw new BadRequestError('Invalid startDate format'); + } + + // Validate startDate based on periodType + if (data.periodType === 'weekly') { + const dayOfWeek = startDate.getUTCDay(); + if (dayOfWeek !== 1) { + throw new BadRequestError('For weekly targets, startDate must be a Monday'); + } + } else if (data.periodType === 'monthly') { + if (startDate.getUTCDate() !== 1) { + throw new BadRequestError('For monthly targets, startDate must be the 1st of a month'); + } + } + + // Validate workDays + if (!data.workDays || data.workDays.length === 0) { + throw new BadRequestError('At least one working day must be selected'); + } + if (new Set(data.workDays).size !== data.workDays.length) { + throw new BadRequestError('workDays must not contain duplicates'); } // Ensure the client belongs to this user and is not soft-deleted @@ -110,7 +182,13 @@ export class ClientTargetService { // Reactivate the soft-deleted target with the new settings const reactivated = await prisma.clientTarget.update({ where: { id: existing.id }, - data: { deletedAt: null, weeklyHours: data.weeklyHours, startDate }, + data: { + deletedAt: null, + targetHours: data.targetHours, + periodType: data.periodType, + workDays: data.workDays, + startDate, + }, include: { client: { select: { id: true, name: true } }, corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } }, @@ -125,7 +203,9 @@ export class ClientTargetService { data: { userId, clientId: data.clientId, - weeklyHours: data.weeklyHours, + targetHours: data.targetHours, + periodType: data.periodType, + workDays: data.workDays, startDate, }, include: { @@ -141,16 +221,46 @@ export class ClientTargetService { const existing = await this.findById(id, userId); if (!existing) throw new NotFoundError('Client target not found'); - const updateData: { weeklyHours?: number; startDate?: Date } = {}; + const updateData: { + targetHours?: number; + periodType?: string; + workDays?: number[]; + startDate?: Date; + } = {}; - if (data.weeklyHours !== undefined) { - updateData.weeklyHours = data.weeklyHours; + if (data.targetHours !== undefined) { + updateData.targetHours = data.targetHours; + } + + if (data.periodType !== undefined) { + updateData.periodType = data.periodType; + } + + if (data.workDays !== undefined) { + if (data.workDays.length === 0) { + throw new BadRequestError('At least one working day must be selected'); + } + if (new Set(data.workDays).size !== data.workDays.length) { + throw new BadRequestError('workDays must not contain duplicates'); + } + updateData.workDays = data.workDays; } 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'); + if (isNaN(startDate.getTime())) { + throw new BadRequestError('Invalid startDate format'); + } + + const periodType = data.periodType ?? existing.periodType; + if (periodType === 'weekly') { + if (startDate.getUTCDay() !== 1) { + throw new BadRequestError('For weekly targets, startDate must be a Monday'); + } + } else if (periodType === 'monthly') { + if (startDate.getUTCDate() !== 1) { + throw new BadRequestError('For monthly targets, startDate must be the 1st of a month'); + } } updateData.startDate = startDate; } @@ -217,89 +327,154 @@ export class ClientTargetService { id: string; clientId: string; userId: string; - weeklyHours: number; + targetHours: number; + periodType: string; + workDays: 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); + const startDateObj = new Date(target.startDate); + startDateObj.setUTCHours(0, 0, 0, 0); - if (mondays.length === 0) { + // Get all periods (weeks or months) from startDate to now + const periods: Array<{ start: Date; end: Date }> = []; + if (target.periodType === 'weekly') { + const mondays = getWeekMondays(startDateObj); + for (const monday of mondays) { + const sunday = new Date(monday); + sunday.setUTCDate(sunday.getUTCDate() + 6); + sunday.setUTCHours(23, 59, 59, 999); + periods.push({ start: monday, end: sunday }); + } + } else { + const months = getMonthStarts(startDateObj); + for (const monthStart of months) { + const monthEnd = getLastOfMonth(monthStart); + periods.push({ start: monthStart, end: monthEnd }); + } + } + + if (periods.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]); + // Fetch all tracked time for this user on this client's projects + const periodStart = periods[0].start; + const periodEnd = periods[periods.length - 1].end; - type TrackedRow = { week_start: Date; tracked_seconds: bigint }; + type TrackedRow = { date: string; tracked_seconds: bigint }; const rows = await prisma.$queryRaw(Prisma.sql` SELECT - DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start, + DATE(te.start_time AT TIME ZONE 'UTC') AS date, COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 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} + AND DATE(te.start_time AT TIME ZONE 'UTC') >= ${periodStart}::date + AND DATE(te.start_time AT TIME ZONE 'UTC') <= ${periodEnd}::date AND te.deleted_at IS NULL AND p.deleted_at IS NULL - GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') + GROUP BY DATE(te.start_time AT TIME ZONE 'UTC') + ORDER BY date ASC `); - // Index tracked seconds by week start (ISO Monday string) - const trackedByWeek = new Map(); + // Index tracked seconds by date (ISO string) + const trackedByDate = 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)); + trackedByDate.set(row.date, Number(row.tracked_seconds)); } - // Index corrections by week - const correctionsByWeek = new Map(); + // Index corrections by period + const correctionsByPeriod = new Map(); // period index -> total hours 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 correctionDate = new Date(c.date); + correctionDate.setUTCHours(0, 0, 0, 0); + + for (let i = 0; i < periods.length; i++) { + if (correctionDate >= periods[i].start && correctionDate <= periods[i].end) { + correctionsByPeriod.set(i, (correctionsByPeriod.get(i) ?? 0) + c.hours); + break; + } + } } - const targetSecondsPerWeek = target.weeklyHours * 3600; - const weeks: WeekBalance[] = []; + // Compute daily balances + const days: DayBalance[] = []; let totalBalanceSeconds = 0; + let currentPeriodTrackedSeconds = 0; + let currentPeriodTargetSeconds = 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; + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); - weeks.push({ - weekStart: key, - weekEnd: sunday.toISOString().split('T')[0], - trackedSeconds, - targetSeconds: effectiveTargetSeconds, - correctionHours, - balanceSeconds, - }); + for (let periodIdx = 0; periodIdx < periods.length; periodIdx++) { + const period = periods[periodIdx]; + + // Get working days for this period up to today + const workingDaysInPeriod = getWorkingDaysUpToToday( + period.start, + period.end, + target.workDays + ); + + if (workingDaysInPeriod.length === 0) { + continue; // No working days in this period up to today + } + + // Calculate effective target for this period (after corrections) + const correctionHours = correctionsByPeriod.get(periodIdx) ?? 0; + const effectiveTargetSeconds = (target.targetHours * 3600) - (correctionHours * 3600); + const targetSecondsPerDay = effectiveTargetSeconds / workingDaysInPeriod.length; + + // Is this the current period? + const isCurrentPeriod = periodIdx === periods.length - 1; + if (isCurrentPeriod) { + // For full month/week target, count all working days in the period (for display) + const allWorkingDaysInPeriod = getWorkingDaysInPeriod(period.start, period.end, target.workDays); + currentPeriodTargetSeconds = (target.targetHours * 3600) - (correctionHours * 3600); + } + + // Process each working day + for (const workingDay of workingDaysInPeriod) { + const dateStr = workingDay.toISOString().split('T')[0]; + const trackedSeconds = trackedByDate.get(dateStr) ?? 0; + const balanceSeconds = trackedSeconds - targetSecondsPerDay; + + days.push({ + date: dateStr, + trackedSeconds, + targetSeconds: targetSecondsPerDay, + balanceSeconds, + }); + + totalBalanceSeconds += balanceSeconds; + + if (isCurrentPeriod) { + currentPeriodTrackedSeconds += trackedSeconds; + } + } } - const currentWeek = weeks[weeks.length - 1]; + // If no days were added but we have periods, use defaults + if (days.length === 0) { + const lastPeriod = periods[periods.length - 1]; + const correctionHours = correctionsByPeriod.get(periods.length - 1) ?? 0; + currentPeriodTargetSeconds = (target.targetHours * 3600) - (correctionHours * 3600); + } return { id: target.id, clientId: target.clientId, clientName: target.client.name, userId: target.userId, - weeklyHours: target.weeklyHours, + periodType: target.periodType as 'weekly' | 'monthly', + targetHours: target.targetHours, + workDays: target.workDays, startDate: target.startDate.toISOString().split('T')[0], createdAt: target.createdAt.toISOString(), updatedAt: target.updatedAt.toISOString(), @@ -311,9 +486,9 @@ export class ClientTargetService { createdAt: c.createdAt.toISOString(), })), totalBalanceSeconds, - currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0, - currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek, - weeks, + currentPeriodTrackedSeconds, + currentPeriodTargetSeconds, + days, }; } @@ -321,7 +496,9 @@ export class ClientTargetService { id: string; clientId: string; userId: string; - weeklyHours: number; + targetHours: number; + periodType: string; + workDays: number[]; startDate: Date; createdAt: Date; updatedAt: Date; @@ -333,15 +510,17 @@ export class ClientTargetService { clientId: target.clientId, clientName: target.client.name, userId: target.userId, - weeklyHours: target.weeklyHours, + periodType: target.periodType as 'weekly' | 'monthly', + targetHours: target.targetHours, + workDays: target.workDays, 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: [], + currentPeriodTrackedSeconds: 0, + currentPeriodTargetSeconds: target.targetHours * 3600, + days: [], }; } } diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 47aa4cb..cf0b213 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -82,13 +82,17 @@ export interface StopTimerInput { export interface CreateClientTargetInput { clientId: string; - weeklyHours: number; - startDate: string; // YYYY-MM-DD, always a Monday + periodType: 'weekly' | 'monthly'; + targetHours: number; + workDays: number[]; // ISO weekday numbers (1=Mon, 7=Sun) + startDate: string; // YYYY-MM-DD, Monday for weekly, 1st for monthly } export interface UpdateClientTargetInput { - weeklyHours?: number; - startDate?: string; // YYYY-MM-DD, always a Monday + periodType?: 'weekly' | 'monthly'; + targetHours?: number; + workDays?: number[]; + startDate?: string; // YYYY-MM-DD } export interface CreateCorrectionInput { diff --git a/frontend/src/pages/ClientsPage.tsx b/frontend/src/pages/ClientsPage.tsx index fc511cf..4db8437 100644 --- a/frontend/src/pages/ClientsPage.tsx +++ b/frontend/src/pages/ClientsPage.tsx @@ -12,6 +12,7 @@ import type { UpdateClientInput, ClientTargetWithBalance, CreateCorrectionInput, + CreateClientTargetInput, } from '@/types'; // Convert a value like "2026-W07" to the Monday date "2026-02-16" @@ -42,6 +43,16 @@ function mondayToWeekInput(dateStr: string): string { return `${year}-W${week.toString().padStart(2, '0')}`; } +// Convert a YYYY-MM-DD that is the 1st of a month to "YYYY-MM" for month input +function dateToMonthInput(dateStr: string): string { + return dateStr.substring(0, 7); +} + +// Convert "YYYY-MM" to "YYYY-MM-01" +function monthInputToDate(monthValue: string): string { + return `${monthValue}-01`; +} + function balanceLabel(seconds: number): { text: string; color: string } { if (seconds === 0) return { text: '±0', color: 'text-gray-500' }; const abs = Math.abs(seconds); @@ -50,6 +61,9 @@ function balanceLabel(seconds: number): { text: string; color: string } { return { text, color }; } +const WEEKDAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const WEEKDAY_FULL_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + // Inline target panel shown inside each client card function ClientTargetPanel({ target, @@ -58,7 +72,7 @@ function ClientTargetPanel({ }: { client: Client; target: ClientTargetWithBalance | undefined; - onCreated: (weeklyHours: number, startDate: string) => Promise; + onCreated: (input: CreateClientTargetInput) => Promise; onDeleted: () => Promise; }) { const { addCorrection, deleteCorrection, updateTarget } = useClientTargets(); @@ -68,8 +82,10 @@ function ClientTargetPanel({ const [editing, setEditing] = useState(false); // Create/edit form state + const [formPeriodType, setFormPeriodType] = useState<'weekly' | 'monthly'>('weekly'); const [formHours, setFormHours] = useState(''); - const [formWeek, setFormWeek] = useState(''); + const [formStartDate, setFormStartDate] = useState(''); + const [formWorkDays, setFormWorkDays] = useState([1, 2, 3, 4, 5]); const [formError, setFormError] = useState(null); const [formSaving, setFormSaving] = useState(false); @@ -82,12 +98,16 @@ function ClientTargetPanel({ 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])); + const mondayStr = monday.toISOString().split('T')[0]; + + setFormPeriodType('weekly'); + setFormHours(''); + setFormStartDate(mondayStr); + setFormWorkDays([1, 2, 3, 4, 5]); setFormError(null); setEditing(false); setShowForm(true); @@ -95,8 +115,10 @@ function ClientTargetPanel({ const openEdit = () => { if (!target) return; - setFormHours(String(target.weeklyHours)); - setFormWeek(mondayToWeekInput(target.startDate)); + setFormPeriodType(target.periodType); + setFormHours(String(target.targetHours)); + setFormStartDate(target.startDate); + setFormWorkDays(target.workDays); setFormError(null); setEditing(true); setShowForm(true); @@ -105,22 +127,44 @@ function ClientTargetPanel({ 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'); + const maxHours = formPeriodType === 'weekly' ? 168 : 744; + if (isNaN(hours) || hours <= 0 || hours > maxHours) { + setFormError(`Hours must be between 0 and ${maxHours}`); return; } - if (!formWeek) { - setFormError('Please select a start week'); + + if (!formStartDate) { + setFormError('Please select a start date'); return; } - const startDate = weekInputToMonday(formWeek); + + if (formWorkDays.length === 0) { + setFormError('Please select at least one working day'); + return; + } + setFormSaving(true); try { if (editing && target) { - await updateTarget.mutateAsync({ id: target.id, input: { weeklyHours: hours, startDate } }); + await updateTarget.mutateAsync({ + id: target.id, + input: { + periodType: formPeriodType, + targetHours: hours, + startDate: formStartDate, + workDays: formWorkDays, + }, + }); } else { - await onCreated(hours, startDate); + await onCreated({ + clientId: target?.clientId || '', + periodType: formPeriodType, + targetHours: hours, + startDate: formStartDate, + workDays: formWorkDays, + }); } setShowForm(false); } catch (err) { @@ -177,6 +221,12 @@ function ClientTargetPanel({ } }; + const toggleWorkDay = (day: number) => { + setFormWorkDays(prev => + prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day].sort() + ); + }; + if (!target && !showForm) { return (
@@ -185,7 +235,7 @@ function ClientTargetPanel({ className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 font-medium" > - Set weekly target + Set target
); @@ -195,36 +245,99 @@ function ClientTargetPanel({ return (

- {editing ? 'Edit target' : 'Set weekly target'} + {editing ? 'Edit target' : 'Set 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 - /> + + {/* Period Type Selector */} +
+ +
+ +
+ + {/* Hours Input */} +
+ + setFormHours(e.target.value)} + className="input text-sm py-1 w-full" + placeholder={formPeriodType === 'weekly' ? 'e.g. 40' : 'e.g. 160'} + min="0.5" + max={formPeriodType === 'weekly' ? '168' : '744'} + step="0.5" + required + /> +
+ + {/* Start Date Input */} +
+ + {formPeriodType === 'weekly' ? ( + setFormStartDate(weekInputToMonday(e.target.value))} + className="input text-sm py-1 w-full" + required + /> + ) : ( + setFormStartDate(monthInputToDate(e.target.value))} + className="input text-sm py-1 w-full" + required + /> + )} +
+ + {/* Work Days Selector */} +
+ +
+ {WEEKDAY_FULL_NAMES.map((name, idx) => ( + + ))} +
+
+ + {/* Form Actions */}