fix(plan): readable audit descriptions, project labels, dashboard invalidation, view-only & sticky-column UI

Work-plan fixes from this session:
- Czech audit descriptions for plan entries/overrides (was empty '-')
- server-embedded project label on grid cells (was '-' past the 100-project cap)
- dashboard activity + audit-log cache invalidation on plan mutations
- read-only cells: no '+' on empty, keep hover on cells with a record
- view modal: Czech category + formatted dates
- sticky date column made opaque (no bleed-through on horizontal scroll)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 00:24:33 +02:00
parent 2f76fe0bc1
commit 4a9c283789
13 changed files with 2240 additions and 261 deletions

View File

@@ -1,4 +1,59 @@
import prisma from "../config/database";
import { buildPlanAuditDescription } from "../utils/planAuditDescription";
/**
* Resolve display names for an audit description: the employee's full name
* and the project name (or null when there is no project). Used so audit-log
* rows show "Jan Novák · … · Projekt ABC" instead of bare numeric ids.
* Falls back to "#id" placeholders if a row was concurrently removed.
*/
async function resolvePlanLabels(
userId: number,
projectId: number | null,
): Promise<{ userName: string; projectName: string | null }> {
const [user, project] = await Promise.all([
prisma.users.findUnique({
where: { id: userId },
select: { first_name: true, last_name: true, username: true },
}),
projectId
? prisma.projects.findUnique({
where: { id: projectId },
select: { name: true, project_number: true },
})
: Promise.resolve(null),
]);
const userName = user
? `${user.first_name} ${user.last_name}`.trim() || user.username
: `uživatel #${userId}`;
const projectName = project
? project.name?.trim() || project.project_number || `projekt #${projectId}`
: null;
return { userName, projectName };
}
/** YYYY-MM-DD from a Prisma @db.Date value. Matches how the rest of this
* file reads date-only columns (UTC slice), so it is timezone-stable. */
function isoDay(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** Prisma `select` for the joined project, plus a mapper to the cell's
* project_number/project_name fields. Used so the grid carries the project
* label without a second client-side lookup. */
const projectSelect = { select: { project_number: true, name: true } } as const;
function projectFields(
p: { project_number: string | null; name: string | null } | null | undefined,
): { project_number: string | null; project_name: string | null } {
return {
project_number: p?.project_number ?? null,
project_name: p?.name ?? null,
};
}
/**
* Resolved plan cell for a single (user, date). Either from a range entry
@@ -15,6 +70,11 @@ export interface ResolvedCell {
/** The date the cell resolves to (echoes the input, YYYY-MM-DD). */
shift_date: string;
project_id: number | null;
/** Project number/name resolved server-side from the projects relation,
* so the UI never depends on a separately-fetched (and capped/permission-
* gated) projects list to display the project. Null when no project. */
project_number: string | null;
project_name: string | null;
category: string;
note: string;
/** Original entry range, or null when source is "override". */
@@ -43,6 +103,7 @@ export async function resolveCell(
// 1. Single-day override for this exact date
const override = await prisma.work_plan_overrides.findFirst({
where: { user_id: userId, shift_date: date, is_deleted: false },
include: { projects: projectSelect },
});
if (override) {
return {
@@ -52,6 +113,7 @@ export async function resolveCell(
user_id: override.user_id,
shift_date: dateStr,
project_id: override.project_id,
...projectFields(override.projects),
category: override.category,
note: override.note,
rangeFrom: null,
@@ -68,6 +130,7 @@ export async function resolveCell(
is_deleted: false,
},
orderBy: { created_at: "desc" },
include: { projects: projectSelect },
});
if (entries.length === 0) return null;
@@ -90,6 +153,7 @@ export async function resolveCell(
user_id: entry.user_id,
shift_date: dateStr,
project_id: entry.project_id,
...projectFields(entry.projects),
category: entry.category,
note: entry.note,
rangeFrom: entry.date_from.toISOString().slice(0, 10),
@@ -122,6 +186,7 @@ export async function resolveGrid(
date_to: { gte: dateFrom },
},
orderBy: { created_at: "desc" },
include: { projects: projectSelect },
}),
prisma.work_plan_overrides.findMany({
where: {
@@ -129,6 +194,7 @@ export async function resolveGrid(
is_deleted: false,
shift_date: { gte: dateFrom, lte: dateTo },
},
include: { projects: projectSelect },
}),
]);
@@ -160,6 +226,7 @@ export async function resolveGrid(
user_id: override.user_id,
shift_date: dateStr,
project_id: override.project_id,
...projectFields(override.projects),
category: override.category,
note: override.note,
rangeFrom: null,
@@ -182,6 +249,7 @@ export async function resolveGrid(
user_id: entry.user_id,
shift_date: dateStr,
project_id: entry.project_id,
...projectFields(entry.projects),
category: entry.category,
note: entry.note,
rangeFrom: entry.date_from.toISOString().slice(0, 10),
@@ -292,7 +360,15 @@ export function assertNotPastDate(
* On failure returns `{ error, status }`.
*/
export type Result<T> =
| { data: T; oldData: unknown | null; replacedData?: unknown | null }
| {
data: T;
oldData: unknown | null;
replacedData?: unknown | null;
/** Human-readable Czech subject for the audit-log row. */
description?: string;
/** Audit description for the `replacedData` soft-delete row, if any. */
replacedDescription?: string;
}
| { error: string; status: number };
/** Convert a YYYY-MM-DD string to a Date at midnight UTC. */
@@ -350,7 +426,20 @@ export async function createEntry(
},
});
return { data: created, oldData: null };
const { userName, projectName } = await resolvePlanLabels(
created.user_id,
created.project_id,
);
const description = buildPlanAuditDescription({
userName,
category: created.category,
projectName,
dateFrom: input.date_from,
dateTo: input.date_to,
force,
});
return { data: created, oldData: null, description };
}
/** Update a work_plan_entries row. Partial update. */
@@ -396,7 +485,20 @@ export async function updateEntry(
},
});
return { data: updated, oldData: existing };
const { userName, projectName } = await resolvePlanLabels(
updated.user_id,
updated.project_id,
);
const description = buildPlanAuditDescription({
userName,
category: updated.category,
projectName,
dateFrom: isoDay(updated.date_from),
dateTo: isoDay(updated.date_to),
force,
});
return { data: updated, oldData: existing, description };
}
/** Soft-delete a work_plan_entries row. */
@@ -418,7 +520,20 @@ export async function deleteEntry(
data: { is_deleted: true },
});
return { data: { ok: true }, oldData: existing };
const { userName, projectName } = await resolvePlanLabels(
existing.user_id,
existing.project_id,
);
const description = buildPlanAuditDescription({
userName,
category: existing.category,
projectName,
dateFrom: isoDay(existing.date_from),
dateTo: isoDay(existing.date_to),
force,
});
return { data: { ok: true }, oldData: existing, description };
}
// ---------------------------------------------------------------------------
@@ -459,7 +574,7 @@ export async function createOverride(
// can't both create active overrides for the same user-day.
const date = toDateOnly(input.shift_date);
return prisma.$transaction(async (tx) => {
const { created, replaced } = await prisma.$transaction(async (tx) => {
const existing = await tx.work_plan_overrides.findFirst({
where: {
user_id: input.user_id,
@@ -468,7 +583,6 @@ export async function createOverride(
},
});
let replacedData: unknown = null;
if (existing) {
// Lock the row before update so a concurrent createOverride for the
// same (user, date) waits and sees the soft-deleted state.
@@ -477,10 +591,9 @@ export async function createOverride(
where: { id: existing.id },
data: { is_deleted: true },
});
replacedData = existing;
}
const created = await tx.work_plan_overrides.create({
const createdRow = await tx.work_plan_overrides.create({
data: {
user_id: input.user_id,
shift_date: date,
@@ -491,12 +604,42 @@ export async function createOverride(
},
});
return {
data: created,
oldData: null,
replacedData,
};
return { created: createdRow, replaced: existing };
});
const { userName, projectName } = await resolvePlanLabels(
created.user_id,
created.project_id,
);
const description = buildPlanAuditDescription({
userName,
category: created.category,
projectName,
dateFrom: input.shift_date,
dateTo: input.shift_date,
force,
});
let replacedDescription: string | undefined;
if (replaced) {
const r = await resolvePlanLabels(replaced.user_id, replaced.project_id);
replacedDescription = buildPlanAuditDescription({
userName: r.userName,
category: replaced.category,
projectName: r.projectName,
dateFrom: isoDay(replaced.shift_date),
dateTo: isoDay(replaced.shift_date),
suffix: "nahrazeno novým záznamem",
});
}
return {
data: created,
oldData: null,
replacedData: replaced,
description,
replacedDescription,
};
}
/** Update a work_plan_overrides row. */
@@ -525,7 +668,20 @@ export async function updateOverride(
},
});
return { data: updated, oldData: existing };
const { userName, projectName } = await resolvePlanLabels(
updated.user_id,
updated.project_id,
);
const description = buildPlanAuditDescription({
userName,
category: updated.category,
projectName,
dateFrom: isoDay(updated.shift_date),
dateTo: isoDay(updated.shift_date),
force,
});
return { data: updated, oldData: existing, description };
}
/** Soft-delete a work_plan_overrides row. */
@@ -549,7 +705,20 @@ export async function deleteOverride(
data: { is_deleted: true },
});
return { data: { ok: true }, oldData: existing };
const { userName, projectName } = await resolvePlanLabels(
existing.user_id,
existing.project_id,
);
const description = buildPlanAuditDescription({
userName,
category: existing.category,
projectName,
dateFrom: isoDay(existing.shift_date),
dateTo: isoDay(existing.shift_date),
force,
});
return { data: { ok: true }, oldData: existing, description };
}
// ---------------------------------------------------------------------------