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