430 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|