Files
app/src/routes/admin/dashboard.ts
2026-06-09 19:04:10 +02:00

330 lines
12 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 } 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();
const todayStart = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
);
const todayEnd = new Date(
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 03 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);
});
}