From bcad377f92389e63a51c838a738300470fc9b478 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 23 Mar 2026 18:51:29 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20dashboard=20=E2=80=94=20gate=20all=20sec?= =?UTF-8?q?tions=20by=20user=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API now only returns data sections the user has permission to see: - my_shift: attendance.record - attendance: attendance.admin - offers: offers.view - projects: projects.view - invoices: invoices.view - orders: orders.view - leave_pending: attendance.approve - recent_activity: settings.audit Frontend hides KPI cards, activity feed, and attendance sections for users without the matching permissions. Regular employees now only see their shift status, quick actions, profile, and sessions — not company KPIs or admin data. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/admin/pages/Dashboard.tsx | 12 +- src/routes/admin/dashboard.ts | 380 +++++++++++++++------------------- 2 files changed, 169 insertions(+), 223 deletions(-) diff --git a/src/admin/pages/Dashboard.tsx b/src/admin/pages/Dashboard.tsx index 8d06e80..0a71f3c 100644 --- a/src/admin/pages/Dashboard.tsx +++ b/src/admin/pages/Dashboard.tsx @@ -19,7 +19,7 @@ const API_BASE = '/api/admin' type DashData = Record export default function Dashboard() { - const { user, updateUser } = useAuth() + const { user, updateUser, hasPermission } = useAuth() const alert = useAlert() const [dashData, setDashData] = useState(null) @@ -279,8 +279,10 @@ export default function Dashboard() { )} - {/* KPI cards */} - {!dashLoading && } + {/* KPI cards — only show if user has any admin-level permissions */} + {!dashLoading && (hasPermission('offers.view') || hasPermission('invoices.view') || hasPermission('projects.view') || hasPermission('orders.view')) && ( + + )} {/* Quick actions */} {!dashLoading && ( @@ -298,9 +300,9 @@ export default function Dashboard() { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.12 }} > - + {hasPermission('settings.audit') && } - + {hasPermission('attendance.admin') && } {/* Pravy sloupec: projekty + nabidky */}
diff --git a/src/routes/admin/dashboard.ts b/src/routes/admin/dashboard.ts index b2f4039..f43ebd4 100644 --- a/src/routes/admin/dashboard.ts +++ b/src/routes/admin/dashboard.ts @@ -10,243 +10,187 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise 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 userId = request.authData!.userId; + const authData = request.authData!; + const userId = authData.userId; + const perms = authData.permissions; + const has = (p: string) => perms.includes(p); - const [ - usersCount, - activeProjectsCount, - pendingOrdersCount, - unpaidInvoicesCount, - pendingLeaveRequests, - // Attendance - todayAttendance, - onLeaveToday, - // Offers / quotations - openQuotations, - convertedQuotations, - expiredQuotations, - quotationsThisMonth, - // Invoices - issuedInvoicesThisMonth, - // My shift - myShiftToday, - // Recent activity - recentActivity, - // Active projects list - activeProjectsList, - ] = await Promise.all([ - // Existing counts - prisma.users.count({ where: { is_active: true } }), - prisma.projects.count({ where: { status: 'aktivni' } }), - prisma.orders.count({ where: { status: 'prijata' } }), - prisma.invoices.count({ where: { status: 'issued' } }), - prisma.leave_requests.count({ where: { status: 'pending' } }), + const result: Record = {}; - // Attendance: today's WORK records with user info - 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' }, - }), - - // Users on leave today (attendance records with leave type) - prisma.attendance.findMany({ - where: { - shift_date: { gte: todayStart, lt: todayEnd }, - leave_type: { in: ['vacation', 'sick', 'holiday', 'unpaid'] }, - }, - include: { users: { select: { id: true, first_name: true, last_name: true } } }, - }), - - // Quotation stats - prisma.quotations.count({ where: { status: 'active' } }), - prisma.quotations.count({ where: { status: 'converted' } }), - prisma.quotations.count({ where: { status: 'expired' } }), - prisma.quotations.count({ - where: { created_at: { gte: monthStart, lt: monthEnd } }, - }), - - // Invoice stats — this month's invoices - prisma.invoices.findMany({ - where: { - issue_date: { gte: monthStart, lt: monthEnd }, - }, - include: { invoice_items: true }, - }), - - // My active (ongoing) shift — any unclosed shift, not just today - prisma.attendance.findFirst({ - where: { - user_id: userId, - arrival_time: { not: null }, - departure_time: null, - }, + // 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' }, - }), - - // Recent audit log activity (last 10) - prisma.audit_logs.findMany({ - orderBy: { created_at: 'desc' }, - take: 10, - select: { - id: true, - action: true, - entity_type: true, - description: true, - username: true, - created_at: true, - }, - }), - - // Active projects with customer - prisma.projects.findMany({ - where: { status: 'aktivni' }, - include: { customers: { select: { name: true } } }, - orderBy: { created_at: 'desc' }, - }), - ]); - - // Build attendance users list — deduplicate by user_id, keep latest record per user - // Match PHP status logic: in = working, away = on break, out = departed - 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); - } + }); + result.my_shift = { has_ongoing: myShift !== null }; } - let presentCount = 0; - const attendanceUsers: Array<{ - user_id: number; name: string; initials: string; - status: string; arrived_at: string | null; leave_type?: string; - }> = []; + // Attendance admin — only for attendance.admin + if (has('attendance.admin')) { + 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', 'holiday', 'unpaid'] }, + }, + include: { users: { select: { id: true, first_name: true, last_name: true } } }, + }), + prisma.users.count({ where: { is_active: true } }), + ]); - // Work records — deduplicate by user, determine status - 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: string = '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++; + 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); } } - attendanceUsers.push({ - user_id: user.id, - name: `${user.first_name} ${user.last_name}`, - initials: `${firstInitial}${lastInitial}`.toUpperCase(), - status, - arrived_at: a.arrival_time ? a.arrival_time.toISOString() : null, - }); - } + let presentCount = 0; + const attendanceUsers: Array<{ + user_id: number; name: string; initials: string; + status: string; arrived_at: string | null; leave_type?: string; + }> = []; - // Leave records — add users on leave with status 'leave' + leave_type (matching PHP) - const leaveUserIds = new Set(); - for (const a of onLeaveToday) { - if (leaveUserIds.has(a.users.id)) continue; // deduplicate - leaveUserIds.add(a.users.id); - const user = a.users; - const firstInitial = user.first_name?.charAt(0) ?? ''; - const lastInitial = user.last_name?.charAt(0) ?? ''; - attendanceUsers.push({ - user_id: user.id, - name: `${user.first_name} ${user.last_name}`, - initials: `${firstInitial}${lastInitial}`.toUpperCase(), - status: 'leave', - arrived_at: null, - leave_type: (a.leave_type as string) || 'vacation', - }); - } - - // Compute invoice revenue this month grouped by currency - const revenueByCurrency: Record = {}; - for (const inv of issuedInvoicesThisMonth) { - const currency = inv.currency ?? 'CZK'; - let total = 0; - for (const item of inv.invoice_items) { - const qty = item.quantity ? Number(item.quantity) : 0; - const price = item.unit_price ? Number(item.unit_price) : 0; - total += qty * price; + 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 ? a.arrival_time.toISOString() : null, + }); } - revenueByCurrency[currency] = (revenueByCurrency[currency] ?? 0) + total; - } - const revenueThisMonth = Object.entries(revenueByCurrency).map(([currency, amount]) => ({ - amount: Math.round(amount * 100) / 100, - currency, - })); - const revenueCzk = revenueByCurrency['CZK'] != null - ? Math.round(revenueByCurrency['CZK'] * 100) / 100 - : null; - return success(reply, { - // Existing counts - users_count: usersCount, - active_projects: activeProjectsCount, - pending_orders: pendingOrdersCount, - unpaid_invoices: unpaidInvoicesCount, - pending_leave_requests: pendingLeaveRequests, + 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', + }); + } - // Attendance data - attendance: { + result.attendance = { present_today: presentCount, total_active: usersCount, on_leave: leaveUserIds.size, users: attendanceUsers, - }, + }; + result.users_count = usersCount; + } - // Offers/quotations stats - offers: { - open_count: openQuotations, - converted_count: convertedQuotations, - expired_count: expiredQuotations, - created_this_month: quotationsThisMonth, - }, + // 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: 'converted' } }), + prisma.quotations.count({ where: { status: 'expired' } }), + 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 }; + } - // Invoice revenue - invoices: { - revenue_this_month: revenueThisMonth, - unpaid_count: unpaidInvoicesCount, - revenue_czk: revenueCzk, - }, - - // Leave pending - leave_pending: { count: pendingLeaveRequests }, - - // Current user's shift status - my_shift: { - has_ongoing: myShiftToday !== null, - }, - - // Recent audit log activity - recent_activity: recentActivity.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 ? log.created_at.toISOString() : '', - })), - - // Active projects list - projects: { - active_projects: activeProjectsList.map((p) => ({ - id: p.id, - name: p.name ?? '', - customer_name: p.customers?.name ?? null, + // 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')) { + const [unpaidCount, issuedThisMonth] = await Promise.all([ + prisma.invoices.count({ where: { status: 'issued' } }), + prisma.invoices.findMany({ + where: { issue_date: { gte: monthStart, lt: monthEnd } }, + include: { invoice_items: true }, + }), + ]); + + const revenueByCurrency: Record = {}; + for (const inv of issuedThisMonth) { + const currency = inv.currency ?? 'CZK'; + let total = 0; + for (const item of inv.invoice_items) { + total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); + } + revenueByCurrency[currency] = (revenueByCurrency[currency] ?? 0) + total; + } + + result.invoices = { + revenue_this_month: Object.entries(revenueByCurrency).map(([currency, amount]) => ({ + amount: Math.round(amount * 100) / 100, currency, + })), + unpaid_count: unpaidCount, + revenue_czk: revenueByCurrency['CZK'] != null ? Math.round(revenueByCurrency['CZK'] * 100) / 100 : null, + }; + result.unpaid_invoices = unpaidCount; + } + + // 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 }; + result.pending_leave_requests = 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 ? log.created_at.toISOString() : '', + })); + } + + return success(reply, result); }); }