adds targets
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "client_targets" (
|
||||
"id" TEXT NOT NULL,
|
||||
"weekly_hours" DOUBLE PRECISION NOT NULL,
|
||||
"start_date" DATE NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"user_id" VARCHAR(255) NOT NULL,
|
||||
"client_id" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "client_targets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "balance_corrections" (
|
||||
"id" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"hours" DOUBLE PRECISION NOT NULL,
|
||||
"description" VARCHAR(255),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"client_target_id" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "balance_corrections_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "client_targets_user_id_idx" ON "client_targets"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "client_targets_client_id_idx" ON "client_targets"("client_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "client_targets_user_id_client_id_key" ON "client_targets"("user_id", "client_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "balance_corrections_client_target_id_idx" ON "balance_corrections"("client_target_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "client_targets" ADD CONSTRAINT "client_targets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "client_targets" ADD CONSTRAINT "client_targets_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "balance_corrections" ADD CONSTRAINT "balance_corrections_client_target_id_fkey" FOREIGN KEY ("client_target_id") REFERENCES "client_targets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -18,6 +18,7 @@ model User {
|
||||
projects Project[]
|
||||
timeEntries TimeEntry[]
|
||||
ongoingTimer OngoingTimer?
|
||||
clientTargets ClientTarget[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -32,6 +33,7 @@ model Client {
|
||||
userId String @map("user_id") @db.VarChar(255)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
projects Project[]
|
||||
clientTargets ClientTarget[]
|
||||
|
||||
@@index([userId])
|
||||
@@map("clients")
|
||||
@@ -91,3 +93,38 @@ model OngoingTimer {
|
||||
@@index([userId])
|
||||
@@map("ongoing_timers")
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
userId String @map("user_id") @db.VarChar(255)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
clientId String @map("client_id")
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
corrections BalanceCorrection[]
|
||||
|
||||
@@unique([userId, clientId])
|
||||
@@index([userId])
|
||||
@@index([clientId])
|
||||
@@map("client_targets")
|
||||
}
|
||||
|
||||
model BalanceCorrection {
|
||||
id String @id @default(uuid())
|
||||
date DateTime @map("date") @db.Date
|
||||
hours Float
|
||||
description String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
clientTargetId String @map("client_target_id")
|
||||
clientTarget ClientTarget @relation(fields: [clientTargetId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([clientTargetId])
|
||||
@@map("balance_corrections")
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import clientRoutes from "./routes/client.routes";
|
||||
import projectRoutes from "./routes/project.routes";
|
||||
import timeEntryRoutes from "./routes/timeEntry.routes";
|
||||
import timerRoutes from "./routes/timer.routes";
|
||||
import clientTargetRoutes from "./routes/clientTarget.routes";
|
||||
|
||||
async function main() {
|
||||
// Validate configuration
|
||||
@@ -60,6 +61,7 @@ async function main() {
|
||||
app.use("/projects", projectRoutes);
|
||||
app.use("/time-entries", timeEntryRoutes);
|
||||
app.use("/timer", timerRoutes);
|
||||
app.use("/client-targets", clientTargetRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(notFoundHandler);
|
||||
|
||||
109
backend/src/routes/clientTarget.routes.ts
Normal file
109
backend/src/routes/clientTarget.routes.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { validateBody, validateParams } from '../middleware/validation';
|
||||
import { ClientTargetService } from '../services/clientTarget.service';
|
||||
import {
|
||||
CreateClientTargetSchema,
|
||||
UpdateClientTargetSchema,
|
||||
CreateCorrectionSchema,
|
||||
IdSchema,
|
||||
} from '../schemas';
|
||||
import { z } from 'zod';
|
||||
import type { AuthenticatedRequest } from '../types';
|
||||
|
||||
const router = Router();
|
||||
const service = new ClientTargetService();
|
||||
|
||||
const TargetAndCorrectionParamsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
correctionId: z.string().uuid(),
|
||||
});
|
||||
|
||||
// GET /api/client-targets — list all targets with balance for current user
|
||||
router.get('/', requireAuth, async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const targets = await service.findAll(req.user!.id);
|
||||
res.json(targets);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/client-targets — create a target
|
||||
router.post(
|
||||
'/',
|
||||
requireAuth,
|
||||
validateBody(CreateClientTargetSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const target = await service.create(req.user!.id, req.body);
|
||||
res.status(201).json(target);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/client-targets/:id — update a target
|
||||
router.put(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
validateParams(IdSchema),
|
||||
validateBody(UpdateClientTargetSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const target = await service.update(req.params.id, req.user!.id, req.body);
|
||||
res.json(target);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/client-targets/:id — delete a target (cascades corrections)
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
validateParams(IdSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
await service.delete(req.params.id, req.user!.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/client-targets/:id/corrections — add a correction
|
||||
router.post(
|
||||
'/:id/corrections',
|
||||
requireAuth,
|
||||
validateParams(IdSchema),
|
||||
validateBody(CreateCorrectionSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const correction = await service.addCorrection(req.params.id, req.user!.id, req.body);
|
||||
res.status(201).json(correction);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/client-targets/:id/corrections/:correctionId — delete a correction
|
||||
router.delete(
|
||||
'/:id/corrections/:correctionId',
|
||||
requireAuth,
|
||||
validateParams(TargetAndCorrectionParamsSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
await service.deleteCorrection(req.params.id, req.params.correctionId, req.user!.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -69,3 +69,20 @@ export const UpdateTimerSchema = z.object({
|
||||
export const StopTimerSchema = z.object({
|
||||
projectId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const CreateClientTargetSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
weeklyHours: z.number().positive().max(168),
|
||||
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(),
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format').optional(),
|
||||
});
|
||||
|
||||
export const CreateCorrectionSchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be in YYYY-MM-DD format'),
|
||||
hours: z.number().min(-24).max(24),
|
||||
description: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
327
backend/src/services/clientTarget.service.ts
Normal file
327
backend/src/services/clientTarget.service.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { prisma } from '../prisma/client';
|
||||
import { NotFoundError, BadRequestError } from '../errors/AppError';
|
||||
import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
// Returns the Monday of the week containing the given date
|
||||
function getMondayOfWeek(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
const day = d.getUTCDay(); // 0 = Sunday, 1 = Monday, ...
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
d.setUTCDate(d.getUTCDate() + diff);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
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);
|
||||
d.setUTCHours(23, 59, 59, 999);
|
||||
return d;
|
||||
}
|
||||
|
||||
// Returns all Mondays from startDate up to and including the current week's Monday
|
||||
function getWeekMondays(startDate: Date): Date[] {
|
||||
const mondays: Date[] = [];
|
||||
const currentMonday = getMondayOfWeek(new Date());
|
||||
let cursor = new Date(startDate);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
while (cursor <= currentMonday) {
|
||||
mondays.push(new Date(cursor));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 7);
|
||||
}
|
||||
return mondays;
|
||||
}
|
||||
|
||||
interface WeekBalance {
|
||||
weekStart: string; // ISO date string (Monday)
|
||||
weekEnd: string; // ISO date string (Sunday)
|
||||
trackedSeconds: number;
|
||||
targetSeconds: number;
|
||||
correctionHours: number;
|
||||
balanceSeconds: number; // positive = overtime, negative = undertime
|
||||
}
|
||||
|
||||
export interface ClientTargetWithBalance {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
userId: string;
|
||||
weeklyHours: number;
|
||||
startDate: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
corrections: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
hours: number;
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
}>;
|
||||
totalBalanceSeconds: number; // running total across all weeks
|
||||
currentWeekTrackedSeconds: number;
|
||||
currentWeekTargetSeconds: number;
|
||||
weeks: WeekBalance[];
|
||||
}
|
||||
|
||||
export class ClientTargetService {
|
||||
async findAll(userId: string): Promise<ClientTargetWithBalance[]> {
|
||||
const targets = await prisma.clientTarget.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
client: { select: { id: true, name: true } },
|
||||
corrections: { orderBy: { date: 'asc' } },
|
||||
},
|
||||
orderBy: { client: { name: 'asc' } },
|
||||
});
|
||||
|
||||
return Promise.all(targets.map(t => this.computeBalance(t)));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string) {
|
||||
return prisma.clientTarget.findFirst({
|
||||
where: { id, userId },
|
||||
include: {
|
||||
client: { select: { id: true, name: true } },
|
||||
corrections: { orderBy: { date: 'asc' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(userId: string, data: CreateClientTargetInput): Promise<ClientTargetWithBalance> {
|
||||
// Validate startDate is a Monday
|
||||
const startDate = new Date(data.startDate + 'T00:00:00Z');
|
||||
const dayOfWeek = startDate.getUTCDay();
|
||||
if (dayOfWeek !== 1) {
|
||||
throw new BadRequestError('startDate must be a Monday');
|
||||
}
|
||||
|
||||
// Ensure the client belongs to this user
|
||||
const client = await prisma.client.findFirst({ where: { id: data.clientId, userId } });
|
||||
if (!client) {
|
||||
throw new NotFoundError('Client not found');
|
||||
}
|
||||
|
||||
// Check for existing target (unique per user+client)
|
||||
const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } });
|
||||
if (existing) {
|
||||
throw new BadRequestError('A target already exists for this client. Delete the existing one first or update it.');
|
||||
}
|
||||
|
||||
const target = await prisma.clientTarget.create({
|
||||
data: {
|
||||
userId,
|
||||
clientId: data.clientId,
|
||||
weeklyHours: data.weeklyHours,
|
||||
startDate,
|
||||
},
|
||||
include: {
|
||||
client: { select: { id: true, name: true } },
|
||||
corrections: { orderBy: { date: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
return this.computeBalance(target);
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: UpdateClientTargetInput): Promise<ClientTargetWithBalance> {
|
||||
const existing = await this.findById(id, userId);
|
||||
if (!existing) throw new NotFoundError('Client target not found');
|
||||
|
||||
const updateData: { weeklyHours?: number; startDate?: Date } = {};
|
||||
|
||||
if (data.weeklyHours !== undefined) {
|
||||
updateData.weeklyHours = data.weeklyHours;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
updateData.startDate = startDate;
|
||||
}
|
||||
|
||||
const updated = await prisma.clientTarget.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
client: { select: { id: true, name: true } },
|
||||
corrections: { orderBy: { date: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
return this.computeBalance(updated);
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const existing = await this.findById(id, userId);
|
||||
if (!existing) throw new NotFoundError('Client target not found');
|
||||
await prisma.clientTarget.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async addCorrection(targetId: string, userId: string, data: CreateCorrectionInput) {
|
||||
const target = await this.findById(targetId, userId);
|
||||
if (!target) throw new NotFoundError('Client target not found');
|
||||
|
||||
const correctionDate = new Date(data.date + 'T00:00:00Z');
|
||||
const startDate = new Date(target.startDate);
|
||||
startDate.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
if (correctionDate < startDate) {
|
||||
throw new BadRequestError('Correction date cannot be before the target start date');
|
||||
}
|
||||
|
||||
return prisma.balanceCorrection.create({
|
||||
data: {
|
||||
clientTargetId: targetId,
|
||||
date: correctionDate,
|
||||
hours: data.hours,
|
||||
description: data.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCorrection(targetId: string, correctionId: string, userId: string): Promise<void> {
|
||||
const target = await this.findById(targetId, userId);
|
||||
if (!target) throw new NotFoundError('Client target not found');
|
||||
|
||||
const correction = await prisma.balanceCorrection.findFirst({
|
||||
where: { id: correctionId, clientTargetId: targetId },
|
||||
});
|
||||
if (!correction) throw new NotFoundError('Correction not found');
|
||||
|
||||
await prisma.balanceCorrection.delete({ where: { id: correctionId } });
|
||||
}
|
||||
|
||||
private async computeBalance(target: {
|
||||
id: string;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
weeklyHours: 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);
|
||||
|
||||
if (mondays.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]);
|
||||
|
||||
type TrackedRow = { week_start: Date; 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,
|
||||
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 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}
|
||||
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
|
||||
`);
|
||||
|
||||
// Index tracked seconds by week start (ISO Monday string)
|
||||
const trackedByWeek = 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));
|
||||
}
|
||||
|
||||
// Index corrections by week
|
||||
const correctionsByWeek = new Map<string, number>();
|
||||
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 targetSecondsPerWeek = target.weeklyHours * 3600;
|
||||
const weeks: WeekBalance[] = [];
|
||||
let totalBalanceSeconds = 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;
|
||||
|
||||
weeks.push({
|
||||
weekStart: key,
|
||||
weekEnd: sunday.toISOString().split('T')[0],
|
||||
trackedSeconds,
|
||||
targetSeconds: effectiveTargetSeconds,
|
||||
correctionHours,
|
||||
balanceSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
const currentWeek = weeks[weeks.length - 1];
|
||||
|
||||
return {
|
||||
id: target.id,
|
||||
clientId: target.clientId,
|
||||
clientName: target.client.name,
|
||||
userId: target.userId,
|
||||
weeklyHours: target.weeklyHours,
|
||||
startDate: target.startDate.toISOString().split('T')[0],
|
||||
createdAt: target.createdAt.toISOString(),
|
||||
updatedAt: target.updatedAt.toISOString(),
|
||||
corrections: target.corrections.map(c => ({
|
||||
id: c.id,
|
||||
date: c.date.toISOString().split('T')[0],
|
||||
hours: c.hours,
|
||||
description: c.description,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
})),
|
||||
totalBalanceSeconds,
|
||||
currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0,
|
||||
currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek,
|
||||
weeks,
|
||||
};
|
||||
}
|
||||
|
||||
private emptyBalance(target: {
|
||||
id: string;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
weeklyHours: 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 }>;
|
||||
}): ClientTargetWithBalance {
|
||||
return {
|
||||
id: target.id,
|
||||
clientId: target.clientId,
|
||||
clientName: target.client.name,
|
||||
userId: target.userId,
|
||||
weeklyHours: target.weeklyHours,
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -75,3 +75,20 @@ export interface UpdateTimerInput {
|
||||
export interface StopTimerInput {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface CreateClientTargetInput {
|
||||
clientId: string;
|
||||
weeklyHours: number;
|
||||
startDate: string; // YYYY-MM-DD, always a Monday
|
||||
}
|
||||
|
||||
export interface UpdateClientTargetInput {
|
||||
weeklyHours?: number;
|
||||
startDate?: string; // YYYY-MM-DD, always a Monday
|
||||
}
|
||||
|
||||
export interface CreateCorrectionInput {
|
||||
date: string; // YYYY-MM-DD
|
||||
hours: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
41
frontend/src/api/clientTargets.ts
Normal file
41
frontend/src/api/clientTargets.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
65
frontend/src/hooks/useClientTargets.ts
Normal file
65
frontend/src/hooks/useClientTargets.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,7 +489,9 @@ export function ClientsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{clients?.map((client) => (
|
||||
{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">
|
||||
@@ -136,8 +519,20 @@ export function ClientsPage() {
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user