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:
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user