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:
@@ -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">
|
||||||
|
|||||||
@@ -10,39 +10,25 @@ 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
|
||||||
|
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.admin
|
||||||
|
if (has('attendance.admin')) {
|
||||||
|
const [todayAttendance, onLeaveToday, usersCount] = await Promise.all([
|
||||||
prisma.attendance.findMany({
|
prisma.attendance.findMany({
|
||||||
where: {
|
where: {
|
||||||
shift_date: { gte: todayStart, lt: todayEnd },
|
shift_date: { gte: todayStart, lt: todayEnd },
|
||||||
@@ -51,8 +37,6 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
|
|||||||
include: { users: { select: { id: true, first_name: true, last_name: true } } },
|
include: { users: { select: { id: true, first_name: true, last_name: true } } },
|
||||||
orderBy: { arrival_time: 'asc' },
|
orderBy: { arrival_time: 'asc' },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Users on leave today (attendance records with leave type)
|
|
||||||
prisma.attendance.findMany({
|
prisma.attendance.findMany({
|
||||||
where: {
|
where: {
|
||||||
shift_date: { gte: todayStart, lt: todayEnd },
|
shift_date: { gte: todayStart, lt: todayEnd },
|
||||||
@@ -60,57 +44,9 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
|
|||||||
},
|
},
|
||||||
include: { users: { select: { id: true, first_name: true, last_name: true } } },
|
include: { users: { select: { id: true, first_name: true, last_name: true } } },
|
||||||
}),
|
}),
|
||||||
|
prisma.users.count({ where: { is_active: 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' },
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 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]>();
|
const userAttendanceMap = new Map<number, typeof todayAttendance[0]>();
|
||||||
for (const a of todayAttendance) {
|
for (const a of todayAttendance) {
|
||||||
const existing = userAttendanceMap.get(a.users.id);
|
const existing = userAttendanceMap.get(a.users.id);
|
||||||
@@ -125,24 +61,16 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
|
|||||||
status: string; arrived_at: string | null; leave_type?: string;
|
status: string; arrived_at: string | null; leave_type?: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
// Work records — deduplicate by user, determine status
|
|
||||||
for (const a of userAttendanceMap.values()) {
|
for (const a of userAttendanceMap.values()) {
|
||||||
const user = a.users;
|
const user = a.users;
|
||||||
const firstInitial = user.first_name?.charAt(0) ?? '';
|
const firstInitial = user.first_name?.charAt(0) ?? '';
|
||||||
const lastInitial = user.last_name?.charAt(0) ?? '';
|
const lastInitial = user.last_name?.charAt(0) ?? '';
|
||||||
|
let status = 'out';
|
||||||
let status: string = 'out';
|
|
||||||
if (a.arrival_time) {
|
if (a.arrival_time) {
|
||||||
if (a.departure_time) {
|
if (a.departure_time) status = 'out';
|
||||||
status = 'out';
|
else if (a.break_start && !a.break_end) status = 'away';
|
||||||
} else if (a.break_start && !a.break_end) {
|
else { status = 'in'; presentCount++; }
|
||||||
status = 'away';
|
|
||||||
} else {
|
|
||||||
status = 'in';
|
|
||||||
presentCount++;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
attendanceUsers.push({
|
attendanceUsers.push({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
name: `${user.first_name} ${user.last_name}`,
|
name: `${user.first_name} ${user.last_name}`,
|
||||||
@@ -152,101 +80,117 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave records — add users on leave with status 'leave' + leave_type (matching PHP)
|
|
||||||
const leaveUserIds = new Set<number>();
|
const leaveUserIds = new Set<number>();
|
||||||
for (const a of onLeaveToday) {
|
for (const a of onLeaveToday) {
|
||||||
if (leaveUserIds.has(a.users.id)) continue; // deduplicate
|
if (leaveUserIds.has(a.users.id)) continue;
|
||||||
leaveUserIds.add(a.users.id);
|
leaveUserIds.add(a.users.id);
|
||||||
const user = a.users;
|
const user = a.users;
|
||||||
const firstInitial = user.first_name?.charAt(0) ?? '';
|
|
||||||
const lastInitial = user.last_name?.charAt(0) ?? '';
|
|
||||||
attendanceUsers.push({
|
attendanceUsers.push({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
name: `${user.first_name} ${user.last_name}`,
|
name: `${user.first_name} ${user.last_name}`,
|
||||||
initials: `${firstInitial}${lastInitial}`.toUpperCase(),
|
initials: `${user.first_name?.charAt(0) ?? ''}${user.last_name?.charAt(0) ?? ''}`.toUpperCase(),
|
||||||
status: 'leave',
|
status: 'leave',
|
||||||
arrived_at: null,
|
arrived_at: null,
|
||||||
leave_type: (a.leave_type as string) || 'vacation',
|
leave_type: (a.leave_type as string) || 'vacation',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute invoice revenue this month grouped by currency
|
result.attendance = {
|
||||||
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, {
|
|
||||||
// Existing counts
|
|
||||||
users_count: usersCount,
|
|
||||||
active_projects: activeProjectsCount,
|
|
||||||
pending_orders: pendingOrdersCount,
|
|
||||||
unpaid_invoices: unpaidInvoicesCount,
|
|
||||||
pending_leave_requests: pendingLeaveRequests,
|
|
||||||
|
|
||||||
// Attendance data
|
|
||||||
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
|
// Invoices — only for invoices.view
|
||||||
projects: {
|
if (has('invoices.view')) {
|
||||||
active_projects: activeProjectsList.map((p) => ({
|
const [unpaidCount, issuedThisMonth] = await Promise.all([
|
||||||
id: p.id,
|
prisma.invoices.count({ where: { status: 'issued' } }),
|
||||||
name: p.name ?? '',
|
prisma.invoices.findMany({
|
||||||
customer_name: p.customers?.name ?? null,
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user