Two dashboard bugs reported after clock-in: 1. 'Dochazka dnes' / 'Pritomni dnes' showed everyone absent even after refresh: attendance.shift_date is @db.Date and Prisma truncates filter Dates to their UTC date part, so the local-midnight boundaries (new Date(y,m,d) = 22:00/23:00Z of the previous day) queried [yesterday, today) and matched zero of today's punches. Boundaries are now UTC-midnight instants of the local (Prague) calendar day. Reproduced empirically against real rows; route-level regression test added (dashboard.test.ts). 2. After logout + login as a different user, cached dashboards/buttons from the previous user were served: query keys are user-agnostic and the React Query cache outlives the session. logout(), login() and the 2FA verify path now clear the whole cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
||
import prisma from "../../config/database";
|
||
import { requireAuth } from "../../middleware/auth";
|
||
import { success } from "../../utils/response";
|
||
import { localTimeStr } from "../../utils/date";
|
||
import { toCzk } from "../../services/exchange-rates";
|
||
import { resolveCell } from "../../services/plan.service";
|
||
|
||
export default async function dashboardRoutes(
|
||
fastify: FastifyInstance,
|
||
): Promise<void> {
|
||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||
const now = new Date();
|
||
// shift_date is @db.Date: Prisma truncates a filter Date to its UTC date
|
||
// part, so these boundaries MUST be UTC-midnight instants of the LOCAL
|
||
// (Prague) calendar day. A local-midnight Date (new Date(y, m, d)) is
|
||
// 22:00/23:00 UTC of the PREVIOUS day and silently shifts the window to
|
||
// [yesterday, today) — the "Docházka dnes" card then matches zero rows.
|
||
const todayStart = new Date(
|
||
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()),
|
||
);
|
||
const todayEnd = new Date(
|
||
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() + 1),
|
||
);
|
||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||
const authData = request.authData!;
|
||
const userId = authData.userId;
|
||
const perms = authData.permissions;
|
||
const has = (p: string) => perms.includes(p);
|
||
|
||
const result: Record<string, unknown> = {};
|
||
|
||
// My shift — always available for authenticated users with attendance.record
|
||
if (has("attendance.record")) {
|
||
const myShift = await prisma.attendance.findFirst({
|
||
where: {
|
||
user_id: userId,
|
||
arrival_time: { not: null },
|
||
departure_time: null,
|
||
},
|
||
orderBy: { created_at: "desc" },
|
||
});
|
||
result.my_shift = { has_ongoing: myShift !== null };
|
||
}
|
||
|
||
// Today's work plan (personal) — powers the "Vaše dnešní zařazení" card.
|
||
// resolveCell returns 0–3 records (override-beats-range layering); an empty
|
||
// array means the user has plan access but nothing is scheduled today. Each
|
||
// record's category key is enriched with its label + colour so the widget
|
||
// needs no extra query. todayStr uses local (Europe/Prague) calendar date —
|
||
// the plan's @db.Date day.
|
||
if (has("attendance.record") || has("attendance.manage")) {
|
||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||
const cells = await resolveCell(userId, todayStr);
|
||
// Resolve all category labels in one query (was a per-cell N+1) then map.
|
||
const categoryKeys = [...new Set(cells.map((c) => c.category))];
|
||
const categories = categoryKeys.length
|
||
? await prisma.plan_categories.findMany({
|
||
where: { key: { in: categoryKeys } },
|
||
select: { key: true, label: true, color: true },
|
||
})
|
||
: [];
|
||
const categoryMap = new Map(categories.map((c) => [c.key, c]));
|
||
result.today_plan = cells.map((cell) => {
|
||
const cat = categoryMap.get(cell.category);
|
||
return {
|
||
...cell,
|
||
category_label: cat?.label ?? cell.category,
|
||
category_color: cat?.color ?? null,
|
||
};
|
||
});
|
||
}
|
||
|
||
// Attendance admin — only for attendance.manage
|
||
if (has("attendance.manage")) {
|
||
const [todayAttendance, onLeaveToday, activeUsers] = await Promise.all([
|
||
prisma.attendance.findMany({
|
||
where: {
|
||
shift_date: { gte: todayStart, lt: todayEnd },
|
||
OR: [{ leave_type: null }, { leave_type: "work" }],
|
||
},
|
||
include: {
|
||
users: { select: { id: true, first_name: true, last_name: true } },
|
||
},
|
||
orderBy: { arrival_time: "asc" },
|
||
}),
|
||
prisma.attendance.findMany({
|
||
where: {
|
||
shift_date: { gte: todayStart, lt: todayEnd },
|
||
leave_type: { in: ["vacation", "sick", "unpaid"] },
|
||
},
|
||
include: {
|
||
users: { select: { id: true, first_name: true, last_name: true } },
|
||
},
|
||
}),
|
||
prisma.users.findMany({
|
||
where: { is_active: true },
|
||
select: { id: true, first_name: true, last_name: true },
|
||
orderBy: [{ last_name: "asc" }, { first_name: "asc" }],
|
||
}),
|
||
]);
|
||
const usersCount = activeUsers.length;
|
||
|
||
const userAttendanceMap = new Map<number, (typeof todayAttendance)[0]>();
|
||
for (const a of todayAttendance) {
|
||
const existing = userAttendanceMap.get(a.users.id);
|
||
if (
|
||
!existing ||
|
||
(a.arrival_time &&
|
||
existing.arrival_time &&
|
||
a.arrival_time > existing.arrival_time)
|
||
) {
|
||
userAttendanceMap.set(a.users.id, a);
|
||
}
|
||
}
|
||
|
||
let presentCount = 0;
|
||
const attendanceUsers: Array<{
|
||
user_id: number;
|
||
name: string;
|
||
initials: string;
|
||
status: string;
|
||
arrived_at: string | null;
|
||
leave_type?: string;
|
||
}> = [];
|
||
|
||
for (const a of userAttendanceMap.values()) {
|
||
const user = a.users;
|
||
const firstInitial = user.first_name?.charAt(0) ?? "";
|
||
const lastInitial = user.last_name?.charAt(0) ?? "";
|
||
let status = "out";
|
||
if (a.arrival_time) {
|
||
if (a.departure_time) status = "out";
|
||
else if (a.break_start && !a.break_end) status = "away";
|
||
else {
|
||
status = "in";
|
||
presentCount++;
|
||
}
|
||
}
|
||
attendanceUsers.push({
|
||
user_id: user.id,
|
||
name: `${user.first_name} ${user.last_name}`,
|
||
initials: `${firstInitial}${lastInitial}`.toUpperCase(),
|
||
status,
|
||
arrived_at: a.arrival_time ? localTimeStr(a.arrival_time) : null,
|
||
});
|
||
}
|
||
|
||
const leaveUserIds = new Set<number>();
|
||
for (const a of onLeaveToday) {
|
||
if (leaveUserIds.has(a.users.id)) continue;
|
||
leaveUserIds.add(a.users.id);
|
||
const user = a.users;
|
||
attendanceUsers.push({
|
||
user_id: user.id,
|
||
name: `${user.first_name} ${user.last_name}`,
|
||
initials:
|
||
`${user.first_name?.charAt(0) ?? ""}${user.last_name?.charAt(0) ?? ""}`.toUpperCase(),
|
||
status: "leave",
|
||
arrived_at: null,
|
||
leave_type: (a.leave_type as string) || "vacation",
|
||
});
|
||
}
|
||
|
||
// Show every active employee — those with no attendance record today
|
||
// appear as "absent" so the card reflects the whole team, not just those
|
||
// who clocked in or are on leave.
|
||
const recordedUserIds = new Set<number>([
|
||
...userAttendanceMap.keys(),
|
||
...leaveUserIds,
|
||
]);
|
||
for (const user of activeUsers) {
|
||
if (recordedUserIds.has(user.id)) continue;
|
||
attendanceUsers.push({
|
||
user_id: user.id,
|
||
name: `${user.first_name} ${user.last_name}`,
|
||
initials:
|
||
`${user.first_name?.charAt(0) ?? ""}${user.last_name?.charAt(0) ?? ""}`.toUpperCase(),
|
||
status: "absent",
|
||
arrived_at: null,
|
||
});
|
||
}
|
||
|
||
result.attendance = {
|
||
present_today: presentCount,
|
||
total_active: usersCount,
|
||
on_leave: leaveUserIds.size,
|
||
users: attendanceUsers,
|
||
};
|
||
result.users_count = usersCount;
|
||
}
|
||
|
||
// Offers — only for offers.view
|
||
if (has("offers.view")) {
|
||
const [openCount, convertedCount, expiredCount, createdThisMonth] =
|
||
await Promise.all([
|
||
prisma.quotations.count({ where: { status: "active" } }),
|
||
prisma.quotations.count({ where: { status: "ordered" } }),
|
||
prisma.quotations.count({ where: { status: "invalidated" } }),
|
||
prisma.quotations.count({
|
||
where: {
|
||
// Drafts aren't real offers — keep them out of the count.
|
||
status: { not: "draft" },
|
||
created_at: { gte: monthStart, lt: monthEnd },
|
||
},
|
||
}),
|
||
]);
|
||
result.offers = {
|
||
open_count: openCount,
|
||
converted_count: convertedCount,
|
||
expired_count: expiredCount,
|
||
created_this_month: createdThisMonth,
|
||
};
|
||
}
|
||
|
||
// Projects — only for projects.view
|
||
if (has("projects.view")) {
|
||
const [activeCount, activeList] = await Promise.all([
|
||
prisma.projects.count({ where: { status: "aktivni" } }),
|
||
prisma.projects.findMany({
|
||
where: { status: "aktivni" },
|
||
include: { customers: { select: { name: true } } },
|
||
orderBy: { created_at: "desc" },
|
||
take: 5,
|
||
}),
|
||
]);
|
||
result.active_projects = activeCount;
|
||
result.projects = {
|
||
active_projects: activeList.map((p) => ({
|
||
id: p.id,
|
||
name: p.name ?? "",
|
||
customer_name: p.customers?.name ?? null,
|
||
})),
|
||
};
|
||
}
|
||
|
||
// Invoices — only for invoices.view
|
||
if (has("invoices.view")) {
|
||
// $queryRaw template literal interpolation with Date objects fails on
|
||
// MySQL when Date.toJSON is overridden — pass explicit date strings instead.
|
||
const monthStartStr = `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}-01`;
|
||
const monthEndStr = `${monthEnd.getFullYear()}-${String(monthEnd.getMonth() + 1).padStart(2, "0")}-01`;
|
||
|
||
const [unpaidCount, revenueAgg] = await Promise.all([
|
||
prisma.invoices.count({ where: { status: "issued" } }),
|
||
prisma.$queryRaw<
|
||
Array<{ currency: string | null; total: string | number | null }>
|
||
>`
|
||
SELECT i.currency, SUM(ii.quantity * ii.unit_price) as total
|
||
FROM invoices i
|
||
JOIN invoice_items ii ON i.id = ii.invoice_id
|
||
WHERE i.issue_date >= ${monthStartStr} AND i.issue_date < ${monthEndStr}
|
||
AND i.status <> 'draft'
|
||
GROUP BY i.currency
|
||
`,
|
||
]);
|
||
|
||
const revenueByCurrency: Record<string, number> = {};
|
||
for (const row of revenueAgg) {
|
||
const currency = row.currency || "CZK";
|
||
const amount = Number(row.total) || 0;
|
||
revenueByCurrency[currency] =
|
||
(revenueByCurrency[currency] || 0) + amount;
|
||
}
|
||
|
||
const revenueConversions = await Promise.all(
|
||
Object.entries(revenueByCurrency).map(async ([currency, amount]) => ({
|
||
amount: Math.round(amount * 100) / 100,
|
||
currency,
|
||
czk: await toCzk(Math.round(amount * 100) / 100, currency),
|
||
})),
|
||
);
|
||
|
||
result.invoices = {
|
||
revenue_this_month: revenueConversions.map(({ amount, currency }) => ({
|
||
amount,
|
||
currency,
|
||
})),
|
||
unpaid_count: unpaidCount,
|
||
revenue_czk:
|
||
Math.round(
|
||
revenueConversions.reduce((sum, r) => sum + r.czk, 0) * 100,
|
||
) / 100,
|
||
};
|
||
}
|
||
|
||
// Orders — only for orders.view
|
||
if (has("orders.view")) {
|
||
result.pending_orders = await prisma.orders.count({
|
||
where: { status: "prijata" },
|
||
});
|
||
}
|
||
|
||
// Leave pending — only for attendance.approve
|
||
if (has("attendance.approve")) {
|
||
const count = await prisma.leave_requests.count({
|
||
where: { status: "pending" },
|
||
});
|
||
result.leave_pending = { count };
|
||
}
|
||
|
||
// Recent activity — only for settings.audit (admin)
|
||
if (has("settings.audit")) {
|
||
const logs = await prisma.audit_logs.findMany({
|
||
orderBy: { created_at: "desc" },
|
||
take: 8,
|
||
where: { action: { in: ["create", "update", "delete", "login"] } },
|
||
select: {
|
||
id: true,
|
||
action: true,
|
||
entity_type: true,
|
||
description: true,
|
||
username: true,
|
||
created_at: true,
|
||
},
|
||
});
|
||
result.recent_activity = logs.map((log) => ({
|
||
id: log.id,
|
||
action: log.action,
|
||
entity_type: log.entity_type ?? "",
|
||
description: log.description ?? "",
|
||
username: log.username ?? null,
|
||
created_at: log.created_at,
|
||
}));
|
||
}
|
||
|
||
return success(reply, result);
|
||
});
|
||
}
|