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)
This commit is contained in:
2026-02-24 18:11:45 +01:00
parent 5b7b8e47cb
commit 4f23c1c653
8 changed files with 481 additions and 145 deletions

View File

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

View File

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

View File

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

View File

@@ -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<ClientTargetWithBalance> {
// 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<ClientTargetWithBalance> {
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<TrackedRow[]>(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<string, number>();
// Index tracked seconds by date (ISO string)
const trackedByDate = 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));
trackedByDate.set(row.date, Number(row.tracked_seconds));
}
// Index corrections by week
const correctionsByWeek = new Map<string, number>();
// Index corrections by period
const correctionsByPeriod = new Map<number, number>(); // 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: [],
};
}
}

View File

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

View File

@@ -12,6 +12,7 @@ import type {
UpdateClientInput,
ClientTargetWithBalance,
CreateCorrectionInput,
CreateClientTargetInput,
} from '@/types';
// Convert a <input type="week"> 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<void>;
onCreated: (input: CreateClientTargetInput) => Promise<void>;
onDeleted: () => Promise<void>;
}) {
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<number[]>([1, 2, 3, 4, 5]);
const [formError, setFormError] = useState<string | null>(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 (
<div className="mt-3 pt-3 border-t border-gray-100">
@@ -185,7 +235,7 @@ function ClientTargetPanel({
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
Set target
</button>
</div>
);
@@ -195,36 +245,99 @@ function ClientTargetPanel({
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'}
{editing ? 'Edit target' : 'Set target'}
</p>
<form onSubmit={handleFormSubmit} className="space-y-2">
<form onSubmit={handleFormSubmit} className="space-y-3">
{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
/>
{/* Period Type Selector */}
<div>
<label className="block text-xs text-gray-500 mb-1">Period Type</label>
<div className="flex gap-2">
<label className="flex items-center gap-1.5 text-xs">
<input
type="radio"
name="periodType"
value="weekly"
checked={formPeriodType === 'weekly'}
onChange={() => setFormPeriodType('weekly')}
className="w-3 h-3"
/>
Weekly
</label>
<label className="flex items-center gap-1.5 text-xs">
<input
type="radio"
name="periodType"
value="monthly"
checked={formPeriodType === 'monthly'}
onChange={() => setFormPeriodType('monthly')}
className="w-3 h-3"
/>
Monthly
</label>
</div>
</div>
{/* Hours Input */}
<div>
<label className="block text-xs text-gray-500 mb-0.5">
Hours / {formPeriodType === 'weekly' ? 'week' : 'month'}
</label>
<input
type="number"
value={formHours}
onChange={e => 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
/>
</div>
{/* Start Date Input */}
<div>
<label className="block text-xs text-gray-500 mb-0.5">Starting</label>
{formPeriodType === 'weekly' ? (
<input
type="week"
value={mondayToWeekInput(formStartDate)}
onChange={e => setFormStartDate(weekInputToMonday(e.target.value))}
className="input text-sm py-1 w-full"
required
/>
) : (
<input
type="month"
value={dateToMonthInput(formStartDate)}
onChange={e => setFormStartDate(monthInputToDate(e.target.value))}
className="input text-sm py-1 w-full"
required
/>
)}
</div>
{/* Work Days Selector */}
<div>
<label className="block text-xs text-gray-500 mb-1">Working Days</label>
<div className="grid grid-cols-7 gap-1">
{WEEKDAY_FULL_NAMES.map((name, idx) => (
<label key={idx + 1} className="flex items-center justify-center" title={name}>
<input
type="checkbox"
checked={formWorkDays.includes(idx + 1)}
onChange={() => toggleWorkDay(idx + 1)}
className="w-3 h-3"
/>
<span className="text-xs ml-1">{WEEKDAY_NAMES[idx]}</span>
</label>
))}
</div>
</div>
{/* Form Actions */}
<div className="flex gap-2 justify-end">
<button
type="button"
@@ -248,6 +361,7 @@ function ClientTargetPanel({
// Target exists — show summary + expandable details
const balance = balanceLabel(target!.totalBalanceSeconds);
const workDayLabel = target!.workDays.map(d => WEEKDAY_NAMES[d - 1]).join(' ');
return (
<div className="mt-3 pt-3 border-t border-gray-100">
@@ -256,8 +370,10 @@ function ClientTargetPanel({
<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 className="font-medium">{target!.targetHours}h</span>
/{target!.periodType === 'weekly' ? 'week' : 'month'}
</span>
<span className="text-xs text-gray-500">({workDayLabel})</span>
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
</div>
<div className="flex items-center gap-1">
@@ -324,7 +440,7 @@ function ClientTargetPanel({
type="date"
value={corrDate}
onChange={e => setCorrDate(e.target.value)}
className="input text-xs py-1"
className="input text-xs py-1 w-full"
required
/>
</div>
@@ -347,7 +463,7 @@ function ClientTargetPanel({
type="text"
value={corrDesc}
onChange={e => setCorrDesc(e.target.value)}
className="input text-xs py-1"
className="input text-xs py-1 w-full"
placeholder="e.g. Public holiday"
maxLength={255}
/>
@@ -531,8 +647,8 @@ export function ClientsPage() {
<ClientTargetPanel
client={client}
target={target}
onCreated={async (weeklyHours, startDate) => {
await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate });
onCreated={async (input) => {
await createTarget.mutateAsync({ ...input, clientId: client.id });
}}
onDeleted={async () => {
if (target) await deleteTarget.mutateAsync(target.id);

View File

@@ -59,7 +59,7 @@ export function DashboardPage() {
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
}, 0) || 0;
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? [];
const targetsWithData = targets?.filter(t => t.days.length > 0) ?? [];
return (
<div className="space-y-6">
@@ -103,12 +103,12 @@ export function DashboardPage() {
/>
</div>
{/* Overtime / Targets Widget */}
{/* 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>
<h2 className="text-lg font-semibold text-gray-900">Targets</h2>
</div>
<div className="space-y-3">
{targetsWithData.map(target => {
@@ -116,8 +116,9 @@ export function DashboardPage() {
const absBalance = Math.abs(balance);
const isOver = balance > 0;
const isEven = balance === 0;
const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds);
const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds);
const currentPeriodTracked = formatDurationHoursMinutes(target.currentPeriodTrackedSeconds);
const currentPeriodTarget = formatDurationHoursMinutes(target.currentPeriodTargetSeconds);
const periodLabel = target.periodType === 'weekly' ? 'week' : 'month';
return (
<div
@@ -127,7 +128,7 @@ export function DashboardPage() {
<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}
This {periodLabel}: {currentPeriodTracked} / {currentPeriodTarget}
</p>
</div>
<div className="text-right">

View File

@@ -155,6 +155,13 @@ export interface BalanceCorrection {
deletedAt: string | null;
}
export interface DayBalance {
date: string; // YYYY-MM-DD
trackedSeconds: number;
targetSeconds: number;
balanceSeconds: number;
}
export interface WeekBalance {
weekStart: string; // YYYY-MM-DD (Monday)
weekEnd: string; // YYYY-MM-DD (Sunday)
@@ -169,25 +176,31 @@ export interface ClientTargetWithBalance {
clientId: string;
clientName: string;
userId: string;
weeklyHours: number;
periodType: 'weekly' | 'monthly';
targetHours: number;
workDays: number[];
startDate: string; // YYYY-MM-DD
createdAt: string;
updatedAt: string;
corrections: BalanceCorrection[];
totalBalanceSeconds: number;
currentWeekTrackedSeconds: number;
currentWeekTargetSeconds: number;
weeks: WeekBalance[];
currentPeriodTrackedSeconds: number;
currentPeriodTargetSeconds: number;
days: DayBalance[];
}
export interface CreateClientTargetInput {
clientId: string;
weeklyHours: number;
periodType: 'weekly' | 'monthly';
targetHours: number;
workDays: number[];
startDate: string; // YYYY-MM-DD
}
export interface UpdateClientTargetInput {
weeklyHours?: number;
periodType?: 'weekly' | 'monthly';
targetHours?: number;
workDays?: number[];
startDate?: string;
}