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[]
|
projects Project[]
|
||||||
timeEntries TimeEntry[]
|
timeEntries TimeEntry[]
|
||||||
ongoingTimer OngoingTimer?
|
ongoingTimer OngoingTimer?
|
||||||
|
clientTargets ClientTarget[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ model Client {
|
|||||||
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)
|
||||||
projects Project[]
|
projects Project[]
|
||||||
|
clientTargets ClientTarget[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("clients")
|
@@map("clients")
|
||||||
@@ -91,3 +93,38 @@ model OngoingTimer {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("ongoing_timers")
|
@@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 projectRoutes from "./routes/project.routes";
|
||||||
import timeEntryRoutes from "./routes/timeEntry.routes";
|
import timeEntryRoutes from "./routes/timeEntry.routes";
|
||||||
import timerRoutes from "./routes/timer.routes";
|
import timerRoutes from "./routes/timer.routes";
|
||||||
|
import clientTargetRoutes from "./routes/clientTarget.routes";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
@@ -60,6 +61,7 @@ async function main() {
|
|||||||
app.use("/projects", projectRoutes);
|
app.use("/projects", projectRoutes);
|
||||||
app.use("/time-entries", timeEntryRoutes);
|
app.use("/time-entries", timeEntryRoutes);
|
||||||
app.use("/timer", timerRoutes);
|
app.use("/timer", timerRoutes);
|
||||||
|
app.use("/client-targets", clientTargetRoutes);
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(notFoundHandler);
|
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({
|
export const StopTimerSchema = z.object({
|
||||||
projectId: z.string().uuid().optional(),
|
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 {
|
export interface StopTimerInput {
|
||||||
projectId?: string;
|
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 { 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 { useClients } from '@/hooks/useClients';
|
||||||
|
import { useClientTargets } from '@/hooks/useClientTargets';
|
||||||
import { Modal } from '@/components/Modal';
|
import { Modal } from '@/components/Modal';
|
||||||
import { Spinner } from '@/components/Spinner';
|
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() {
|
export function ClientsPage() {
|
||||||
const { clients, isLoading, createClient, updateClient, deleteClient } = useClients();
|
const { clients, isLoading, createClient, updateClient, deleteClient } = useClients();
|
||||||
|
const { targets, createTarget, deleteTarget } = useClientTargets();
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
||||||
const [formData, setFormData] = useState<CreateClientInput>({ name: '', description: '' });
|
const [formData, setFormData] = useState<CreateClientInput>({ name: '', description: '' });
|
||||||
@@ -108,7 +489,9 @@ export function ClientsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<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 key={client.id} className="card">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -136,8 +519,20 @@ export function ClientsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link } from "react-router-dom";
|
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 { useTimeEntries } from "@/hooks/useTimeEntries";
|
||||||
|
import { useClientTargets } from "@/hooks/useClientTargets";
|
||||||
import { ProjectColorDot } from "@/components/ProjectColorDot";
|
import { ProjectColorDot } from "@/components/ProjectColorDot";
|
||||||
import { StatCard } from "@/components/StatCard";
|
import { StatCard } from "@/components/StatCard";
|
||||||
import {
|
import {
|
||||||
@@ -24,11 +25,15 @@ export function DashboardPage() {
|
|||||||
limit: 10,
|
limit: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { targets } = useClientTargets();
|
||||||
|
|
||||||
const totalTodaySeconds =
|
const totalTodaySeconds =
|
||||||
todayEntries?.entries.reduce((total, entry) => {
|
todayEntries?.entries.reduce((total, entry) => {
|
||||||
return total + calculateDuration(entry.startTime, entry.endTime);
|
return total + calculateDuration(entry.startTime, entry.endTime);
|
||||||
}, 0) || 0;
|
}, 0) || 0;
|
||||||
|
|
||||||
|
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
@@ -71,6 +76,61 @@ export function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Recent Activity */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
@@ -138,3 +138,53 @@ export interface UpdateTimeEntryInput {
|
|||||||
description?: string;
|
description?: string;
|
||||||
projectId?: 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