Files
app/src/routes/admin/dashboard.ts
BOHA 5233db2002 v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix
Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
2026-06-03 23:13:10 +02:00

272 lines
9.0 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";
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 };
}
// Attendance admin — only for attendance.manage
if (has("attendance.manage")) {
const [todayAttendance, onLeaveToday, usersCount] = 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.count({ where: { is_active: true } }),
]);
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",
});
}
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: { 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}
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);
});
}