diff --git a/distribution_fe/app/(dashboard)/layout.tsx b/distribution_fe/app/(dashboard)/layout.tsx index a70e37d..8ae0431 100644 --- a/distribution_fe/app/(dashboard)/layout.tsx +++ b/distribution_fe/app/(dashboard)/layout.tsx @@ -2,6 +2,7 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s import { AppSidebar } from '@/components/layout/app-sidebar' import { BottomNav } from '@/components/layout/bottom-nav' import { ThemeToggle } from '@/components/layout/theme-toggle' +import { TopLoader } from '@/components/shared/top-loader' export default function DashboardLayout({ children, @@ -10,6 +11,7 @@ export default function DashboardLayout({ }) { return ( +
diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx index 8789dde..842b9e0 100644 --- a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx +++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { useRouter } from 'next/navigation' import { HugeiconsIcon } from '@hugeicons/react' -import { Add01Icon } from '@hugeicons/core-free-icons' +import { Add01Icon, PackageDeliveredIcon } from '@hugeicons/core-free-icons' import { DataTable } from '@/components/shared/data-table' import type { TableConfig } from '@/components/shared/data-table' import { Button } from '@/components/ui/button' @@ -11,6 +11,7 @@ import { toast } from '@/components/ui/toast' import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index' import type { GrnResponse } from '@/types/api' import { GrnViewSheet } from './grn-view-sheet' +import { EmptyState } from '@/components/shared/empty-state' const fmtDate = (v: string) => new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) @@ -53,7 +54,19 @@ export function GrnClient() { rowKey: 'id', serverSide: true, stickyHeader: true, - emptyMessage: 'No goods receipts found.', + emptyState: ( + router.push('/procurement/grn/new')}> + + New GRN + + } + /> + ), pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, columns: [ { diff --git a/distribution_fe/app/(dashboard)/suppliers/[id]/payments/_components/supplier-payments-client.tsx b/distribution_fe/app/(dashboard)/suppliers/[id]/payments/_components/supplier-payments-client.tsx index 809fc44..0d8781c 100644 --- a/distribution_fe/app/(dashboard)/suppliers/[id]/payments/_components/supplier-payments-client.tsx +++ b/distribution_fe/app/(dashboard)/suppliers/[id]/payments/_components/supplier-payments-client.tsx @@ -30,7 +30,7 @@ import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { toast } from '@/components/ui/toast' -import { TopLoader } from '@/components/shared/top-loader' +import { useTopLoader } from '@/components/shared/top-loader' import { EmptyState } from '@/components/shared/empty-state' import { ReceiptDollarIcon } from '@hugeicons/core-free-icons' @@ -70,11 +70,12 @@ export function SupplierPaymentsClient({ supplierId }: { supplierId: string }) { useEffect(() => { loadPayments() }, [loadPayments]) + useTopLoader(loading) + const pageCount = Math.max(1, Math.ceil(total / pageSize)) return ( -
- +

Supplier Payments

diff --git a/distribution_fe/app/globals.css b/distribution_fe/app/globals.css index bb6547e..ab54b75 100644 --- a/distribution_fe/app/globals.css +++ b/distribution_fe/app/globals.css @@ -135,42 +135,44 @@ border-bottom-color: var(--muted-foreground); } -/* Top loading strip: thin bar sliding across with a neon-red leading dot. */ +/* Top loading strip: fixed-at-top-of-viewport bar sliding across with a + neon-red leading dot. Fixed positioning so no scroll container can clip it. */ .top-loader { pointer-events: none; - position: absolute; + position: fixed; left: 0; right: 0; top: 0; height: 2px; overflow: visible; - z-index: 50; + z-index: 100; } .top-loader__bar { position: absolute; inset: 0; background: linear-gradient( 90deg, - transparent 0%, - rgba(255, 60, 90, 0.35) 55%, - rgba(255, 90, 120, 0.9) 90%, + rgba(255, 46, 99, 0.0) 0%, + rgba(255, 46, 99, 0.85) 40%, + rgba(255, 90, 120, 1) 80%, #ff2e63 100% ); transform: translateX(-100%); - animation: top-loader-slide 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite; + animation: top-loader-slide 1.1s cubic-bezier(0.4, 0, 0.2, 1) infinite; + box-shadow: 0 0 8px rgba(255, 46, 99, 0.45); } .top-loader__head { position: absolute; - right: -4px; + right: -2px; top: -2px; height: 6px; width: 10px; border-radius: 9999px; - background: #ff6b8a; + background: #ffd1dc; box-shadow: 0 0 6px 1px #ff2e63, - 0 0 14px 2px rgba(255, 46, 99, 0.75), - 0 0 24px 4px rgba(255, 46, 99, 0.35); + 0 0 14px 3px rgba(255, 46, 99, 0.85), + 0 0 28px 6px rgba(255, 46, 99, 0.45); } @keyframes top-loader-slide { 0% { transform: translateX(-100%); } diff --git a/distribution_fe/components/shared/data-table/data-table.tsx b/distribution_fe/components/shared/data-table/data-table.tsx index f86475b..c5cd9dc 100644 --- a/distribution_fe/components/shared/data-table/data-table.tsx +++ b/distribution_fe/components/shared/data-table/data-table.tsx @@ -620,9 +620,11 @@ export function DataTable({
))} {!loading && displayData.length === 0 && ( -
- {config.emptyMessage ?? 'No records found.'} -
+ config.emptyState ?? ( +
+ {config.emptyMessage ?? 'No records found.'} +
+ ) )} {!loading && displayData.map(row => ( ({ - {config.emptyMessage ?? 'No records found.'} + {config.emptyState ?? ( +
+ {config.emptyMessage ?? 'No records found.'} +
+ )}
)} diff --git a/distribution_fe/components/shared/data-table/types.ts b/distribution_fe/components/shared/data-table/types.ts index ee84db2..9edb03e 100644 --- a/distribution_fe/components/shared/data-table/types.ts +++ b/distribution_fe/components/shared/data-table/types.ts @@ -119,6 +119,8 @@ export interface TableConfig { exportable?: boolean | ExportConfig; density?: 'compact' | 'normal' | 'comfortable'; emptyMessage?: string; + /** Custom rendered empty state. Overrides emptyMessage when both are provided. */ + emptyState?: React.ReactNode; rowClass?: (row: T) => string; isRowSelectable?: (row: T) => boolean; expandRender?: (row: T) => React.ReactNode; diff --git a/distribution_fe/components/shared/top-loader.tsx b/distribution_fe/components/shared/top-loader.tsx index cda83f6..cbc427f 100644 --- a/distribution_fe/components/shared/top-loader.tsx +++ b/distribution_fe/components/shared/top-loader.tsx @@ -1,26 +1,44 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useState, useSyncExternalStore } from 'react' -/** - * Thin top strip shown while `active` is true. Stays visible for at least - * `minDurationMs` so quick fetches don't produce an invisible flicker. - */ -export function TopLoader({ active, minDurationMs = 350 }: { active: boolean; minDurationMs?: number }) { - const [visible, setVisible] = useState(active) +// Global ref-count store. Any page can call useTopLoader(active) and the +// shared (mounted once in the dashboard layout) will reflect it. + +let activeCount = 0 +const listeners = new Set<() => void>() +function emit() { listeners.forEach(l => l()) } +function subscribe(cb: () => void) { listeners.add(cb); return () => { listeners.delete(cb) } } +function getSnapshot() { return activeCount } + +export function useTopLoader(active: boolean) { + useEffect(() => { + if (!active) return + activeCount += 1 + emit() + return () => { + activeCount = Math.max(0, activeCount - 1) + emit() + } + }, [active]) +} + +// Short tail so a sub-100ms fetch still shows the strip briefly. +const TAIL_MS = 350 + +export function TopLoader() { + const count = useSyncExternalStore(subscribe, getSnapshot, () => 0) + const [tailing, setTailing] = useState(false) useEffect(() => { - if (active) { - setVisible(true) - return - } - const t = setTimeout(() => setVisible(false), minDurationMs) + if (count > 0) { setTailing(true); return } + const t = setTimeout(() => setTailing(false), TAIL_MS) return () => clearTimeout(t) - }, [active, minDurationMs]) + }, [count]) - if (!visible) return null + if (count === 0 && !tailing) return null return ( -
+