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:
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
+4
-3
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user