467 lines
22 KiB
TypeScript
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>
|
|
{/* )} */}
|
|
</>
|
|
);
|
|
}
|