adds targets

This commit is contained in:
simon.franken
2026-02-18 14:27:44 +01:00
parent a352318e8a
commit 4cce62934e
12 changed files with 1198 additions and 32 deletions

View File

@@ -0,0 +1,41 @@
import apiClient from './client';
import type {
ClientTargetWithBalance,
CreateClientTargetInput,
UpdateClientTargetInput,
CreateCorrectionInput,
BalanceCorrection,
} from '@/types';
export const clientTargetsApi = {
getAll: async (): Promise<ClientTargetWithBalance[]> => {
const { data } = await apiClient.get<ClientTargetWithBalance[]>('/client-targets');
return data;
},
create: async (input: CreateClientTargetInput): Promise<ClientTargetWithBalance> => {
const { data } = await apiClient.post<ClientTargetWithBalance>('/client-targets', input);
return data;
},
update: async (id: string, input: UpdateClientTargetInput): Promise<ClientTargetWithBalance> => {
const { data } = await apiClient.put<ClientTargetWithBalance>(`/client-targets/${id}`, input);
return data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/client-targets/${id}`);
},
addCorrection: async (targetId: string, input: CreateCorrectionInput): Promise<BalanceCorrection> => {
const { data } = await apiClient.post<BalanceCorrection>(
`/client-targets/${targetId}/corrections`,
input,
);
return data;
},
deleteCorrection: async (targetId: string, correctionId: string): Promise<void> => {
await apiClient.delete(`/client-targets/${targetId}/corrections/${correctionId}`);
},
};

View File

@@ -0,0 +1,65 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { clientTargetsApi } from '@/api/clientTargets';
import type {
CreateClientTargetInput,
UpdateClientTargetInput,
CreateCorrectionInput,
} from '@/types';
export function useClientTargets() {
const queryClient = useQueryClient();
const { data: targets, isLoading, error } = useQuery({
queryKey: ['clientTargets'],
queryFn: clientTargetsApi.getAll,
});
const createTarget = useMutation({
mutationFn: (input: CreateClientTargetInput) => clientTargetsApi.create(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clientTargets'] });
},
});
const updateTarget = useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateClientTargetInput }) =>
clientTargetsApi.update(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clientTargets'] });
},
});
const deleteTarget = useMutation({
mutationFn: (id: string) => clientTargetsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clientTargets'] });
},
});
const addCorrection = useMutation({
mutationFn: ({ targetId, input }: { targetId: string; input: CreateCorrectionInput }) =>
clientTargetsApi.addCorrection(targetId, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clientTargets'] });
},
});
const deleteCorrection = useMutation({
mutationFn: ({ targetId, correctionId }: { targetId: string; correctionId: string }) =>
clientTargetsApi.deleteCorrection(targetId, correctionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clientTargets'] });
},
});
return {
targets,
isLoading,
error,
createTarget,
updateTarget,
deleteTarget,
addCorrection,
deleteCorrection,
};
}

View File

@@ -1,12 +1,393 @@
import { useState } from 'react';
import { Plus, Edit2, Trash2, Building2 } from 'lucide-react';
import { Plus, Edit2, Trash2, Building2, Target, ChevronDown, ChevronUp, X } from 'lucide-react';
import { useClients } from '@/hooks/useClients';
import { useClientTargets } from '@/hooks/useClientTargets';
import { Modal } from '@/components/Modal';
import { Spinner } from '@/components/Spinner';
import type { Client, CreateClientInput, UpdateClientInput } from '@/types';
import { formatDurationHoursMinutes } from '@/utils/dateUtils';
import type {
Client,
CreateClientInput,
UpdateClientInput,
ClientTargetWithBalance,
CreateCorrectionInput,
} from '@/types';
// Convert a <input type="week"> value like "2026-W07" to the Monday date "2026-02-16"
function weekInputToMonday(weekValue: string): string {
const [yearStr, weekStr] = weekValue.split('-W');
const year = parseInt(yearStr, 10);
const week = parseInt(weekStr, 10);
// ISO week 1 is the week containing the first Thursday of January
const jan4 = new Date(Date.UTC(year, 0, 4));
const jan4Day = jan4.getUTCDay() || 7; // Mon=1..Sun=7
const monday = new Date(jan4);
monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7);
return monday.toISOString().split('T')[0];
}
// Convert a YYYY-MM-DD Monday to "YYYY-Www" for the week input
function mondayToWeekInput(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00Z');
// ISO week number calculation
const jan4 = new Date(Date.UTC(date.getUTCFullYear(), 0, 4));
const jan4Day = jan4.getUTCDay() || 7;
const firstMonday = new Date(jan4);
firstMonday.setUTCDate(jan4.getUTCDate() - jan4Day + 1);
const diff = date.getTime() - firstMonday.getTime();
const week = Math.floor(diff / (7 * 24 * 3600 * 1000)) + 1;
// Handle year boundary: if week > 52 we might be in week 1 of next year
const year = date.getUTCFullYear();
return `${year}-W${week.toString().padStart(2, '0')}`;
}
function balanceLabel(seconds: number): { text: string; color: string } {
if (seconds === 0) return { text: '±0', color: 'text-gray-500' };
const abs = Math.abs(seconds);
const text = (seconds > 0 ? '+' : '') + formatDurationHoursMinutes(abs);
const color = seconds > 0 ? 'text-green-600' : 'text-red-600';
return { text, color };
}
// Inline target panel shown inside each client card
function ClientTargetPanel({
target,
onCreated,
onDeleted,
}: {
client: Client;
target: ClientTargetWithBalance | undefined;
onCreated: (weeklyHours: number, startDate: string) => Promise<void>;
onDeleted: () => Promise<void>;
}) {
const { addCorrection, deleteCorrection, updateTarget } = useClientTargets();
const [expanded, setExpanded] = useState(false);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState(false);
// Create/edit form state
const [formHours, setFormHours] = useState('');
const [formWeek, setFormWeek] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [formSaving, setFormSaving] = useState(false);
// Correction form state
const [showCorrectionForm, setShowCorrectionForm] = useState(false);
const [corrDate, setCorrDate] = useState('');
const [corrHours, setCorrHours] = useState('');
const [corrDesc, setCorrDesc] = useState('');
const [corrError, setCorrError] = useState<string | null>(null);
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]));
setFormError(null);
setEditing(false);
setShowForm(true);
};
const openEdit = () => {
if (!target) return;
setFormHours(String(target.weeklyHours));
setFormWeek(mondayToWeekInput(target.startDate));
setFormError(null);
setEditing(true);
setShowForm(true);
};
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');
return;
}
if (!formWeek) {
setFormError('Please select a start week');
return;
}
const startDate = weekInputToMonday(formWeek);
setFormSaving(true);
try {
if (editing && target) {
await updateTarget.mutateAsync({ id: target.id, input: { weeklyHours: hours, startDate } });
} else {
await onCreated(hours, startDate);
}
setShowForm(false);
} catch (err) {
setFormError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setFormSaving(false);
}
};
const handleDelete = async () => {
if (!confirm('Delete this target? All corrections will also be deleted.')) return;
try {
await onDeleted();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete');
}
};
const handleAddCorrection = async (e: React.FormEvent) => {
e.preventDefault();
setCorrError(null);
if (!target) return;
const hours = parseFloat(corrHours);
if (isNaN(hours) || hours < -24 || hours > 24) {
setCorrError('Hours must be between -24 and 24');
return;
}
if (!corrDate) {
setCorrError('Please select a date');
return;
}
setCorrSaving(true);
try {
const input: CreateCorrectionInput = { date: corrDate, hours, description: corrDesc || undefined };
await addCorrection.mutateAsync({ targetId: target.id, input });
setCorrDate('');
setCorrHours('');
setCorrDesc('');
setShowCorrectionForm(false);
} catch (err) {
setCorrError(err instanceof Error ? err.message : 'Failed to add correction');
} finally {
setCorrSaving(false);
}
};
const handleDeleteCorrection = async (correctionId: string) => {
if (!target) return;
try {
await deleteCorrection.mutateAsync({ targetId: target.id, correctionId });
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete correction');
}
};
if (!target && !showForm) {
return (
<div className="mt-3 pt-3 border-t border-gray-100">
<button
onClick={openCreate}
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
</button>
</div>
);
}
if (showForm) {
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'}
</p>
<form onSubmit={handleFormSubmit} className="space-y-2">
{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
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setShowForm(false)}
className="btn-secondary text-xs py-1 px-3"
>
Cancel
</button>
<button
type="submit"
disabled={formSaving}
className="btn-primary text-xs py-1 px-3"
>
{formSaving ? 'Saving...' : editing ? 'Save' : 'Set target'}
</button>
</div>
</form>
</div>
);
}
// Target exists — show summary + expandable details
const balance = balanceLabel(target!.totalBalanceSeconds);
return (
<div className="mt-3 pt-3 border-t border-gray-100">
{/* Target summary row */}
<div className="flex items-center justify-between">
<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>
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={openEdit}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Edit target"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={handleDelete}
className="p-1 text-gray-400 hover:text-red-600 rounded"
title="Delete target"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setExpanded(v => !v)}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Show corrections"
>
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
</div>
</div>
{/* Expanded: corrections list + add form */}
{expanded && (
<div className="mt-2 space-y-1.5">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Corrections</p>
{target!.corrections.length === 0 && !showCorrectionForm && (
<p className="text-xs text-gray-400">No corrections yet</p>
)}
{target!.corrections.map(c => (
<div key={c.id} className="flex items-center justify-between bg-gray-50 rounded px-2 py-1">
<div className="text-xs text-gray-700">
<span className="font-medium">{c.date}</span>
{c.description && <span className="text-gray-500 ml-1"> {c.description}</span>}
</div>
<div className="flex items-center gap-1.5">
<span className={`text-xs font-semibold ${c.hours >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{c.hours >= 0 ? '+' : ''}{c.hours}h
</span>
<button
onClick={() => handleDeleteCorrection(c.id)}
className="text-gray-300 hover:text-red-500"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
))}
{showCorrectionForm ? (
<form onSubmit={handleAddCorrection} className="space-y-1.5 pt-1">
{corrError && <p className="text-xs text-red-600">{corrError}</p>}
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Date</label>
<input
type="date"
value={corrDate}
onChange={e => setCorrDate(e.target.value)}
className="input text-xs py-1"
required
/>
</div>
<div className="w-24">
<label className="block text-xs text-gray-500 mb-0.5">Hours</label>
<input
type="number"
value={corrHours}
onChange={e => setCorrHours(e.target.value)}
className="input text-xs py-1"
placeholder="+8 / -4"
min="-24"
max="24"
step="0.5"
required
/>
</div>
</div>
<div>
<label className="block text-xs text-gray-500 mb-0.5">Description (optional)</label>
<input
type="text"
value={corrDesc}
onChange={e => setCorrDesc(e.target.value)}
className="input text-xs py-1"
placeholder="e.g. Public holiday"
maxLength={255}
/>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setShowCorrectionForm(false)}
className="btn-secondary text-xs py-1 px-3"
>
Cancel
</button>
<button
type="submit"
disabled={corrSaving}
className="btn-primary text-xs py-1 px-3"
>
{corrSaving ? 'Saving...' : 'Add'}
</button>
</div>
</form>
) : (
<button
onClick={() => setShowCorrectionForm(true)}
className="flex items-center gap-1 text-xs text-primary-600 hover:text-primary-700 font-medium pt-0.5"
>
<Plus className="h-3.5 w-3.5" />
Add correction
</button>
)}
</div>
)}
</div>
);
}
export function ClientsPage() {
const { clients, isLoading, createClient, updateClient, deleteClient } = useClients();
const { targets, createTarget, deleteTarget } = useClientTargets();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingClient, setEditingClient] = useState<Client | null>(null);
const [formData, setFormData] = useState<CreateClientInput>({ name: '', description: '' });
@@ -108,36 +489,50 @@ export function ClientsPage() {
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{clients?.map((client) => (
<div key={client.id} className="card">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-medium text-gray-900 truncate">
{client.name}
</h3>
{client.description && (
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
{client.description}
</p>
)}
</div>
<div className="flex space-x-2 ml-4">
<button
onClick={() => handleOpenModal(client)}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
>
<Edit2 className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(client)}
className="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
{clients?.map((client) => {
const target = targets?.find(t => t.clientId === client.id);
return (
<div key={client.id} className="card">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-medium text-gray-900 truncate">
{client.name}
</h3>
{client.description && (
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
{client.description}
</p>
)}
</div>
<div className="flex space-x-2 ml-4">
<button
onClick={() => handleOpenModal(client)}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
>
<Edit2 className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(client)}
className="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<ClientTargetPanel
client={client}
target={target}
onCreated={async (weeklyHours, startDate) => {
await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate });
}}
onDeleted={async () => {
if (target) await deleteTarget.mutateAsync(target.id);
}}
/>
</div>
</div>
))}
);
})}
</div>
)}
@@ -202,4 +597,4 @@ export function ClientsPage() {
)}
</div>
);
}
}

View File

@@ -1,6 +1,7 @@
import { Link } from "react-router-dom";
import { Clock, Calendar, Briefcase, TrendingUp } from "lucide-react";
import { Clock, Calendar, Briefcase, TrendingUp, Target } from "lucide-react";
import { useTimeEntries } from "@/hooks/useTimeEntries";
import { useClientTargets } from "@/hooks/useClientTargets";
import { ProjectColorDot } from "@/components/ProjectColorDot";
import { StatCard } from "@/components/StatCard";
import {
@@ -24,11 +25,15 @@ export function DashboardPage() {
limit: 10,
});
const { targets } = useClientTargets();
const totalTodaySeconds =
todayEntries?.entries.reduce((total, entry) => {
return total + calculateDuration(entry.startTime, entry.endTime);
}, 0) || 0;
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? [];
return (
<div className="space-y-6">
{/* Page Header */}
@@ -71,6 +76,61 @@ export function DashboardPage() {
/>
</div>
{/* 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>
</div>
<div className="space-y-3">
{targetsWithData.map(target => {
const balance = target.totalBalanceSeconds;
const absBalance = Math.abs(balance);
const isOver = balance > 0;
const isEven = balance === 0;
const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds);
const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds);
return (
<div
key={target.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<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}
</p>
</div>
<div className="text-right">
<p
className={`text-sm font-bold ${
isEven
? 'text-gray-500'
: isOver
? 'text-green-600'
: 'text-red-600'
}`}
>
{isEven
? '±0'
: (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)}
</p>
<p className="text-xs text-gray-400">running balance</p>
</div>
</div>
);
})}
</div>
<p className="mt-3 text-xs text-gray-400">
<Link to="/clients" className="text-primary-600 hover:text-primary-700">
Manage targets
</Link>
</p>
</div>
)}
{/* Recent Activity */}
<div className="card">
<div className="flex items-center justify-between mb-4">

View File

@@ -138,3 +138,53 @@ export interface UpdateTimeEntryInput {
description?: string;
projectId?: string;
}
export interface BalanceCorrection {
id: string;
date: string; // YYYY-MM-DD
hours: number;
description: string | null;
createdAt: string;
}
export interface WeekBalance {
weekStart: string; // YYYY-MM-DD (Monday)
weekEnd: string; // YYYY-MM-DD (Sunday)
trackedSeconds: number;
targetSeconds: number;
correctionHours: number;
balanceSeconds: number;
}
export interface ClientTargetWithBalance {
id: string;
clientId: string;
clientName: string;
userId: string;
weeklyHours: number;
startDate: string; // YYYY-MM-DD
createdAt: string;
updatedAt: string;
corrections: BalanceCorrection[];
totalBalanceSeconds: number;
currentWeekTrackedSeconds: number;
currentWeekTargetSeconds: number;
weeks: WeekBalance[];
}
export interface CreateClientTargetInput {
clientId: string;
weeklyHours: number;
startDate: string; // YYYY-MM-DD
}
export interface UpdateClientTargetInput {
weeklyHours?: number;
startDate?: string;
}
export interface CreateCorrectionInput {
date: string; // YYYY-MM-DD
hours: number;
description?: string;
}