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:
@@ -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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user