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 { 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 = {}; // 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(); 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(); 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([ ...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 = {}; 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); }); }