inspiren-sem-tool/resources/js/components/app-header.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

467 lines
22 KiB
TypeScript

import { Breadcrumbs } from '@/components/breadcrumbs';
import { Icon } from '@/components/icon';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuList,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { UserMenuContent } from '@/components/user-menu-content';
import { useInitials } from '@/hooks/use-initials';
import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
import { dashboard } from '@/routes';
import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { Bell, BookOpen, Folder, LayoutGrid, Menu, Search, UserPlus } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import AppLogo from './app-logo';
import AppLogoIcon from './app-logo-icon';
const mainNavItems: NavItem[] = [
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
];
const rightNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/react-starter-kit',
icon: Folder,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#react',
icon: BookOpen,
},
];
const activeItemStyles =
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
interface AppHeaderProps {
breadcrumbs?: BreadcrumbItem[];
}
interface PendingInvoiceNotification {
id: number;
client_id: number | null;
invoice_no: string;
pending_sql_acc_code?: string | null;
pending_client_name?: string | null;
requires_client?: boolean;
start_date: string | null;
end_date: string | null;
management_fee: string | number | null;
media_fee: string | number | null;
nett_amount: string | number | null;
total_spending: string | number | null;
client?: {
name: string | null;
customer_id: string | null;
} | null;
}
function InvoiceNotifications() {
const [invoices, setInvoices] = useState<PendingInvoiceNotification[]>([]);
const [count, setCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const currencyFormatter = useMemo(
() =>
new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
[],
);
useEffect(() => {
let isMounted = true;
const loadPendingInvoices = async () => {
try {
const response = await fetch('/api/customer-invoices/pending', {
headers: {
Accept: 'application/json',
},
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error('Unable to load pending invoices.');
}
const data = await response.json();
if (isMounted) {
setInvoices(data.invoices ?? []);
setCount(data.count ?? 0);
}
} catch {
if (isMounted) {
setInvoices([]);
setCount(0);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadPendingInvoices();
return () => {
isMounted = false;
};
}, []);
const formatAmount = (amount: string | number | null) => {
const value = Number(amount ?? 0);
return currencyFormatter.format(Number.isFinite(value) ? value : 0);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group relative h-9 w-9 cursor-pointer"
>
<Bell className="!size-5 opacity-80 group-hover:opacity-100" />
{count > 0 && (
<span className="absolute -top-1 -right-1 flex min-w-5 items-center justify-center rounded-full bg-red-600 px-1.5 text-[11px] leading-5 font-semibold text-white">
{count > 99 ? '99+' : count}
</span>
)}
<span className="sr-only">Pending invoice approvals</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[min(92vw,720px)] p-0"
align="end"
>
<div className="border-b px-4 py-3">
<div className="text-sm font-semibold">
Pending invoice approvals
</div>
<div className="text-xs text-muted-foreground">
{count} invoice{count === 1 ? '' : 's'} waiting for
approval
</div>
</div>
<div className="max-h-[420px] overflow-auto">
{isLoading ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
Loading pending invoices...
</div>
) : invoices.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
No invoices are waiting for approval.
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-popover text-xs text-muted-foreground">
<tr className="border-b">
<th className="px-4 py-2 text-left font-medium">
Invoice
</th>
<th className="px-4 py-2 text-left font-medium">
Client
</th>
<th className="px-4 py-2 text-left font-medium">
SQL Acc
</th>
<th className="px-4 py-2 text-right font-medium">
Media
</th>
<th className="px-4 py-2 text-right font-medium">
Nett
</th>
<th className="px-4 py-2 text-left font-medium">
Period
</th>
<th className="px-4 py-2 text-right font-medium">
Action
</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice) => (
<tr
key={invoice.id}
className="border-b last:border-b-0 hover:bg-accent/50"
>
<td className="px-4 py-2 font-medium">
<Link
href={
invoice.requires_client
? `/client-invoices/${invoice.id}/client/create`
: `/client-invoices/${invoice.id}/edit`
}
className="text-primary hover:underline"
>
{invoice.invoice_no}
</Link>
</td>
<td className="px-4 py-2 text-muted-foreground">
{invoice.client?.name ?? invoice.pending_client_name ?? '-'}
</td>
<td className="px-4 py-2 text-muted-foreground">
{invoice.pending_sql_acc_code ?? '-'}
</td>
<td className="px-4 py-2 text-right tabular-nums">
{formatAmount(invoice.media_fee)}
</td>
<td className="px-4 py-2 text-right tabular-nums">
{formatAmount(invoice.nett_amount)}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
{invoice.start_date ?? '-'} to{' '}
{invoice.end_date ?? '-'}
</td>
<td className="px-4 py-2 text-right">
{invoice.requires_client ? (
<Link
href={`/client-invoices/${invoice.id}/client/create`}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
<UserPlus className="h-3.5 w-3.5" />
Link client
</Link>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
const page = usePage<SharedData>();
const { auth } = page.props;
const getInitials = useInitials();
return (
<>
<div className="border-b border-sidebar-border/80">
<div className="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
{/* Mobile Menu */}
<div className="lg:hidden">
<Sheet>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="mr-2 h-[34px] w-[34px]"
>
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="flex h-full w-64 flex-col items-stretch justify-between bg-sidebar"
>
<SheetTitle className="sr-only">
Navigation Menu
</SheetTitle>
<SheetHeader className="flex justify-start text-left">
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
</SheetHeader>
<div className="flex h-full flex-1 flex-col space-y-4 p-4">
<div className="flex h-full flex-col justify-between text-sm">
<div className="flex flex-col space-y-4">
{mainNavItems.map((item) => (
<Link
key={item.title}
href={item.href}
className="flex items-center space-x-2 font-medium"
>
{item.icon && (
<Icon
iconNode={item.icon}
className="h-5 w-5"
/>
)}
<span>{item.title}</span>
</Link>
))}
</div>
<div className="flex flex-col space-y-4">
{rightNavItems.map((item) => (
<a
key={item.title}
href={resolveUrl(item.href)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 font-medium"
>
{item.icon && (
<Icon
iconNode={item.icon}
className="h-5 w-5"
/>
)}
<span>{item.title}</span>
</a>
))}
</div>
</div>
</div>
</SheetContent>
</Sheet>
</div>
<Link
href={dashboard()}
prefetch
className="flex items-center space-x-2"
>
<AppLogo />
</Link>
{/* Desktop Navigation */}
<div className="ml-6 hidden h-full items-center space-x-6 lg:flex">
<NavigationMenu className="flex h-full items-stretch">
<NavigationMenuList className="flex h-full items-stretch space-x-2">
{mainNavItems.map((item, index) => (
<NavigationMenuItem
key={index}
className="relative flex h-full items-center"
>
<Link
href={item.href}
className={cn(
navigationMenuTriggerStyle(),
isSameUrl(
page.url,
item.href,
) && activeItemStyles,
'h-9 cursor-pointer px-3',
)}
>
{item.icon && (
<Icon
iconNode={item.icon}
className="mr-2 h-4 w-4"
/>
)}
{item.title}
</Link>
{isSameUrl(page.url, item.href) && (
<div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"></div>
)}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
</div>
<div className="ml-auto flex items-center space-x-2">
<div className="relative flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
className="group h-9 w-9 cursor-pointer"
>
<Search className="!size-5 opacity-80 group-hover:opacity-100" />
</Button>
<div className="hidden lg:flex">
{rightNavItems.map((item) => (
<TooltipProvider
key={item.title}
delayDuration={0}
>
<Tooltip>
<TooltipTrigger>
<a
href={resolveUrl(item.href)}
target="_blank"
rel="noopener noreferrer"
className="group ml-1 inline-flex h-9 w-9 items-center justify-center rounded-md bg-transparent p-0 text-sm font-medium text-accent-foreground ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
>
<span className="sr-only">
{item.title}
</span>
{item.icon && (
<Icon
iconNode={item.icon}
className="size-5 opacity-80 group-hover:opacity-100"
/>
)}
</a>
</TooltipTrigger>
<TooltipContent>
<p>{item.title}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
</div>
<InvoiceNotifications />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="size-10 rounded-full p-1"
>
<Avatar className="size-8 overflow-hidden rounded-full">
<AvatarImage
src={auth.user.avatar}
alt={auth.user.name}
/>
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
{getInitials(auth.user.name)}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<UserMenuContent user={auth.user} />
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* {breadcrumbs.length > 1 && ( */}
<div className="flex w-full border-b border-sidebar-border/70">
<div className="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl">
<Breadcrumbs breadcrumbs={breadcrumbs} />
</div>
</div>
{/* )} */}
</>
);
}