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(); 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 = {}; // 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 "Můj plán na dnešek" card. // resolveCell applies override-beats-range precedence; null = the user has // plan access but nothing is scheduled today. The 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 cell = await resolveCell(userId, todayStr); if (cell) { const cat = await prisma.plan_categories.findUnique({ where: { key: cell.category }, select: { label: true, color: true }, }); result.today_plan = { ...cell, category_label: cat?.label ?? cell.category, category_color: cat?.color ?? null, }; } else { result.today_plan = 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(); 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", }); } 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 = {}; 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); }); }