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 {
|
model ClientTarget {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
weeklyHours Float @map("weekly_hours")
|
targetHours Float @map("target_hours")
|
||||||
startDate DateTime @map("start_date") @db.Date // Always a Monday
|
periodType String @default("weekly") @map("period_type") @db.VarChar(10) // 'weekly' | 'monthly'
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
workDays Int[] @default([1, 2, 3, 4, 5]) @map("work_days") // ISO weekday numbers (1=Mon, 7=Sun)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
startDate DateTime @map("start_date") @db.Date // Monday for weekly, 1st of month for monthly
|
||||||
deletedAt DateTime? @map("deleted_at")
|
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)
|
userId String @map("user_id") @db.VarChar(255)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -75,12 +75,16 @@ export const StopTimerSchema = z.object({
|
|||||||
|
|
||||||
export const CreateClientTargetSchema = z.object({
|
export const CreateClientTargetSchema = z.object({
|
||||||
clientId: z.string().uuid(),
|
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'),
|
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({
|
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(),
|
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;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the Sunday (end of week) for a given Monday
|
// Returns the first day of the month for a given date
|
||||||
function getSundayOfWeek(monday: Date): Date {
|
function getFirstOfMonth(date: Date): Date {
|
||||||
const d = new Date(monday);
|
const d = new Date(date);
|
||||||
d.setUTCDate(d.getUTCDate() + 6);
|
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);
|
d.setUTCHours(23, 59, 59, 999);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
@@ -34,13 +42,56 @@ function getWeekMondays(startDate: Date): Date[] {
|
|||||||
return mondays;
|
return mondays;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeekBalance {
|
// Returns all month starts from startDate up to and including the current month
|
||||||
weekStart: string; // ISO date string (Monday)
|
function getMonthStarts(startDate: Date): Date[] {
|
||||||
weekEnd: string; // ISO date string (Sunday)
|
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;
|
trackedSeconds: number;
|
||||||
targetSeconds: number;
|
targetSeconds: number;
|
||||||
correctionHours: number;
|
balanceSeconds: number;
|
||||||
balanceSeconds: number; // positive = overtime, negative = undertime
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientTargetWithBalance {
|
export interface ClientTargetWithBalance {
|
||||||
@@ -48,7 +99,9 @@ export interface ClientTargetWithBalance {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
weeklyHours: number;
|
periodType: 'weekly' | 'monthly';
|
||||||
|
targetHours: number;
|
||||||
|
workDays: number[];
|
||||||
startDate: string;
|
startDate: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -59,10 +112,10 @@ export interface ClientTargetWithBalance {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}>;
|
}>;
|
||||||
totalBalanceSeconds: number; // running total across all weeks
|
totalBalanceSeconds: number;
|
||||||
currentWeekTrackedSeconds: number;
|
currentPeriodTrackedSeconds: number;
|
||||||
currentWeekTargetSeconds: number;
|
currentPeriodTargetSeconds: number;
|
||||||
weeks: WeekBalance[];
|
days: DayBalance[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ClientTargetService {
|
export class ClientTargetService {
|
||||||
@@ -90,11 +143,30 @@ export class ClientTargetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(userId: string, data: CreateClientTargetInput): Promise<ClientTargetWithBalance> {
|
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 startDate = new Date(data.startDate + 'T00:00:00Z');
|
||||||
const dayOfWeek = startDate.getUTCDay();
|
if (isNaN(startDate.getTime())) {
|
||||||
if (dayOfWeek !== 1) {
|
throw new BadRequestError('Invalid startDate format');
|
||||||
throw new BadRequestError('startDate must be a Monday');
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// Reactivate the soft-deleted target with the new settings
|
||||||
const reactivated = await prisma.clientTarget.update({
|
const reactivated = await prisma.clientTarget.update({
|
||||||
where: { id: existing.id },
|
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: {
|
include: {
|
||||||
client: { select: { id: true, name: true } },
|
client: { select: { id: true, name: true } },
|
||||||
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||||
@@ -125,7 +203,9 @@ export class ClientTargetService {
|
|||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
clientId: data.clientId,
|
clientId: data.clientId,
|
||||||
weeklyHours: data.weeklyHours,
|
targetHours: data.targetHours,
|
||||||
|
periodType: data.periodType,
|
||||||
|
workDays: data.workDays,
|
||||||
startDate,
|
startDate,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -141,16 +221,46 @@ export class ClientTargetService {
|
|||||||
const existing = await this.findById(id, userId);
|
const existing = await this.findById(id, userId);
|
||||||
if (!existing) throw new NotFoundError('Client target not found');
|
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) {
|
if (data.targetHours !== undefined) {
|
||||||
updateData.weeklyHours = data.weeklyHours;
|
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) {
|
if (data.startDate !== undefined) {
|
||||||
const startDate = new Date(data.startDate + 'T00:00:00Z');
|
const startDate = new Date(data.startDate + 'T00:00:00Z');
|
||||||
if (startDate.getUTCDay() !== 1) {
|
if (isNaN(startDate.getTime())) {
|
||||||
throw new BadRequestError('startDate must be a Monday');
|
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;
|
updateData.startDate = startDate;
|
||||||
}
|
}
|
||||||
@@ -217,89 +327,154 @@ export class ClientTargetService {
|
|||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
weeklyHours: number;
|
targetHours: number;
|
||||||
|
periodType: string;
|
||||||
|
workDays: number[];
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
client: { id: string; name: string };
|
client: { id: string; name: string };
|
||||||
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
|
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
|
||||||
}): Promise<ClientTargetWithBalance> {
|
}): 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);
|
return this.emptyBalance(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all tracked time for this user on this client's projects in one query
|
// Fetch all tracked time for this user on this client's projects
|
||||||
// covering startDate to end of current week
|
const periodStart = periods[0].start;
|
||||||
const periodStart = mondays[0];
|
const periodEnd = periods[periods.length - 1].end;
|
||||||
const periodEnd = getSundayOfWeek(mondays[mondays.length - 1]);
|
|
||||||
|
|
||||||
type TrackedRow = { week_start: Date; tracked_seconds: bigint };
|
type TrackedRow = { date: string; tracked_seconds: bigint };
|
||||||
|
|
||||||
const rows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
const rows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
||||||
SELECT
|
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
|
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS tracked_seconds
|
||||||
FROM time_entries te
|
FROM time_entries te
|
||||||
JOIN projects p ON p.id = te.project_id
|
JOIN projects p ON p.id = te.project_id
|
||||||
WHERE te.user_id = ${target.userId}
|
WHERE te.user_id = ${target.userId}
|
||||||
AND p.client_id = ${target.clientId}
|
AND p.client_id = ${target.clientId}
|
||||||
AND te.start_time >= ${periodStart}
|
AND DATE(te.start_time AT TIME ZONE 'UTC') >= ${periodStart}::date
|
||||||
AND te.start_time <= ${periodEnd}
|
AND DATE(te.start_time AT TIME ZONE 'UTC') <= ${periodEnd}::date
|
||||||
AND te.deleted_at IS NULL
|
AND te.deleted_at IS NULL
|
||||||
AND p.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)
|
// Index tracked seconds by date (ISO string)
|
||||||
const trackedByWeek = new Map<string, number>();
|
const trackedByDate = new Map<string, number>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
// DATE_TRUNC with 'week' gives Monday in Postgres (ISO week)
|
trackedByDate.set(row.date, Number(row.tracked_seconds));
|
||||||
const monday = getMondayOfWeek(new Date(row.week_start));
|
|
||||||
const key = monday.toISOString().split('T')[0];
|
|
||||||
trackedByWeek.set(key, Number(row.tracked_seconds));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index corrections by week
|
// Index corrections by period
|
||||||
const correctionsByWeek = new Map<string, number>();
|
const correctionsByPeriod = new Map<number, number>(); // period index -> total hours
|
||||||
for (const c of target.corrections) {
|
for (const c of target.corrections) {
|
||||||
const monday = getMondayOfWeek(new Date(c.date));
|
const correctionDate = new Date(c.date);
|
||||||
const key = monday.toISOString().split('T')[0];
|
correctionDate.setUTCHours(0, 0, 0, 0);
|
||||||
correctionsByWeek.set(key, (correctionsByWeek.get(key) ?? 0) + c.hours);
|
|
||||||
|
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;
|
// Compute daily balances
|
||||||
const weeks: WeekBalance[] = [];
|
const days: DayBalance[] = [];
|
||||||
let totalBalanceSeconds = 0;
|
let totalBalanceSeconds = 0;
|
||||||
|
let currentPeriodTrackedSeconds = 0;
|
||||||
|
let currentPeriodTargetSeconds = 0;
|
||||||
|
|
||||||
for (const monday of mondays) {
|
const today = new Date();
|
||||||
const key = monday.toISOString().split('T')[0];
|
today.setUTCHours(0, 0, 0, 0);
|
||||||
const sunday = getSundayOfWeek(monday);
|
|
||||||
const trackedSeconds = trackedByWeek.get(key) ?? 0;
|
|
||||||
const correctionHours = correctionsByWeek.get(key) ?? 0;
|
|
||||||
const effectiveTargetSeconds = targetSecondsPerWeek - correctionHours * 3600;
|
|
||||||
const balanceSeconds = trackedSeconds - effectiveTargetSeconds;
|
|
||||||
totalBalanceSeconds += balanceSeconds;
|
|
||||||
|
|
||||||
weeks.push({
|
for (let periodIdx = 0; periodIdx < periods.length; periodIdx++) {
|
||||||
weekStart: key,
|
const period = periods[periodIdx];
|
||||||
weekEnd: sunday.toISOString().split('T')[0],
|
|
||||||
trackedSeconds,
|
// Get working days for this period up to today
|
||||||
targetSeconds: effectiveTargetSeconds,
|
const workingDaysInPeriod = getWorkingDaysUpToToday(
|
||||||
correctionHours,
|
period.start,
|
||||||
balanceSeconds,
|
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 {
|
return {
|
||||||
id: target.id,
|
id: target.id,
|
||||||
clientId: target.clientId,
|
clientId: target.clientId,
|
||||||
clientName: target.client.name,
|
clientName: target.client.name,
|
||||||
userId: target.userId,
|
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],
|
startDate: target.startDate.toISOString().split('T')[0],
|
||||||
createdAt: target.createdAt.toISOString(),
|
createdAt: target.createdAt.toISOString(),
|
||||||
updatedAt: target.updatedAt.toISOString(),
|
updatedAt: target.updatedAt.toISOString(),
|
||||||
@@ -311,9 +486,9 @@ export class ClientTargetService {
|
|||||||
createdAt: c.createdAt.toISOString(),
|
createdAt: c.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
totalBalanceSeconds,
|
totalBalanceSeconds,
|
||||||
currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0,
|
currentPeriodTrackedSeconds,
|
||||||
currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek,
|
currentPeriodTargetSeconds,
|
||||||
weeks,
|
days,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +496,9 @@ export class ClientTargetService {
|
|||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
weeklyHours: number;
|
targetHours: number;
|
||||||
|
periodType: string;
|
||||||
|
workDays: number[];
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -333,15 +510,17 @@ export class ClientTargetService {
|
|||||||
clientId: target.clientId,
|
clientId: target.clientId,
|
||||||
clientName: target.client.name,
|
clientName: target.client.name,
|
||||||
userId: target.userId,
|
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],
|
startDate: target.startDate.toISOString().split('T')[0],
|
||||||
createdAt: target.createdAt.toISOString(),
|
createdAt: target.createdAt.toISOString(),
|
||||||
updatedAt: target.updatedAt.toISOString(),
|
updatedAt: target.updatedAt.toISOString(),
|
||||||
corrections: [],
|
corrections: [],
|
||||||
totalBalanceSeconds: 0,
|
totalBalanceSeconds: 0,
|
||||||
currentWeekTrackedSeconds: 0,
|
currentPeriodTrackedSeconds: 0,
|
||||||
currentWeekTargetSeconds: target.weeklyHours * 3600,
|
currentPeriodTargetSeconds: target.targetHours * 3600,
|
||||||
weeks: [],
|
days: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,13 +82,17 @@ export interface StopTimerInput {
|
|||||||
|
|
||||||
export interface CreateClientTargetInput {
|
export interface CreateClientTargetInput {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
weeklyHours: number;
|
periodType: 'weekly' | 'monthly';
|
||||||
startDate: string; // YYYY-MM-DD, always a Monday
|
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 {
|
export interface UpdateClientTargetInput {
|
||||||
weeklyHours?: number;
|
periodType?: 'weekly' | 'monthly';
|
||||||
startDate?: string; // YYYY-MM-DD, always a Monday
|
targetHours?: number;
|
||||||
|
workDays?: number[];
|
||||||
|
startDate?: string; // YYYY-MM-DD
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCorrectionInput {
|
export interface CreateCorrectionInput {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
UpdateClientInput,
|
UpdateClientInput,
|
||||||
ClientTargetWithBalance,
|
ClientTargetWithBalance,
|
||||||
CreateCorrectionInput,
|
CreateCorrectionInput,
|
||||||
|
CreateClientTargetInput,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
// Convert a <input type="week"> value like "2026-W07" to the Monday date "2026-02-16"
|
// 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')}`;
|
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 } {
|
function balanceLabel(seconds: number): { text: string; color: string } {
|
||||||
if (seconds === 0) return { text: '±0', color: 'text-gray-500' };
|
if (seconds === 0) return { text: '±0', color: 'text-gray-500' };
|
||||||
const abs = Math.abs(seconds);
|
const abs = Math.abs(seconds);
|
||||||
@@ -50,6 +61,9 @@ function balanceLabel(seconds: number): { text: string; color: string } {
|
|||||||
return { text, color };
|
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
|
// Inline target panel shown inside each client card
|
||||||
function ClientTargetPanel({
|
function ClientTargetPanel({
|
||||||
target,
|
target,
|
||||||
@@ -58,7 +72,7 @@ function ClientTargetPanel({
|
|||||||
}: {
|
}: {
|
||||||
client: Client;
|
client: Client;
|
||||||
target: ClientTargetWithBalance | undefined;
|
target: ClientTargetWithBalance | undefined;
|
||||||
onCreated: (weeklyHours: number, startDate: string) => Promise<void>;
|
onCreated: (input: CreateClientTargetInput) => Promise<void>;
|
||||||
onDeleted: () => Promise<void>;
|
onDeleted: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const { addCorrection, deleteCorrection, updateTarget } = useClientTargets();
|
const { addCorrection, deleteCorrection, updateTarget } = useClientTargets();
|
||||||
@@ -68,8 +82,10 @@ function ClientTargetPanel({
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
// Create/edit form state
|
// Create/edit form state
|
||||||
|
const [formPeriodType, setFormPeriodType] = useState<'weekly' | 'monthly'>('weekly');
|
||||||
const [formHours, setFormHours] = useState('');
|
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 [formError, setFormError] = useState<string | null>(null);
|
||||||
const [formSaving, setFormSaving] = useState(false);
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
|
|
||||||
@@ -82,12 +98,16 @@ function ClientTargetPanel({
|
|||||||
const [corrSaving, setCorrSaving] = useState(false);
|
const [corrSaving, setCorrSaving] = useState(false);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setFormHours('');
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const day = today.getUTCDay() || 7;
|
const day = today.getUTCDay() || 7;
|
||||||
const monday = new Date(today);
|
const monday = new Date(today);
|
||||||
monday.setUTCDate(today.getUTCDate() - day + 1);
|
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);
|
setFormError(null);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
@@ -95,8 +115,10 @@ function ClientTargetPanel({
|
|||||||
|
|
||||||
const openEdit = () => {
|
const openEdit = () => {
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
setFormHours(String(target.weeklyHours));
|
setFormPeriodType(target.periodType);
|
||||||
setFormWeek(mondayToWeekInput(target.startDate));
|
setFormHours(String(target.targetHours));
|
||||||
|
setFormStartDate(target.startDate);
|
||||||
|
setFormWorkDays(target.workDays);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
@@ -105,22 +127,44 @@ function ClientTargetPanel({
|
|||||||
const handleFormSubmit = async (e: React.FormEvent) => {
|
const handleFormSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
||||||
const hours = parseFloat(formHours);
|
const hours = parseFloat(formHours);
|
||||||
if (isNaN(hours) || hours <= 0 || hours > 168) {
|
const maxHours = formPeriodType === 'weekly' ? 168 : 744;
|
||||||
setFormError('Weekly hours must be between 0 and 168');
|
if (isNaN(hours) || hours <= 0 || hours > maxHours) {
|
||||||
|
setFormError(`Hours must be between 0 and ${maxHours}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!formWeek) {
|
|
||||||
setFormError('Please select a start week');
|
if (!formStartDate) {
|
||||||
|
setFormError('Please select a start date');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const startDate = weekInputToMonday(formWeek);
|
|
||||||
|
if (formWorkDays.length === 0) {
|
||||||
|
setFormError('Please select at least one working day');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setFormSaving(true);
|
setFormSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editing && target) {
|
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 {
|
} else {
|
||||||
await onCreated(hours, startDate);
|
await onCreated({
|
||||||
|
clientId: target?.clientId || '',
|
||||||
|
periodType: formPeriodType,
|
||||||
|
targetHours: hours,
|
||||||
|
startDate: formStartDate,
|
||||||
|
workDays: formWorkDays,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
} catch (err) {
|
} 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) {
|
if (!target && !showForm) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
<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"
|
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" />
|
<Target className="h-3.5 w-3.5" />
|
||||||
Set weekly target
|
Set target
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -195,36 +245,99 @@ function ClientTargetPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||||
<p className="text-xs font-medium text-gray-700 mb-2">
|
<p className="text-xs font-medium text-gray-700 mb-2">
|
||||||
{editing ? 'Edit target' : 'Set weekly target'}
|
{editing ? 'Edit target' : 'Set target'}
|
||||||
</p>
|
</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>}
|
{formError && <p className="text-xs text-red-600">{formError}</p>}
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex-1">
|
{/* Period Type Selector */}
|
||||||
<label className="block text-xs text-gray-500 mb-0.5">Hours/week</label>
|
<div>
|
||||||
<input
|
<label className="block text-xs text-gray-500 mb-1">Period Type</label>
|
||||||
type="number"
|
<div className="flex gap-2">
|
||||||
value={formHours}
|
<label className="flex items-center gap-1.5 text-xs">
|
||||||
onChange={e => setFormHours(e.target.value)}
|
<input
|
||||||
className="input text-sm py-1"
|
type="radio"
|
||||||
placeholder="e.g. 40"
|
name="periodType"
|
||||||
min="0.5"
|
value="weekly"
|
||||||
max="168"
|
checked={formPeriodType === 'weekly'}
|
||||||
step="0.5"
|
onChange={() => setFormPeriodType('weekly')}
|
||||||
required
|
className="w-3 h-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
Weekly
|
||||||
<div className="flex-1">
|
</label>
|
||||||
<label className="block text-xs text-gray-500 mb-0.5">Starting week</label>
|
<label className="flex items-center gap-1.5 text-xs">
|
||||||
<input
|
<input
|
||||||
type="week"
|
type="radio"
|
||||||
value={formWeek}
|
name="periodType"
|
||||||
onChange={e => setFormWeek(e.target.value)}
|
value="monthly"
|
||||||
className="input text-sm py-1"
|
checked={formPeriodType === 'monthly'}
|
||||||
required
|
onChange={() => setFormPeriodType('monthly')}
|
||||||
/>
|
className="w-3 h-3"
|
||||||
|
/>
|
||||||
|
Monthly
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -248,6 +361,7 @@ function ClientTargetPanel({
|
|||||||
|
|
||||||
// Target exists — show summary + expandable details
|
// Target exists — show summary + expandable details
|
||||||
const balance = balanceLabel(target!.totalBalanceSeconds);
|
const balance = balanceLabel(target!.totalBalanceSeconds);
|
||||||
|
const workDayLabel = target!.workDays.map(d => WEEKDAY_NAMES[d - 1]).join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||||
@@ -256,8 +370,10 @@ function ClientTargetPanel({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Target className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
<Target className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||||
<span className="text-xs text-gray-600">
|
<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>
|
||||||
|
<span className="text-xs text-gray-500">({workDayLabel})</span>
|
||||||
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
|
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -324,7 +440,7 @@ function ClientTargetPanel({
|
|||||||
type="date"
|
type="date"
|
||||||
value={corrDate}
|
value={corrDate}
|
||||||
onChange={e => setCorrDate(e.target.value)}
|
onChange={e => setCorrDate(e.target.value)}
|
||||||
className="input text-xs py-1"
|
className="input text-xs py-1 w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +463,7 @@ function ClientTargetPanel({
|
|||||||
type="text"
|
type="text"
|
||||||
value={corrDesc}
|
value={corrDesc}
|
||||||
onChange={e => setCorrDesc(e.target.value)}
|
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"
|
placeholder="e.g. Public holiday"
|
||||||
maxLength={255}
|
maxLength={255}
|
||||||
/>
|
/>
|
||||||
@@ -531,8 +647,8 @@ export function ClientsPage() {
|
|||||||
<ClientTargetPanel
|
<ClientTargetPanel
|
||||||
client={client}
|
client={client}
|
||||||
target={target}
|
target={target}
|
||||||
onCreated={async (weeklyHours, startDate) => {
|
onCreated={async (input) => {
|
||||||
await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate });
|
await createTarget.mutateAsync({ ...input, clientId: client.id });
|
||||||
}}
|
}}
|
||||||
onDeleted={async () => {
|
onDeleted={async () => {
|
||||||
if (target) await deleteTarget.mutateAsync(target.id);
|
if (target) await deleteTarget.mutateAsync(target.id);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function DashboardPage() {
|
|||||||
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
|
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
|
||||||
}, 0) || 0;
|
}, 0) || 0;
|
||||||
|
|
||||||
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? [];
|
const targetsWithData = targets?.filter(t => t.days.length > 0) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -103,12 +103,12 @@ export function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overtime / Targets Widget */}
|
{/* Overtime / Targets Widget */}
|
||||||
{targetsWithData.length > 0 && (
|
{targetsWithData.length > 0 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<Target className="h-5 w-5 text-primary-600" />
|
<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>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{targetsWithData.map(target => {
|
{targetsWithData.map(target => {
|
||||||
@@ -116,8 +116,9 @@ export function DashboardPage() {
|
|||||||
const absBalance = Math.abs(balance);
|
const absBalance = Math.abs(balance);
|
||||||
const isOver = balance > 0;
|
const isOver = balance > 0;
|
||||||
const isEven = balance === 0;
|
const isEven = balance === 0;
|
||||||
const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds);
|
const currentPeriodTracked = formatDurationHoursMinutes(target.currentPeriodTrackedSeconds);
|
||||||
const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds);
|
const currentPeriodTarget = formatDurationHoursMinutes(target.currentPeriodTargetSeconds);
|
||||||
|
const periodLabel = target.periodType === 'weekly' ? 'week' : 'month';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -127,7 +128,7 @@ export function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">{target.clientName}</p>
|
<p className="text-sm font-medium text-gray-900">{target.clientName}</p>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
This week: {currentWeekTracked} / {currentWeekTarget}
|
This {periodLabel}: {currentPeriodTracked} / {currentPeriodTarget}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
|
|||||||
@@ -155,6 +155,13 @@ export interface BalanceCorrection {
|
|||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DayBalance {
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
trackedSeconds: number;
|
||||||
|
targetSeconds: number;
|
||||||
|
balanceSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WeekBalance {
|
export interface WeekBalance {
|
||||||
weekStart: string; // YYYY-MM-DD (Monday)
|
weekStart: string; // YYYY-MM-DD (Monday)
|
||||||
weekEnd: string; // YYYY-MM-DD (Sunday)
|
weekEnd: string; // YYYY-MM-DD (Sunday)
|
||||||
@@ -169,25 +176,31 @@ export interface ClientTargetWithBalance {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
weeklyHours: number;
|
periodType: 'weekly' | 'monthly';
|
||||||
|
targetHours: number;
|
||||||
|
workDays: number[];
|
||||||
startDate: string; // YYYY-MM-DD
|
startDate: string; // YYYY-MM-DD
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
corrections: BalanceCorrection[];
|
corrections: BalanceCorrection[];
|
||||||
totalBalanceSeconds: number;
|
totalBalanceSeconds: number;
|
||||||
currentWeekTrackedSeconds: number;
|
currentPeriodTrackedSeconds: number;
|
||||||
currentWeekTargetSeconds: number;
|
currentPeriodTargetSeconds: number;
|
||||||
weeks: WeekBalance[];
|
days: DayBalance[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateClientTargetInput {
|
export interface CreateClientTargetInput {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
weeklyHours: number;
|
periodType: 'weekly' | 'monthly';
|
||||||
|
targetHours: number;
|
||||||
|
workDays: number[];
|
||||||
startDate: string; // YYYY-MM-DD
|
startDate: string; // YYYY-MM-DD
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateClientTargetInput {
|
export interface UpdateClientTargetInput {
|
||||||
weeklyHours?: number;
|
periodType?: 'weekly' | 'monthly';
|
||||||
|
targetHours?: number;
|
||||||
|
workDays?: number[];
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user