UI fix:
- Close-only modals showing a redundant second close button now use
hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.
Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
preserving payload shape, minimal vite module type, frontend body/query-key
types, SystemInfo for Settings.
Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.
Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||
import {
|
||
requireAuth,
|
||
requirePermission,
|
||
requireAnyPermission,
|
||
} from "../../middleware/auth";
|
||
import { success, error, parseId } from "../../utils/response";
|
||
import { parseBody } from "../../schemas/common";
|
||
import { AuthData } from "../../types";
|
||
import { logAudit } from "../../services/audit";
|
||
import {
|
||
CreatePlanEntrySchema,
|
||
UpdatePlanEntrySchema,
|
||
CreatePlanOverrideSchema,
|
||
UpdatePlanOverrideSchema,
|
||
PlanRangeQuerySchema,
|
||
PlanGridQuerySchema,
|
||
BulkPlanEntrySchema,
|
||
} from "../../schemas/plan.schema";
|
||
import {
|
||
resolveGrid,
|
||
listPlanUsers,
|
||
createEntry,
|
||
updateEntry,
|
||
deleteEntry,
|
||
createOverride,
|
||
updateOverride,
|
||
deleteOverride,
|
||
listEntries,
|
||
listOverrides,
|
||
bulkCreateEntries,
|
||
} from "../../services/plan.service";
|
||
import {
|
||
CreatePlanCategorySchema,
|
||
UpdatePlanCategorySchema,
|
||
} from "../../schemas/planCategory.schema";
|
||
import {
|
||
listPlanCategories,
|
||
createPlanCategory,
|
||
updatePlanCategory,
|
||
deletePlanCategory,
|
||
} from "../../services/planCategory.service";
|
||
|
||
const MAX_RANGE_DAYS = 92;
|
||
|
||
// NOTE: AuthData carries `roleName` (not `role` — that lives on JwtPayload).
|
||
// The previous `authData.role` read was always undefined, so admins were
|
||
// wrongly scoped to their own plan rows on the /entries and /overrides
|
||
// endpoints. Typed (not `any`) so this can't silently regress.
|
||
function isAdminLike(authData: AuthData | undefined): boolean {
|
||
return authData?.roleName === "admin";
|
||
}
|
||
|
||
function forceFromQuery(query: unknown): boolean {
|
||
if (!query || typeof query !== "object") return false;
|
||
return (query as { force?: unknown }).force === "1";
|
||
}
|
||
|
||
function daysBetween(from: string, to: string): number {
|
||
const a = new Date(from + "T00:00:00.000Z").getTime();
|
||
const b = new Date(to + "T00:00:00.000Z").getTime();
|
||
return Math.round((b - a) / (1000 * 60 * 60 * 24));
|
||
}
|
||
|
||
export default async function planRoutes(app: FastifyInstance) {
|
||
app.addHook("preHandler", requireAuth);
|
||
|
||
// --- GET /plan/users ---
|
||
app.get(
|
||
"/users",
|
||
{
|
||
preHandler: requireAnyPermission(
|
||
"attendance.manage",
|
||
"attendance.record",
|
||
),
|
||
},
|
||
async (_request: FastifyRequest, reply: FastifyReply) => {
|
||
const users = await listPlanUsers();
|
||
return success(reply, users);
|
||
},
|
||
);
|
||
|
||
// --- GET /plan/grid ---
|
||
app.get(
|
||
"/grid",
|
||
{
|
||
preHandler: requireAnyPermission(
|
||
"attendance.manage",
|
||
"attendance.record",
|
||
),
|
||
},
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const parsed = PlanGridQuerySchema.safeParse(request.query);
|
||
if (!parsed.success) {
|
||
return error(reply, parsed.error.issues[0].message, 400);
|
||
}
|
||
const { date_from, date_to, view } = parsed.data;
|
||
if (daysBetween(date_from, date_to) > MAX_RANGE_DAYS) {
|
||
return error(reply, "Maximální rozsah je 92 dní", 400);
|
||
}
|
||
const users = await listPlanUsers();
|
||
const cells = await resolveGrid(
|
||
users.map((u) => u.id),
|
||
date_from,
|
||
date_to,
|
||
);
|
||
return success(reply, { view, date_from, date_to, users, cells });
|
||
},
|
||
);
|
||
|
||
// --- GET /plan/entries ---
|
||
app.get(
|
||
"/entries",
|
||
{
|
||
preHandler: requireAnyPermission(
|
||
"attendance.manage",
|
||
"attendance.record",
|
||
),
|
||
},
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const parsed = PlanRangeQuerySchema.safeParse(request.query);
|
||
if (!parsed.success)
|
||
return error(reply, parsed.error.issues[0].message, 400);
|
||
const rows = await listEntries(
|
||
parsed.data,
|
||
request.authData!.userId,
|
||
isAdminLike(request.authData),
|
||
);
|
||
return success(reply, rows);
|
||
},
|
||
);
|
||
|
||
// --- GET /plan/overrides ---
|
||
app.get(
|
||
"/overrides",
|
||
{
|
||
preHandler: requireAnyPermission(
|
||
"attendance.manage",
|
||
"attendance.record",
|
||
),
|
||
},
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const parsed = PlanRangeQuerySchema.safeParse(request.query);
|
||
if (!parsed.success)
|
||
return error(reply, parsed.error.issues[0].message, 400);
|
||
const rows = await listOverrides(
|
||
parsed.data,
|
||
request.authData!.userId,
|
||
isAdminLike(request.authData),
|
||
);
|
||
return success(reply, rows);
|
||
},
|
||
);
|
||
|
||
// --- GET /plan/categories (any planner can read; needed for display) ---
|
||
app.get(
|
||
"/categories",
|
||
{
|
||
preHandler: requireAnyPermission(
|
||
"attendance.manage",
|
||
"attendance.record",
|
||
),
|
||
},
|
||
async (_request: FastifyRequest, reply: FastifyReply) => {
|
||
const cats = await listPlanCategories(true);
|
||
return success(reply, cats);
|
||
},
|
||
);
|
||
|
||
// --- POST /plan/categories ---
|
||
app.post(
|
||
"/categories",
|
||
{ preHandler: requirePermission("attendance.manage") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const body = parseBody(CreatePlanCategorySchema, request.body);
|
||
if ("error" in body) return error(reply, body.error, 400);
|
||
const result = await createPlanCategory(body.data!);
|
||
if ("error" in result) return error(reply, result.error, result.status);
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "create",
|
||
entityType: "plan_category",
|
||
entityId: result.data.id,
|
||
newValues: result.data,
|
||
description: `Kategorie: ${result.data.label}`,
|
||
});
|
||
return success(reply, result.data, 201, "Kategorie vytvořena");
|
||
},
|
||
);
|
||
|
||
// --- PATCH /plan/categories/:id ---
|
||
app.patch(
|
||
"/categories/:id",
|
||
{ preHandler: requirePermission("attendance.manage") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const id = parseId((request.params as { id: string }).id, reply);
|
||
if (id === null) return;
|
||
const body = parseBody(UpdatePlanCategorySchema, request.body);
|
||
if ("error" in body) return error(reply, body.error, 400);
|
||
const result = await updatePlanCategory(id, body.data!);
|
||
if ("error" in result) return error(reply, result.error, result.status);
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "update",
|
||
entityType: "plan_category",
|
||
entityId: id,
|
||
newValues: result.data,
|
||
description: `Kategorie: ${result.data.label}`,
|
||
});
|
||
return success(reply, result.data, 200, "Kategorie aktualizována");
|
||
},
|
||
);
|
||
|
||
// --- DELETE /plan/categories/:id (hard delete; blocked if in use) ---
|
||
app.delete(
|
||
"/categories/:id",
|
||
{ preHandler: requirePermission("attendance.manage") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const id = parseId((request.params as { id: string }).id, reply);
|
||
if (id === null) return;
|
||
const result = await deletePlanCategory(id);
|
||
if ("error" in result) return error(reply, result.error, result.status);
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "delete",
|
||
entityType: "plan_category",
|
||
entityId: id,
|
||
description: "Kategorie smazána",
|
||
});
|
||
return success(reply, { ok: true }, 200, "Kategorie smazána");
|
||
},
|
||
);
|
||
|
||
// --- POST /plan/entries ---
|
||
app.post(
|
||
"/entries",
|
||
{ preHandler: requirePermission("attendance.manage") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const body = parseBody(CreatePlanEntrySchema, request.body);
|
||
if ("error" in body) return error(reply, body.error, 400);
|
||
const force = forceFromQuery(request.query);
|
||
const result = await createEntry(
|
||
body.data!,
|
||
request.authData!.userId,
|
||
force,
|
||
);
|
||
if ("error" in result) return error(reply, result.error, result.status);
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "create",
|
||
entityType: "work_plan_entry",
|
||
entityId: result.data.id,
|
||
newValues: result.data,
|
||
description: result.description,
|
||
});
|
||
return success(reply, result.data, 201, "Plán vytvořen");
|
||
},
|
||
);
|
||
|
||
// --- POST /plan/entries/bulk ---
|
||
app.post(
|
||
"/entries/bulk",
|
||
{ preHandler: requirePermission("attendance.manage") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const body = parseBody(BulkPlanEntrySchema, request.body);
|
||
if ("error" in body) return error(reply, body.error, 400);
|
||
const result = await bulkCreateEntries(
|
||
body.data!,
|
||
request.authData!.userId,
|
||
);
|
||
if ("error" in result) return error(reply, result.error, result.status);
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "create",
|
||
entityType: "work_plan_entry",
|
||
description: `Hromadné přiřazení: ${body.data!.category} — ${result.data.users} zaměstnanců, ${body.data!.date_from}–${body.data!.date_to} (${result.data.created_days} dní)`,
|
||
});
|
||
return success(reply, result.data, 201, "Hromadné přiřazení dokončeno");
|
||
},
|
||
);
|
||
|
||
// --- PATCH /plan/entries/:id ---
|
||
app.patch(
|
||
"/entries/:id",
|
||
{ preHandler: requirePermission("attendance.manage") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const id = parseId((request.params as { id: string }).id, reply);
|
||
if (id === null) return;
|
||
const body = parseBody(UpdatePlanEntrySchema, request.body);
|
||
if ("error" in body) return error(reply, body.error, 400);
|
||
const force = forceFromQuery(request.query);
|
||
const result = await updateEntry(
|
||
id,
|
||
body.data!,
|
||
request.authData!.userId,
|
||
force,
|
||
);
|
||
if ("error" in result) return error(reply, result.error, result.status);
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "update",
|
||
entityType: "work_plan_entry",
|
||
entityId: id,
|
||
oldValues:
|
||
(result.oldData as Record<string, unknown> | null) ?? undefined,
|
||
newValues: result.data as Record<string, unknown>,
|
||
description: result.description,
|
||
});
|
||
return success(reply, result.data, 200, "Plán aktualizován");
|
||
},
|
||
);
|
||
|
||
// --- DELETE /plan/entries/:id ---
|
||
app.delete(
|
||
"/entries/:id",
|
||
{ preHandler: requirePermission("attendance.manage") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const id = parseId((request.params as { id: string }).id, reply);
|
||
if (id === null) return;
|
||
const force = forceFromQuery(request.query);
|
||
const result = await deleteEntry(id, request.authData!.userId, force);
|
||
if ("error" in result) return error(reply, result.error, result.status);
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "delete",
|
||
entityType: "work_plan_entry",
|
||
entityId: id,
|
||
oldValues:
|
||
(result.oldData as Record<string, unknown> | null) ?? undefined,
|
||
description: result.description,
|
||
});
|
||
return success(reply, { ok: true }, 200, "Plán smazán");
|
||
},
|
||
);
|
||
|
||
// --- POST /plan/overrides ---
|
||
app.post(
|
||
"/overrides",
|
||
{ preHandler: requirePermission("attendance.manage") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const body = parseBody(CreatePlanOverrideSchema, request.body);
|
||
if ("error" in body) return error(reply, body.error, 400);
|
||
const force = forceFromQuery(request.query);
|
||
const result = await createOverride(
|
||
body.data!,
|
||
request.authData!.userId,
|
||
force,
|
||
);
|
||
if ("error" in result) return error(reply, result.error, result.status);
|
||
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "create",
|
||
entityType: "work_plan_override",
|
||
entityId: result.data.id,
|
||
newValues: result.data,
|
||
description: result.description,
|
||
});
|
||
return success(reply, result.data, 201, "Přepsání dne vytvořeno");
|
||
},
|
||
);
|
||
|
||
// --- PATCH /plan/overrides/:id ---
|
||
app.patch(
|
||
"/overrides/:id",
|
||
{ preHandler: requirePermission("attendance.manage") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const id = parseId((request.params as { id: string }).id, reply);
|
||
if (id === null) return;
|
||
const body = parseBody(UpdatePlanOverrideSchema, request.body);
|
||
if ("error" in body) return error(reply, body.error, 400);
|
||
const force = forceFromQuery(request.query);
|
||
const result = await updateOverride(
|
||
id,
|
||
body.data!,
|
||
request.authData!.userId,
|
||
force,
|
||
);
|
||
if ("error" in result) return error(reply, result.error, result.status);
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "update",
|
||
entityType: "work_plan_override",
|
||
entityId: id,
|
||
oldValues:
|
||
(result.oldData as Record<string, unknown> | null) ?? undefined,
|
||
newValues: result.data,
|
||
description: result.description,
|
||
});
|
||
return success(reply, result.data, 200, "Přepsání aktualizováno");
|
||
},
|
||
);
|
||
|
||
// --- DELETE /plan/overrides/:id ---
|
||
app.delete(
|
||
"/overrides/:id",
|
||
{ preHandler: requirePermission("attendance.manage") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const id = parseId((request.params as { id: string }).id, reply);
|
||
if (id === null) return;
|
||
const force = forceFromQuery(request.query);
|
||
const result = await deleteOverride(id, request.authData!.userId, force);
|
||
if ("error" in result) return error(reply, result.error, result.status);
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "delete",
|
||
entityType: "work_plan_override",
|
||
entityId: id,
|
||
oldValues:
|
||
(result.oldData as Record<string, unknown> | null) ?? undefined,
|
||
description: result.description,
|
||
});
|
||
return success(reply, { ok: true }, 200, "Přepsání smazáno");
|
||
},
|
||
);
|
||
}
|