fix: dashboard — gate all sections by user permissions

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) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 18:51:29 +01:00
parent a1c70ba25f
commit bcad377f92
2 changed files with 169 additions and 223 deletions

View File

@@ -19,7 +19,7 @@ const API_BASE = '/api/admin'
type DashData = Record<string, any> type DashData = Record<string, any>
export default function Dashboard() { export default function Dashboard() {
const { user, updateUser } = useAuth() const { user, updateUser, hasPermission } = useAuth()
const alert = useAlert() const alert = useAlert()
const [dashData, setDashData] = useState<DashData | null>(null) const [dashData, setDashData] = useState<DashData | null>(null)
@@ -279,8 +279,10 @@ export default function Dashboard() {
</div> </div>
)} )}
{/* KPI cards */} {/* KPI cards — only show if user has any admin-level permissions */}
{!dashLoading && <DashKpiCards dashData={dashData} />} {!dashLoading && (hasPermission('offers.view') || hasPermission('invoices.view') || hasPermission('projects.view') || hasPermission('orders.view')) && (
<DashKpiCards dashData={dashData} />
)}
{/* Quick actions */} {/* Quick actions */}
{!dashLoading && ( {!dashLoading && (
@@ -298,9 +300,9 @@ export default function Dashboard() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }} transition={{ duration: 0.25, delay: 0.12 }}
> >
<DashActivityFeed activities={dashData?.recent_activity} /> {hasPermission('settings.audit') && <DashActivityFeed activities={dashData?.recent_activity} />}
<DashAttendanceToday attendance={dashData?.attendance} /> {hasPermission('attendance.admin') && <DashAttendanceToday attendance={dashData?.attendance} />}
{/* Pravy sloupec: projekty + nabidky */} {/* Pravy sloupec: projekty + nabidky */}
<div className="dash-right-col"> <div className="dash-right-col">

View File

@@ -10,243 +10,187 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 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 [ const result: Record<string, unknown> = {};
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' } }),
// Attendance: today's WORK records with user info // My shift — always available for authenticated users with attendance.record
prisma.attendance.findMany({ if (has('attendance.record')) {
where: { const myShift = await prisma.attendance.findFirst({
shift_date: { gte: todayStart, lt: todayEnd }, where: { user_id: userId, arrival_time: { not: null }, departure_time: null },
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,
},
orderBy: { created_at: 'desc' }, orderBy: { created_at: 'desc' },
}), });
result.my_shift = { has_ongoing: myShift !== null };
// 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<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; // Attendance admin — only for attendance.admin
const attendanceUsers: Array<{ if (has('attendance.admin')) {
user_id: number; name: string; initials: string; const [todayAttendance, onLeaveToday, usersCount] = await Promise.all([
status: string; arrived_at: string | null; leave_type?: string; 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 const userAttendanceMap = new Map<number, typeof todayAttendance[0]>();
for (const a of userAttendanceMap.values()) { for (const a of todayAttendance) {
const user = a.users; const existing = userAttendanceMap.get(a.users.id);
const firstInitial = user.first_name?.charAt(0) ?? ''; if (!existing || (a.arrival_time && existing.arrival_time && a.arrival_time > existing.arrival_time)) {
const lastInitial = user.last_name?.charAt(0) ?? ''; userAttendanceMap.set(a.users.id, a);
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++;
} }
} }
attendanceUsers.push({ let presentCount = 0;
user_id: user.id, const attendanceUsers: Array<{
name: `${user.first_name} ${user.last_name}`, user_id: number; name: string; initials: string;
initials: `${firstInitial}${lastInitial}`.toUpperCase(), status: string; arrived_at: string | null; leave_type?: string;
status, }> = [];
arrived_at: a.arrival_time ? a.arrival_time.toISOString() : null,
});
}
// Leave records — add users on leave with status 'leave' + leave_type (matching PHP) for (const a of userAttendanceMap.values()) {
const leaveUserIds = new Set<number>(); const user = a.users;
for (const a of onLeaveToday) { const firstInitial = user.first_name?.charAt(0) ?? '';
if (leaveUserIds.has(a.users.id)) continue; // deduplicate const lastInitial = user.last_name?.charAt(0) ?? '';
leaveUserIds.add(a.users.id); let status = 'out';
const user = a.users; if (a.arrival_time) {
const firstInitial = user.first_name?.charAt(0) ?? ''; if (a.departure_time) status = 'out';
const lastInitial = user.last_name?.charAt(0) ?? ''; else if (a.break_start && !a.break_end) status = 'away';
attendanceUsers.push({ else { status = 'in'; presentCount++; }
user_id: user.id, }
name: `${user.first_name} ${user.last_name}`, attendanceUsers.push({
initials: `${firstInitial}${lastInitial}`.toUpperCase(), user_id: user.id,
status: 'leave', name: `${user.first_name} ${user.last_name}`,
arrived_at: null, initials: `${firstInitial}${lastInitial}`.toUpperCase(),
leave_type: (a.leave_type as string) || 'vacation', status,
}); arrived_at: a.arrival_time ? a.arrival_time.toISOString() : null,
} });
// Compute invoice revenue this month grouped by currency
const revenueByCurrency: Record<string, number> = {};
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;
} }
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, { const leaveUserIds = new Set<number>();
// Existing counts for (const a of onLeaveToday) {
users_count: usersCount, if (leaveUserIds.has(a.users.id)) continue;
active_projects: activeProjectsCount, leaveUserIds.add(a.users.id);
pending_orders: pendingOrdersCount, const user = a.users;
unpaid_invoices: unpaidInvoicesCount, attendanceUsers.push({
pending_leave_requests: pendingLeaveRequests, 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 result.attendance = {
attendance: {
present_today: presentCount, present_today: presentCount,
total_active: usersCount, total_active: usersCount,
on_leave: leaveUserIds.size, on_leave: leaveUserIds.size,
users: attendanceUsers, users: attendanceUsers,
}, };
result.users_count = usersCount;
}
// Offers/quotations stats // Offers — only for offers.view
offers: { if (has('offers.view')) {
open_count: openQuotations, const [openCount, convertedCount, expiredCount, createdThisMonth] = await Promise.all([
converted_count: convertedQuotations, prisma.quotations.count({ where: { status: 'active' } }),
expired_count: expiredQuotations, prisma.quotations.count({ where: { status: 'converted' } }),
created_this_month: quotationsThisMonth, 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 // Projects — only for projects.view
invoices: { if (has('projects.view')) {
revenue_this_month: revenueThisMonth, const [activeCount, activeList] = await Promise.all([
unpaid_count: unpaidInvoicesCount, prisma.projects.count({ where: { status: 'aktivni' } }),
revenue_czk: revenueCzk, prisma.projects.findMany({
}, where: { status: 'aktivni' },
include: { customers: { select: { name: true } } },
// Leave pending orderBy: { created_at: 'desc' },
leave_pending: { count: pendingLeaveRequests }, take: 5,
}),
// Current user's shift status ]);
my_shift: { result.active_projects = activeCount;
has_ongoing: myShiftToday !== null, result.projects = {
}, active_projects: activeList.map(p => ({
id: p.id, name: p.name ?? '', customer_name: p.customers?.name ?? 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,
})), })),
}, };
}); }
// 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<string, number> = {};
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);
}); });
} }