Merge pull request 'Dev' (#76) from dev into production

Reviewed-on: #76
This commit was merged in pull request #76.
This commit is contained in:
2026-10-19 23:38:07 +05:30
7 changed files with 80 additions and 36 deletions
@@ -2,6 +2,7 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
import { AppSidebar } from '@/components/layout/app-sidebar' import { AppSidebar } from '@/components/layout/app-sidebar'
import { BottomNav } from '@/components/layout/bottom-nav' import { BottomNav } from '@/components/layout/bottom-nav'
import { ThemeToggle } from '@/components/layout/theme-toggle' import { ThemeToggle } from '@/components/layout/theme-toggle'
import { TopLoader } from '@/components/shared/top-loader'
export default function DashboardLayout({ export default function DashboardLayout({
children, children,
@@ -10,6 +11,7 @@ export default function DashboardLayout({
}) { }) {
return ( return (
<SidebarProvider> <SidebarProvider>
<TopLoader />
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset>
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4"> <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react' 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 { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table' import type { TableConfig } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button' 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 { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { GrnResponse } from '@/types/api' import type { GrnResponse } from '@/types/api'
import { GrnViewSheet } from './grn-view-sheet' import { GrnViewSheet } from './grn-view-sheet'
import { EmptyState } from '@/components/shared/empty-state'
const fmtDate = (v: string) => const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -53,7 +54,19 @@ export function GrnClient() {
rowKey: 'id', rowKey: 'id',
serverSide: true, serverSide: true,
stickyHeader: true, stickyHeader: true,
emptyMessage: 'No goods receipts found.', emptyState: (
<EmptyState
icon={PackageDeliveredIcon}
title="No goods receipts yet"
description="GRNs appear here once goods are received against a purchase order or booked as a direct receipt."
action={
<Button size="sm" onClick={() => router.push('/procurement/grn/new')}>
<HugeiconsIcon icon={Add01Icon} size={14} />
New GRN
</Button>
}
/>
),
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [ columns: [
{ {
@@ -30,7 +30,7 @@ import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { toast } from '@/components/ui/toast' 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 { EmptyState } from '@/components/shared/empty-state'
import { ReceiptDollarIcon } from '@hugeicons/core-free-icons' import { ReceiptDollarIcon } from '@hugeicons/core-free-icons'
@@ -70,11 +70,12 @@ export function SupplierPaymentsClient({ supplierId }: { supplierId: string }) {
useEffect(() => { loadPayments() }, [loadPayments]) useEffect(() => { loadPayments() }, [loadPayments])
useTopLoader(loading)
const pageCount = Math.max(1, Math.ceil(total / pageSize)) const pageCount = Math.max(1, Math.ceil(total / pageSize))
return ( return (
<div className="relative"> <div>
<TopLoader active={loading} />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">Supplier Payments</h1> <h1 className="text-2xl font-semibold text-foreground">Supplier Payments</h1>
+13 -11
View File
@@ -135,42 +135,44 @@
border-bottom-color: var(--muted-foreground); 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 { .top-loader {
pointer-events: none; pointer-events: none;
position: absolute; position: fixed;
left: 0; left: 0;
right: 0; right: 0;
top: 0; top: 0;
height: 2px; height: 2px;
overflow: visible; overflow: visible;
z-index: 50; z-index: 100;
} }
.top-loader__bar { .top-loader__bar {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
transparent 0%, rgba(255, 46, 99, 0.0) 0%,
rgba(255, 60, 90, 0.35) 55%, rgba(255, 46, 99, 0.85) 40%,
rgba(255, 90, 120, 0.9) 90%, rgba(255, 90, 120, 1) 80%,
#ff2e63 100% #ff2e63 100%
); );
transform: translateX(-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 { .top-loader__head {
position: absolute; position: absolute;
right: -4px; right: -2px;
top: -2px; top: -2px;
height: 6px; height: 6px;
width: 10px; width: 10px;
border-radius: 9999px; border-radius: 9999px;
background: #ff6b8a; background: #ffd1dc;
box-shadow: box-shadow:
0 0 6px 1px #ff2e63, 0 0 6px 1px #ff2e63,
0 0 14px 2px rgba(255, 46, 99, 0.75), 0 0 14px 3px rgba(255, 46, 99, 0.85),
0 0 24px 4px rgba(255, 46, 99, 0.35); 0 0 28px 6px rgba(255, 46, 99, 0.45);
} }
@keyframes top-loader-slide { @keyframes top-loader-slide {
0% { transform: translateX(-100%); } 0% { transform: translateX(-100%); }
@@ -620,9 +620,11 @@ export function DataTable<T extends object>({
</div> </div>
))} ))}
{!loading && displayData.length === 0 && ( {!loading && displayData.length === 0 && (
<div className="h-32 flex items-center justify-center text-muted-foreground text-sm"> config.emptyState ?? (
{config.emptyMessage ?? 'No records found.'} <div className="h-32 flex items-center justify-center text-muted-foreground text-sm">
</div> {config.emptyMessage ?? 'No records found.'}
</div>
)
)} )}
{!loading && displayData.map(row => ( {!loading && displayData.map(row => (
<MobileCard <MobileCard
@@ -710,9 +712,13 @@ export function DataTable<T extends object>({
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={visibleColumns.length + (config.selectable ? 1 : 0) + (config.expandable ? 1 : 0)} colSpan={visibleColumns.length + (config.selectable ? 1 : 0) + (config.expandable ? 1 : 0)}
className="h-32 text-center text-muted-foreground" className="p-0"
> >
{config.emptyMessage ?? 'No records found.'} {config.emptyState ?? (
<div className="h-32 flex items-center justify-center text-muted-foreground text-sm">
{config.emptyMessage ?? 'No records found.'}
</div>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -119,6 +119,8 @@ export interface TableConfig<T = object> {
exportable?: boolean | ExportConfig; exportable?: boolean | ExportConfig;
density?: 'compact' | 'normal' | 'comfortable'; density?: 'compact' | 'normal' | 'comfortable';
emptyMessage?: string; emptyMessage?: string;
/** Custom rendered empty state. Overrides emptyMessage when both are provided. */
emptyState?: React.ReactNode;
rowClass?: (row: T) => string; rowClass?: (row: T) => string;
isRowSelectable?: (row: T) => boolean; isRowSelectable?: (row: T) => boolean;
expandRender?: (row: T) => React.ReactNode; expandRender?: (row: T) => React.ReactNode;
@@ -1,26 +1,44 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState, useSyncExternalStore } from 'react'
/** // Global ref-count store. Any page can call useTopLoader(active) and the
* Thin top strip shown while `active` is true. Stays visible for at least // shared <TopLoader /> (mounted once in the dashboard layout) will reflect it.
* `minDurationMs` so quick fetches don't produce an invisible flicker.
*/ let activeCount = 0
export function TopLoader({ active, minDurationMs = 350 }: { active: boolean; minDurationMs?: number }) { const listeners = new Set<() => void>()
const [visible, setVisible] = useState(active) 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(() => { useEffect(() => {
if (active) { if (count > 0) { setTailing(true); return }
setVisible(true) const t = setTimeout(() => setTailing(false), TAIL_MS)
return
}
const t = setTimeout(() => setVisible(false), minDurationMs)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [active, minDurationMs]) }, [count])
if (!visible) return null if (count === 0 && !tailing) return null
return ( return (
<div className="top-loader"> <div className="top-loader" aria-hidden="true">
<div className="top-loader__bar"> <div className="top-loader__bar">
<span className="top-loader__head" /> <span className="top-loader__head" />
</div> </div>