inspiren-sem-tool/resources/js/pages/dashboard.tsx
brian-inspiren 221d3f8173
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
feat: sem codebase
2026-05-21 11:28:03 +08:00

430 lines
19 KiB
TypeScript

import React from 'react';
import AppLayout from '../layouts/app-layout';
import {
Badge,
Box,
Card,
Container,
Button,
Divider,
Group,
Modal,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconBuilding,
IconChecklist,
IconFileInvoice,
IconLinkOff,
} from '@tabler/icons-react';
type DashboardStats = {
totalClients: number;
pendingInvoices: number;
unlinkedPendingInvoices: number;
pendingActivities: number;
clientsMissingSqlCode: number;
};
type RecentInvoice = {
id: number;
invoice_no: string;
approved_at: string | null;
management_fee: string | number | null;
media_fee: string | number | null;
nett_amount: string | number | null;
created_at: string | null;
client: {
name: string;
customer_id: string;
} | null;
};
type PendingActivity = {
id: number;
activity_no: string;
activity_type: string | null;
task_description: string | null;
estimated_completed_at: string | null;
assigned_person: string | null;
client: {
name: string;
customer_id: string;
} | null;
};
interface Props {
stats: DashboardStats;
clientStatusCounts: Record<string, number>;
recentInvoices: RecentInvoice[];
pendingActivities: PendingActivity[];
}
const formatAmount = (value: string | number | null | undefined) => {
const amount = Number(value ?? 0);
return new Intl.NumberFormat('en-MY', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(Number.isFinite(amount) ? amount : 0);
};
const statusColor = (status: string) => {
if (status === 'ENABLED') return 'green';
if (status === 'PAUSED') return 'yellow';
if (status === 'CANCELED' || status === 'ENDED') return 'red';
return 'gray';
};
const textPreview = (html: string | null) => {
if (!html) return '-';
const text = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
return text.length > 72 ? `${text.slice(0, 72)}...` : text || '-';
};
const daysUntil = (date: string | null) => {
if (!date) {
return { label: '-', color: 'gray' };
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const dueDate = new Date(`${date}T00:00:00`);
const diff = Math.ceil((dueDate.getTime() - today.getTime()) / 86400000);
if (Number.isNaN(diff)) {
return { label: '-', color: 'gray' };
}
if (diff < 0) {
return { label: `${Math.abs(diff)} day${Math.abs(diff) === 1 ? '' : 's'} overdue`, color: 'red' };
}
if (diff === 0) {
return { label: 'Due today', color: 'orange' };
}
return { label: `${diff} day${diff === 1 ? '' : 's'} left`, color: diff <= 3 ? 'yellow' : 'green' };
};
export default function Dashboard({
stats,
clientStatusCounts,
recentInvoices,
pendingActivities,
}: Props) {
const [selectedActivity, setSelectedActivity] = React.useState<PendingActivity | null>(null);
const statCards = [
{
label: 'Visible Clients',
value: stats.totalClients.toLocaleString('en-MY'),
detail: 'Based on your hierarchy access',
icon: IconBuilding,
color: 'blue',
},
{
label: 'Pending Invoices',
value: stats.pendingInvoices.toLocaleString('en-MY'),
detail:
stats.unlinkedPendingInvoices > 0
? `${stats.unlinkedPendingInvoices} need client linking`
: 'Ready for review',
icon: IconFileInvoice,
color: 'orange',
},
{
label: 'Pending Activities',
value: stats.pendingActivities.toLocaleString('en-MY'),
detail: 'Open scheduled work',
icon: IconChecklist,
color: 'violet',
},
{
label: 'Missing SQL Code',
value: stats.clientsMissingSqlCode.toLocaleString('en-MY'),
detail: 'Synced clients not linked to SQL',
icon: IconLinkOff,
color: 'red',
},
];
return (
<AppLayout>
<Container size="xl" px="xs">
<Stack spacing="xl">
<Paper
withBorder
radius="md"
p="lg"
sx={(theme) => ({
background:
theme.colorScheme === 'dark'
? theme.colors.dark[7]
: theme.colors.gray[0],
})}
>
<Group position="apart" align="flex-start">
<Stack spacing={4}>
<Text size="xs" color="dimmed" transform="uppercase" weight={700}>
Overview
</Text>
<Title order={2}>Dashboard</Title>
<Text color="dimmed" size="sm">
Your client, invoice, and activity overview follows the same hierarchy access as the accounts page.
</Text>
</Stack>
<Badge size="lg" variant="light" color="blue">
Hierarchy filtered
</Badge>
</Group>
</Paper>
<SimpleGrid cols={4} breakpoints={[
{ maxWidth: 'lg', cols: 2 },
{ maxWidth: 'md', cols: 2 },
{ maxWidth: 'xs', cols: 1 },
]}>
{statCards.map((item) => {
const Icon = item.icon;
return (
<Card
key={item.label}
withBorder
shadow="sm"
radius="md"
p="lg"
sx={(theme) => ({
position: 'relative',
overflow: 'hidden',
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[6]
: theme.white,
})}
>
<Box
sx={(theme) => ({
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: theme.colors[item.color][6],
})}
/>
<Group position="apart" align="flex-start" noWrap>
<Stack spacing={6}>
<Text size="xs" color="dimmed" transform="uppercase" weight={700}>
{item.label}
</Text>
<Text size={28} weight={800} lh={1}>
{item.value}
</Text>
<Text size="xs" color="dimmed">
{item.detail}
</Text>
</Stack>
<ThemeIcon color={item.color} variant="light" radius="xl" size="lg">
<Icon size={20} />
</ThemeIcon>
</Group>
</Card>
);
})}
</SimpleGrid>
<SimpleGrid cols={2} breakpoints={[{ maxWidth: 'md', cols: 1 }]}>
<Card withBorder shadow="sm" radius="md" p="lg">
<Stack spacing="md">
<Group position="apart">
<Title order={4}>Client Status</Title>
<Text size="sm" color="dimmed">
{stats.totalClients.toLocaleString('en-MY')} total
</Text>
</Group>
<Divider />
{Object.keys(clientStatusCounts).length === 0 ? (
<Text size="sm" color="dimmed">No visible clients.</Text>
) : (
<Stack spacing="xs">
{Object.entries(clientStatusCounts).map(([status, count]) => (
<Paper key={status} withBorder radius="sm" p="sm">
<Group position="apart">
<Group spacing="xs">
<ThemeIcon color={statusColor(status)} variant="light" radius="xl" size="sm">
<span />
</ThemeIcon>
<Text size="sm" weight={600}>{status || 'Unknown'}</Text>
</Group>
<Badge color={statusColor(status)} variant="filled">
{count}
</Badge>
</Group>
</Paper>
))}
</Stack>
)}
</Stack>
</Card>
<Card withBorder shadow="sm" radius="md" p="lg">
<Stack spacing="md">
<Group position="apart">
<Title order={4}>Recent Invoices</Title>
<Badge variant="light">{recentInvoices.length}</Badge>
</Group>
<Divider />
<ScrollArea>
<Table fontSize="sm" verticalSpacing="sm" highlightOnHover>
<thead>
<tr>
<th>Invoice</th>
<th>Client</th>
<th style={{ textAlign: 'right' }}>Nett</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{recentInvoices.length === 0 ? (
<tr>
<td colSpan={4}>
<Text size="sm" color="dimmed" align="center">No invoices found.</Text>
</td>
</tr>
) : recentInvoices.map((invoice) => (
<tr key={invoice.id}>
<td>
<Text weight={700} size="sm">{invoice.invoice_no}</Text>
</td>
<td>
<Text size="sm" lineClamp={1}>{invoice.client?.name ?? '-'}</Text>
</td>
<td style={{ textAlign: 'right' }}>
<Text size="sm" weight={700}>RM {formatAmount(invoice.nett_amount)}</Text>
</td>
<td>
<Badge color={invoice.approved_at ? 'green' : 'orange'} variant="light">
{invoice.approved_at ? 'Approved' : 'Pending'}
</Badge>
</td>
</tr>
))}
</tbody>
</Table>
</ScrollArea>
</Stack>
</Card>
</SimpleGrid>
<Card withBorder shadow="sm" radius="md" p="lg">
<Stack spacing="md">
<Group position="apart">
<Stack spacing={0}>
<Title order={4}>Upcoming Activities</Title>
<Text size="sm" color="dimmed">Sorted by nearest due date</Text>
</Stack>
<Badge color="violet" variant="light">{pendingActivities.length} shown</Badge>
</Group>
<Divider />
<ScrollArea>
<Table fontSize="sm" verticalSpacing="sm" highlightOnHover>
<thead>
<tr>
<th>No</th>
<th>Client</th>
<th>Type</th>
<th>Assigned Person</th>
<th>Task</th>
<th>Due</th>
<th>Days Left</th>
</tr>
</thead>
<tbody>
{pendingActivities.length === 0 ? (
<tr>
<td colSpan={7}>
<Text size="sm" color="dimmed" align="center">No pending activities.</Text>
</td>
</tr>
) : pendingActivities.map((activity) => {
const due = daysUntil(activity.estimated_completed_at);
return (
<tr key={activity.id}>
<td>
<Text size="sm" weight={700}>{activity.activity_no}</Text>
</td>
<td>
<Text size="sm" lineClamp={1}>{activity.client?.name ?? '-'}</Text>
</td>
<td>
<Badge color="gray" variant="light">{activity.activity_type ?? '-'}</Badge>
</td>
<td>{activity.assigned_person ?? '-'}</td>
<td>
<Button
variant="subtle"
compact
onClick={() => setSelectedActivity(activity)}
px={0}
styles={{ label: { whiteSpace: 'normal', textAlign: 'left' } }}
>
{textPreview(activity.task_description)}
</Button>
</td>
<td>{activity.estimated_completed_at ?? '-'}</td>
<td>
<Badge color={due.color} variant="light">
{due.label}
</Badge>
</td>
</tr>
);
})}
</tbody>
</Table>
</ScrollArea>
</Stack>
</Card>
</Stack>
</Container>
<Modal
opened={selectedActivity !== null}
onClose={() => setSelectedActivity(null)}
title={selectedActivity?.activity_no ?? 'Activity'}
size="lg"
>
<Stack spacing="md">
<Group spacing="xs">
<Badge variant="light">{selectedActivity?.client?.name ?? '-'}</Badge>
<Badge color="violet" variant="light">{selectedActivity?.assigned_person ?? 'Unassigned'}</Badge>
<Badge color={daysUntil(selectedActivity?.estimated_completed_at ?? null).color} variant="light">
{daysUntil(selectedActivity?.estimated_completed_at ?? null).label}
</Badge>
</Group>
<Paper withBorder radius="md" p="md">
<div
style={{ lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: selectedActivity?.task_description ?? '-' }}
/>
</Paper>
</Stack>
</Modal>
</AppLayout>
);
}