Files
app/src/routes/admin/plan.ts
BOHA 1ee59b54bc chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix
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>
2026-06-13 18:35:31 +02:00

427 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
},
);
}