initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
252
src/routes/admin/dashboard.ts
Normal file
252
src/routes/admin/dashboard.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requireAuth } from '../../middleware/auth';
|
||||
import { success } from '../../utils/response';
|
||||
|
||||
export default async function dashboardRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
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 userId = request.authData!.userId;
|
||||
|
||||
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' } }),
|
||||
|
||||
// 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,
|
||||
},
|
||||
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]>();
|
||||
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;
|
||||
}> = [];
|
||||
|
||||
// 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++;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Leave records — add users on leave with status 'leave' + leave_type (matching PHP)
|
||||
const leaveUserIds = new Set<number>();
|
||||
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<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,
|
||||
total_active: usersCount,
|
||||
on_leave: leaveUserIds.size,
|
||||
users: attendanceUsers,
|
||||
},
|
||||
|
||||
// Offers/quotations stats
|
||||
offers: {
|
||||
open_count: openQuotations,
|
||||
converted_count: convertedQuotations,
|
||||
expired_count: expiredQuotations,
|
||||
created_this_month: quotationsThisMonth,
|
||||
},
|
||||
|
||||
// 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,
|
||||
})),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user