refactor: remove unused showcase components and related files
- Deleted PouchSample component and its associated logic for PouchDB data handling. - Removed PrinterList component and its integration for printer management. - Eliminated registry.ts for component registration, simplifying the showcase structure. - Cleaned up ShellDemo component, removing unnecessary layout controls and demo configurations. - Removed ShowcaseView component, consolidating the showcase functionality.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
import { lazy, Suspense, useEffect } from 'react';
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { ThemeProvider, DensityType } from '@repo/ui/provider';
|
import { ThemeProvider } from '@repo/ui/provider';
|
||||||
import { NotFound, Forbidden, Maintenance, ComingSoon, AgGridProvider } from '@repo/ui/components';
|
import { NotFound, Forbidden, Maintenance, ComingSoon, AgGridProvider } from '@repo/ui/components';
|
||||||
import { LoadingScreen } from '../core/components/loading-screen';
|
import { LoadingScreen } from '../core/components/loading-screen';
|
||||||
import { useThemeStore } from '../core/stores/theme.store';
|
import { useThemeStore } from '../core/stores/theme.store';
|
||||||
@@ -8,12 +8,9 @@ import { initializeAndPurgeHistoryBackground } from './modules/layouts/hooks/use
|
|||||||
|
|
||||||
const AuthModule = lazy(() => import('./auth'));
|
const AuthModule = lazy(() => import('./auth'));
|
||||||
const AppModule = lazy(() => import('./modules'));
|
const AppModule = lazy(() => import('./modules'));
|
||||||
const ShowcaseView = lazy(() => import('./showcase/showcase-view'));
|
|
||||||
const ShellDemo = lazy(() => import('./showcase/shell-demo'));
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||||
const [density, setDensity] = useState<DensityType>('compact');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Execution runs purely in the background (fire and forget)
|
// Execution runs purely in the background (fire and forget)
|
||||||
@@ -22,20 +19,18 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider colorScheme={colorScheme} density={density}>
|
<ThemeProvider colorScheme={colorScheme} density={'compact'}>
|
||||||
<AgGridProvider bypassLicense>
|
<AgGridProvider bypassLicense>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Suspense fallback={<LoadingScreen />}>
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/auth/*" element={<AuthModule />} />
|
<Route path="/auth/*" element={<AuthModule />} />
|
||||||
<Route path="/app/*" element={<AppModule />} />
|
<Route path="/app/*" element={<AppModule />} />
|
||||||
<Route path="/showcase" element={<ShowcaseView density={density} setDensity={setDensity} />} />
|
|
||||||
<Route path="/shell-demo" element={<ShellDemo />} />
|
|
||||||
<Route path="/404" element={<NotFound homeUrl="/app" />} />
|
<Route path="/404" element={<NotFound homeUrl="/app" />} />
|
||||||
<Route path="/403" element={<Forbidden homeUrl="/app" />} />
|
<Route path="/403" element={<Forbidden homeUrl="/app" />} />
|
||||||
<Route path="/maintenance" element={<Maintenance />} />
|
<Route path="/maintenance" element={<Maintenance />} />
|
||||||
<Route path="/coming-soon" element={<ComingSoon />} />
|
<Route path="/coming-soon" element={<ComingSoon />} />
|
||||||
<Route path="/" element={<Navigate to="/showcase" />} />
|
<Route path="/" element={<Navigate to="/app" />} />
|
||||||
<Route path="*" element={<Navigate to="/404" />} />
|
<Route path="*" element={<Navigate to="/404" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
import { Card, Title, Text, Table, Stack, Badge } from '@repo/ui/components';
|
|
||||||
import { PageActions, RowActions, PageActionProps, RowActionProps } from '@repo/ui/components';
|
|
||||||
import { Save, Printer, Trash, MoreVertical, Edit, FileText, CheckCircle, Check } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function ActionToolsShowcase() {
|
|
||||||
const pageActions: PageActionProps[] = [
|
|
||||||
{
|
|
||||||
key: 'save',
|
|
||||||
label: 'Save Changes',
|
|
||||||
icon: <Save size={16} />,
|
|
||||||
variant: 'filled',
|
|
||||||
onClick: (key) => console.log('Clicked', key),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'save',
|
|
||||||
label: 'Save Changes',
|
|
||||||
icon: <Save size={16} />,
|
|
||||||
onClick: (key) => console.log('Clicked', key),
|
|
||||||
},
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
key: 'print',
|
|
||||||
label: 'Print',
|
|
||||||
icon: <Printer size={16} />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
key: 'print-original',
|
|
||||||
label: 'Print Original',
|
|
||||||
icon: <FileText size={16} />,
|
|
||||||
onClick: (k) => console.log(k),
|
|
||||||
},
|
|
||||||
// { type: 'divider' },
|
|
||||||
{ key: 'print-copy', label: 'Print Copy', icon: <FileText size={16} />, onClick: (k) => console.log(k) },
|
|
||||||
{ key: 'print-copy', label: 'Print Copy', icon: <FileText size={16} />, onClick: (k) => console.log(k) },
|
|
||||||
{ key: 'print-copy', label: 'Print Copy', icon: <FileText size={16} />, onClick: (k) => console.log(k) },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'confirm',
|
|
||||||
label: 'Confirm',
|
|
||||||
icon: <CheckCircle size={16} />,
|
|
||||||
onClick: (key) => console.log('Clicked', key),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'delete',
|
|
||||||
label: 'Delete',
|
|
||||||
icon: <Trash size={16} />,
|
|
||||||
intent: 'destructive',
|
|
||||||
onClick: (key) => console.log('Clicked', key),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
key: 'success',
|
|
||||||
label: 'Success',
|
|
||||||
icon: <Check size={16} />,
|
|
||||||
intent: 'success',
|
|
||||||
onClick: (key) => console.log('Clicked', key),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'warning',
|
|
||||||
label: 'Warning',
|
|
||||||
icon: <Check size={16} />,
|
|
||||||
intent: 'warning',
|
|
||||||
onClick: (key) => console.log('Clicked', key),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'Primary',
|
|
||||||
label: 'Primary',
|
|
||||||
icon: <Check size={16} />,
|
|
||||||
intent: 'primary',
|
|
||||||
onClick: (key) => console.log('Clicked', key),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const rowActions: RowActionProps[] = [
|
|
||||||
{
|
|
||||||
key: 'edit',
|
|
||||||
tooltip: 'Edit Record',
|
|
||||||
label: 'Edit Record',
|
|
||||||
icon: <Edit size={16} />,
|
|
||||||
onClick: (key) => console.log('Clicked', key),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'approve',
|
|
||||||
tooltip: 'Approve',
|
|
||||||
label: 'Approve',
|
|
||||||
icon: <CheckCircle size={16} />,
|
|
||||||
intent: 'success',
|
|
||||||
onClick: (key) => console.log('Clicked', key),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'more',
|
|
||||||
label: 'More Options',
|
|
||||||
icon: <MoreVertical size={16} />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
key: 'delete',
|
|
||||||
label: 'Delete Record',
|
|
||||||
icon: <Trash size={16} />,
|
|
||||||
intent: 'destructive',
|
|
||||||
onClick: (k) => console.log(k),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const tableData = [
|
|
||||||
{ id: '1', name: 'Invoice #001', status: 'Pending' },
|
|
||||||
{ id: '2', name: 'Invoice #002', status: 'Approved' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="xl">
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
Page Actions
|
|
||||||
</Title>
|
|
||||||
<Text c="dimmed" mb="lg">
|
|
||||||
Used in toolbars and page headers.
|
|
||||||
</Text>
|
|
||||||
<PageActions actions={pageActions} />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
Row Actions
|
|
||||||
</Title>
|
|
||||||
<Text c="dimmed" mb="lg">
|
|
||||||
Used inside data grids or list items.
|
|
||||||
</Text>
|
|
||||||
<Table striped highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>ID</Table.Th>
|
|
||||||
<Table.Th>Name</Table.Th>
|
|
||||||
<Table.Th>Status</Table.Th>
|
|
||||||
<Table.Th style={{ width: 120 }}>Actions</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{tableData.map((row) => (
|
|
||||||
<Table.Tr key={row.id}>
|
|
||||||
<Table.Td>{row.id}</Table.Td>
|
|
||||||
<Table.Td>{row.name}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge color={row.status === 'Approved' ? 'success' : 'warning'}>{row.status}</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<RowActions actions={rowActions} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<RowActions showLabels actions={rowActions} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import { useState, useMemo } from 'react';
|
|
||||||
import { Card, Title, Text, Stack, Badge, Group, Select, NumberInput } from '@repo/ui/components';
|
|
||||||
import { AgGridProvider, DataGrid } from '@repo/ui/ag-grid';
|
|
||||||
import type { ColDef, ValueFormatterParams } from '@repo/ui/ag-grid';
|
|
||||||
|
|
||||||
/* ─── Sample Data ──────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
interface OrderRow {
|
|
||||||
id: string;
|
|
||||||
orderCode: string;
|
|
||||||
customer: string;
|
|
||||||
email: string;
|
|
||||||
product: string;
|
|
||||||
quantity: number;
|
|
||||||
unitPrice: number;
|
|
||||||
total: number;
|
|
||||||
status: 'Draft' | 'Pending' | 'Processing' | 'Shipped' | 'Delivered' | 'Cancelled';
|
|
||||||
createdAt: string;
|
|
||||||
region: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SAMPLE_DATA: OrderRow[] = [
|
|
||||||
{ id: '1', orderCode: 'ORD-2026-001', customer: 'Acme Corp', email: 'acme@example.com', product: 'Widget A', quantity: 120, unitPrice: 24.5, total: 2940, status: 'Delivered', createdAt: '2026-01-15', region: 'North America' },
|
|
||||||
{ id: '2', orderCode: 'ORD-2026-002', customer: 'Globex Inc', email: 'info@globex.com', product: 'Widget B', quantity: 50, unitPrice: 89.99, total: 4499.5, status: 'Shipped', createdAt: '2026-02-03', region: 'Europe' },
|
|
||||||
{ id: '3', orderCode: 'ORD-2026-003', customer: 'Initech', email: 'orders@initech.com', product: 'Gadget Pro', quantity: 200, unitPrice: 15.0, total: 3000, status: 'Processing', createdAt: '2026-03-21', region: 'Asia Pacific' },
|
|
||||||
{ id: '4', orderCode: 'ORD-2026-004', customer: 'Umbrella Ltd', email: 'sales@umbrella.co', product: 'Widget A', quantity: 75, unitPrice: 24.5, total: 1837.5, status: 'Pending', createdAt: '2026-04-10', region: 'Europe' },
|
|
||||||
{ id: '5', orderCode: 'ORD-2026-005', customer: 'Stark Industries', email: 'tony@stark.io', product: 'Gadget Elite', quantity: 10, unitPrice: 499.0, total: 4990, status: 'Delivered', createdAt: '2026-04-18', region: 'North America' },
|
|
||||||
{ id: '6', orderCode: 'ORD-2026-006', customer: 'Wayne Enterprises', email: 'bruce@wayne.com', product: 'Widget C', quantity: 300, unitPrice: 12.75, total: 3825, status: 'Draft', createdAt: '2026-05-02', region: 'North America' },
|
|
||||||
{ id: '7', orderCode: 'ORD-2026-007', customer: 'Cyberdyne', email: 'info@cyberdyne.jp', product: 'Gadget Pro', quantity: 150, unitPrice: 15.0, total: 2250, status: 'Cancelled', createdAt: '2026-05-15', region: 'Asia Pacific' },
|
|
||||||
{ id: '8', orderCode: 'ORD-2026-008', customer: 'Oscorp', email: 'orders@oscorp.com', product: 'Widget B', quantity: 90, unitPrice: 89.99, total: 8099.1, status: 'Shipped', createdAt: '2026-06-01', region: 'North America' },
|
|
||||||
{ id: '9', orderCode: 'ORD-2026-009', customer: 'LexCorp', email: 'lex@lexcorp.com', product: 'Gadget Elite', quantity: 5, unitPrice: 499.0, total: 2495, status: 'Processing', createdAt: '2026-06-12', region: 'Europe' },
|
|
||||||
{ id: '10', orderCode: 'ORD-2026-010', customer: 'Pied Piper', email: 'richard@piedpiper.io', product: 'Widget A', quantity: 500, unitPrice: 24.5, total: 12250, status: 'Pending', createdAt: '2026-06-20', region: 'North America' },
|
|
||||||
{ id: '11', orderCode: 'ORD-2026-011', customer: 'Hooli', email: 'gavin@hooli.com', product: 'Gadget Pro', quantity: 1000, unitPrice: 15.0, total: 15000, status: 'Delivered', createdAt: '2026-07-01', region: 'North America' },
|
|
||||||
{ id: '12', orderCode: 'ORD-2026-012', customer: 'Massive Dynamic', email: 'nina@massive.com', product: 'Widget C', quantity: 60, unitPrice: 12.75, total: 765, status: 'Shipped', createdAt: '2026-07-08', region: 'Europe' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ─── Status Badge Renderer ─────────────────────────────────────── */
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<OrderRow['status'], string> = {
|
|
||||||
Draft: 'gray',
|
|
||||||
Pending: 'warning',
|
|
||||||
Processing: 'info',
|
|
||||||
Shipped: 'brand',
|
|
||||||
Delivered: 'success',
|
|
||||||
Cancelled: 'error',
|
|
||||||
};
|
|
||||||
|
|
||||||
function StatusCellRenderer({ value }: { value: OrderRow['status'] }) {
|
|
||||||
return (
|
|
||||||
<Badge size="sm" variant="light" color={STATUS_COLORS[value] || 'gray'}>
|
|
||||||
{value}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Currency Formatter ────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function currencyFormatter(params: ValueFormatterParams): string {
|
|
||||||
if (params.value == null) return '-';
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'USD',
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
}).format(params.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Showcase Component ────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export default function AgGridShowcase() {
|
|
||||||
const [gridHeight, setGridHeight] = useState(460);
|
|
||||||
const [rowModelType, setRowModelType] = useState<string>('clientSide');
|
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(
|
|
||||||
() => ({
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 100,
|
|
||||||
sortable: true,
|
|
||||||
filter: true,
|
|
||||||
resizable: true,
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<OrderRow>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
field: 'orderCode',
|
|
||||||
headerName: 'Order Code',
|
|
||||||
pinned: 'left',
|
|
||||||
minWidth: 140,
|
|
||||||
filter: 'agTextColumnFilter',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'status',
|
|
||||||
headerName: 'Status',
|
|
||||||
minWidth: 120,
|
|
||||||
cellRenderer: StatusCellRenderer,
|
|
||||||
filter: 'agSetColumnFilter',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'customer',
|
|
||||||
headerName: 'Customer',
|
|
||||||
minWidth: 160,
|
|
||||||
filter: 'agTextColumnFilter',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'email',
|
|
||||||
headerName: 'Email',
|
|
||||||
minWidth: 180,
|
|
||||||
filter: 'agTextColumnFilter',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'product',
|
|
||||||
headerName: 'Product',
|
|
||||||
minWidth: 130,
|
|
||||||
filter: 'agSetColumnFilter',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'region',
|
|
||||||
headerName: 'Region',
|
|
||||||
minWidth: 140,
|
|
||||||
filter: 'agSetColumnFilter',
|
|
||||||
enableRowGroup: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'quantity',
|
|
||||||
headerName: 'Qty',
|
|
||||||
minWidth: 80,
|
|
||||||
filter: 'agNumberColumnFilter',
|
|
||||||
type: 'numericColumn',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'unitPrice',
|
|
||||||
headerName: 'Unit Price',
|
|
||||||
minWidth: 110,
|
|
||||||
filter: 'agNumberColumnFilter',
|
|
||||||
type: 'numericColumn',
|
|
||||||
valueFormatter: currencyFormatter,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'total',
|
|
||||||
headerName: 'Total',
|
|
||||||
minWidth: 120,
|
|
||||||
filter: 'agNumberColumnFilter',
|
|
||||||
type: 'numericColumn',
|
|
||||||
valueFormatter: currencyFormatter,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'createdAt',
|
|
||||||
headerName: 'Created Date',
|
|
||||||
minWidth: 130,
|
|
||||||
filter: 'agDateColumnFilter',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AgGridProvider bypassLicense>
|
|
||||||
<Stack gap="xl">
|
|
||||||
{/* ── Info Card ────────────────────────────────── */}
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="xs">
|
|
||||||
AG Grid Enterprise — Mantine Integration
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
|
||||||
This showcase demonstrates AG Grid integrated with Mantine's theme system. The grid
|
|
||||||
automatically adapts to dark/light mode changes, inherits brand colors, typography, and
|
|
||||||
border radius from the Mantine theme — all via AG Grid's built-in CSS variable system with
|
|
||||||
zero custom stylesheets. Toggle the color scheme in the Theme Controls above to see it in
|
|
||||||
action.
|
|
||||||
</Text>
|
|
||||||
<Group>
|
|
||||||
<NumberInput
|
|
||||||
label="Grid Height (px)"
|
|
||||||
value={gridHeight}
|
|
||||||
onChange={(val) => setGridHeight(Number(val) || 400)}
|
|
||||||
min={300}
|
|
||||||
max={800}
|
|
||||||
step={50}
|
|
||||||
w={180}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Row Model"
|
|
||||||
value={rowModelType}
|
|
||||||
onChange={(val) => setRowModelType(val || 'clientSide')}
|
|
||||||
data={[
|
|
||||||
{ value: 'clientSide', label: 'Client Side' },
|
|
||||||
{ value: 'serverSide', label: 'Server Side (no datasource)' },
|
|
||||||
]}
|
|
||||||
w={260}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── AG Grid ──────────────────────────────────── */}
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
Orders Data Grid
|
|
||||||
</Title>
|
|
||||||
<div style={{ height: gridHeight }}>
|
|
||||||
<DataGrid<OrderRow>
|
|
||||||
rowData={SAMPLE_DATA}
|
|
||||||
columnDefs={columnDefs}
|
|
||||||
defaultColDef={defaultColDef}
|
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: true }}
|
|
||||||
pagination
|
|
||||||
paginationPageSize={10}
|
|
||||||
animateRows
|
|
||||||
enableCellTextSelection
|
|
||||||
suppressCopyRowsToClipboard
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
</AgGridProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { usePublishEvent } from '@repo/core-events';
|
|
||||||
import { Button, Group, Stack, TextInput, Badge } from '@repo/ui/components';
|
|
||||||
import { AUTH_EVENTS } from '../../../../core/constants/events';
|
|
||||||
|
|
||||||
// ─── ProfileSettingsUI ──────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A profile settings form that publishes `AUTH:PROFILE_UPDATED`
|
|
||||||
* when the user saves changes.
|
|
||||||
*
|
|
||||||
* **Decoupling principle:**
|
|
||||||
* This component doesn't know about IndexedDB, localStorage,
|
|
||||||
* or any storage mechanism. It simply announces that the profile
|
|
||||||
* has been updated. Any number of listeners can react to this
|
|
||||||
* event independently:
|
|
||||||
*
|
|
||||||
* - `StorageSyncListener` persists to IndexedDB
|
|
||||||
* - A hypothetical `AnalyticsListener` could send to Mixpanel
|
|
||||||
* - A hypothetical `AvatarCacheListener` could pre-warm a CDN
|
|
||||||
*
|
|
||||||
* All without modifying this component.
|
|
||||||
*/
|
|
||||||
export function ProfileSettingsUI() {
|
|
||||||
const publish = usePublishEvent();
|
|
||||||
|
|
||||||
const [name, setName] = useState('Firman Ramdhani');
|
|
||||||
const [email, setEmail] = useState('firman@eigen.co.id');
|
|
||||||
const [avatar, setAvatar] = useState('https://ui-avatars.com/api/?name=FM&background=4263eb&color=fff');
|
|
||||||
const [saveCount, setSaveCount] = useState(0);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
publish(AUTH_EVENTS.PROFILE_UPDATED, {
|
|
||||||
id: 'user-1',
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
avatar,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
});
|
|
||||||
setSaveCount((c) => c + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group grow align="flex-start">
|
|
||||||
<TextInput label="Full Name" value={name} onChange={(e) => setName(e.currentTarget.value)} size="sm" />
|
|
||||||
<TextInput label="Email" value={email} onChange={(e) => setEmail(e.currentTarget.value)} size="sm" />
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<TextInput label="Avatar URL" value={avatar} onChange={(e) => setAvatar(e.currentTarget.value)} size="sm" />
|
|
||||||
|
|
||||||
<Group>
|
|
||||||
<Button variant="filled" color="brand" onClick={handleSave}>
|
|
||||||
💾 Save Profile
|
|
||||||
</Button>
|
|
||||||
{saveCount > 0 && (
|
|
||||||
<Badge color="success" variant="light">
|
|
||||||
Synced {saveCount}×
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useAppEvent } from '@repo/core-events';
|
|
||||||
import { AUTH_EVENTS } from '../../../../core/constants/events';
|
|
||||||
import type { ProfileUpdatedPayload } from '@repo/core-events';
|
|
||||||
import { secureIndexedDB, AppStorageKey } from '../../../../core/storage/local';
|
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface StorageSyncListenerProps {
|
|
||||||
/** Callback to log messages to the parent demo UI. */
|
|
||||||
onLog: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── StorageSyncListener ────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Headless component that listens to `AUTH:PROFILE_UPDATED` events
|
|
||||||
* and persists the profile data to IndexedDB via `@repo/core-storage`.
|
|
||||||
*/
|
|
||||||
export function StorageSyncListener({ onLog }: StorageSyncListenerProps) {
|
|
||||||
useAppEvent(AUTH_EVENTS.PROFILE_UPDATED, (payload: ProfileUpdatedPayload) => {
|
|
||||||
onLog(`Received AUTH:PROFILE_UPDATED for "${payload.name}" (${payload.email})`);
|
|
||||||
|
|
||||||
// Persist to IndexedDB via local AppStorageKey.
|
|
||||||
secureIndexedDB
|
|
||||||
.setItem(AppStorageKey.USER_PROFILE, payload)
|
|
||||||
.then(() => {
|
|
||||||
onLog(`✅ Profile persisted to IndexedDB (key: "${AppStorageKey.USER_PROFILE}", encrypted: true)`);
|
|
||||||
})
|
|
||||||
.catch((err: Error) => {
|
|
||||||
onLog(`❌ IndexedDB write failed: ${err.message}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Headless — renders nothing
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Title,
|
|
||||||
Text,
|
|
||||||
Stack,
|
|
||||||
Badge,
|
|
||||||
Divider,
|
|
||||||
} from '@repo/ui/components';
|
|
||||||
|
|
||||||
// ── Showcase Components ──────────────────────────────────────────
|
|
||||||
import { CashierUI } from './printer/cashier.ui';
|
|
||||||
import { PrinterListener } from './printer/printer.listener';
|
|
||||||
import { LiveStockGrid } from './stock-grid/live-stock-grid.ui';
|
|
||||||
import { ProfileSettingsUI } from './auth-sync/profile-settings.ui';
|
|
||||||
import { StorageSyncListener } from './auth-sync/storage-sync.listener';
|
|
||||||
import { DEVICE_EVENTS, AUTH_EVENTS } from '../../../core/constants/events';
|
|
||||||
|
|
||||||
// ─── Events Demo Page ───────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Orchestrator page for all three Event Bus showcase demos.
|
|
||||||
*
|
|
||||||
* This component is completely self-contained within the
|
|
||||||
* `events-demo/` folder and does not leak state or side-effects
|
|
||||||
* into the rest of the application.
|
|
||||||
*/
|
|
||||||
export default function EventsDemoPage() {
|
|
||||||
// ── Showcase 1: Printer status feedback ──────────────────────
|
|
||||||
const [printerLog, setPrinterLog] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// ── Showcase 3: Storage sync status feedback ─────────────────
|
|
||||||
const [syncLog, setSyncLog] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// ── Render counter to prove this parent is stable ────────────
|
|
||||||
const renderCount = useRef(0);
|
|
||||||
renderCount.current += 1;
|
|
||||||
|
|
||||||
// Keep log sizes bounded
|
|
||||||
useEffect(() => {
|
|
||||||
if (printerLog.length > 20) setPrinterLog((prev) => prev.slice(-20));
|
|
||||||
}, [printerLog.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (syncLog.length > 20) setSyncLog((prev) => prev.slice(-20));
|
|
||||||
}, [syncLog.length]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="xl">
|
|
||||||
<div>
|
|
||||||
<Title order={2}>🔌 Event Bus Showcase</Title>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Three real-world demos of <code>@repo/core-events</code> — zero coupling, strict typing, high performance.
|
|
||||||
</Text>
|
|
||||||
<Badge color="brand" variant="light" mt="xs">
|
|
||||||
Parent render count: {renderCount.current}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════
|
|
||||||
SHOWCASE 1: Cross-Platform Printer Abstraction
|
|
||||||
═══════════════════════════════════════════════════════════ */}
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="xs">
|
|
||||||
🖨️ Showcase 1: Cross-Platform Printer Abstraction
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
|
||||||
The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event.
|
|
||||||
The PrinterListener listens for it and simulates interacting with a physical printer.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Headless listener — renders nothing visible */}
|
|
||||||
<PrinterListener
|
|
||||||
onLog={(msg) => setPrinterLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CashierUI />
|
|
||||||
|
|
||||||
{printerLog.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Divider my="sm" />
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
📋 Printer Log:
|
|
||||||
</Text>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxHeight: 120,
|
|
||||||
overflow: 'auto',
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
background: 'var(--mantine-color-dark-7, #1a1b1e)',
|
|
||||||
color: 'var(--mantine-color-green-4, #69db7c)',
|
|
||||||
padding: 8,
|
|
||||||
borderRadius: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{printerLog.map((line, i) => (
|
|
||||||
<div key={i}>{line}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════
|
|
||||||
SHOWCASE 2: Extreme Performance — Live Stock Grid
|
|
||||||
═══════════════════════════════════════════════════════════ */}
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="xs">
|
|
||||||
📈 Showcase 2: High-Frequency Real-Time Data (50 updates/sec)
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
|
||||||
A mock WebSocket fires <code>WS:STOCK_UPDATE</code> every 20ms.
|
|
||||||
Each StockRow subscribes to the global event but only updates when{' '}
|
|
||||||
<code>payload.id === row.id</code>. The parent grid never re-renders.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<LiveStockGrid />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════
|
|
||||||
SHOWCASE 3: Auth/Profile → IndexedDB Sync
|
|
||||||
═══════════════════════════════════════════════════════════ */}
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="xs">
|
|
||||||
💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code>
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
|
||||||
ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>.
|
|
||||||
StorageSyncListener silently catches it in the background and saves to IndexedDB via <code>secureIndexedDB</code>.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Headless listener — renders nothing visible */}
|
|
||||||
<StorageSyncListener
|
|
||||||
onLog={(msg) => setSyncLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ProfileSettingsUI />
|
|
||||||
|
|
||||||
{syncLog.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Divider my="sm" />
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
📋 Storage Sync Log:
|
|
||||||
</Text>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxHeight: 120,
|
|
||||||
overflow: 'auto',
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
background: 'var(--mantine-color-dark-7, #1a1b1e)',
|
|
||||||
color: 'var(--mantine-color-blue-4, #4dabf7)',
|
|
||||||
padding: 8,
|
|
||||||
borderRadius: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{syncLog.map((line, i) => (
|
|
||||||
<div key={i}>{line}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { usePublishEvent } from '@repo/core-events';
|
|
||||||
import type { ReceiptItem } from '@repo/core-events';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Table,
|
|
||||||
Badge,
|
|
||||||
} from '@repo/ui/components';
|
|
||||||
import { DEVICE_EVENTS } from '../../../../core/constants/events';
|
|
||||||
|
|
||||||
// ─── Mock Receipt Data ──────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEMO_ITEMS: ReceiptItem[] = [
|
|
||||||
{ name: 'Espresso', qty: 2, price: 3.5 },
|
|
||||||
{ name: 'Croissant', qty: 1, price: 4.25 },
|
|
||||||
{ name: 'Orange Juice', qty: 3, price: 2.75 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── CashierUI ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A simple cashier interface that publishes a print receipt event.
|
|
||||||
*
|
|
||||||
* This component has ZERO knowledge of how printing works.
|
|
||||||
* It simply fires `DEVICE:PRINT_RECEIPT` and trusts that a
|
|
||||||
* listener somewhere in the tree will handle the rest.
|
|
||||||
*
|
|
||||||
* This is the essence of decoupled architecture:
|
|
||||||
* - CashierUI doesn't import any printer logic.
|
|
||||||
* - CashierUI doesn't know if it's running in Electron or browser.
|
|
||||||
* - CashierUI doesn't even know if anyone is listening.
|
|
||||||
*/
|
|
||||||
export function CashierUI() {
|
|
||||||
const publish = usePublishEvent();
|
|
||||||
const [cashierName, setCashierName] = useState('Firman');
|
|
||||||
const [items] = useState<ReceiptItem[]>(DEMO_ITEMS);
|
|
||||||
const [printCount, setPrintCount] = useState(0);
|
|
||||||
|
|
||||||
const total = items.reduce((sum, item) => sum + item.qty * item.price, 0);
|
|
||||||
|
|
||||||
const handlePrint = () => {
|
|
||||||
publish(DEVICE_EVENTS.PRINT_RECEIPT, {
|
|
||||||
receiptId: `RCP-${Date.now().toString(36).toUpperCase()}`,
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
cashierName,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
setPrintCount((c) => c + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<TextInput
|
|
||||||
label="Cashier Name"
|
|
||||||
value={cashierName}
|
|
||||||
onChange={(e) => setCashierName(e.currentTarget.value)}
|
|
||||||
size="sm"
|
|
||||||
style={{ maxWidth: 250 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Item</Table.Th>
|
|
||||||
<Table.Th>Qty</Table.Th>
|
|
||||||
<Table.Th>Price</Table.Th>
|
|
||||||
<Table.Th>Subtotal</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{items.map((item, idx) => (
|
|
||||||
<Table.Tr key={idx}>
|
|
||||||
<Table.Td>{item.name}</Table.Td>
|
|
||||||
<Table.Td>{item.qty}</Table.Td>
|
|
||||||
<Table.Td>${item.price.toFixed(2)}</Table.Td>
|
|
||||||
<Table.Td>${(item.qty * item.price).toFixed(2)}</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
<Table.Tfoot>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td colSpan={3}>
|
|
||||||
<Text fw={700}>Total</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fw={700}>${total.toFixed(2)}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Tfoot>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<Group>
|
|
||||||
<Button variant="filled" color="brand" onClick={handlePrint}>
|
|
||||||
🖨️ Print Receipt
|
|
||||||
</Button>
|
|
||||||
{printCount > 0 && (
|
|
||||||
<Badge color="success" variant="light">
|
|
||||||
Printed {printCount}×
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { useAppEvent } from '@repo/core-events';
|
|
||||||
import { DEVICE_EVENTS } from '../../../../core/constants/events';
|
|
||||||
import type { PrintReceiptPayload } from '@repo/core-events';
|
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface PrinterListenerProps {
|
|
||||||
/** Callback to log messages to the parent demo UI. */
|
|
||||||
onLog: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PrinterListener ────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Headless component that listens to `DEVICE:PRINT_RECEIPT` events
|
|
||||||
* and dispatches the print job to the correct platform.
|
|
||||||
*
|
|
||||||
* **Platform detection strategy:**
|
|
||||||
* - If `window.electronAPI` exists → Electron preload bridge.
|
|
||||||
* Uses `window.electronAPI.print()` which goes through the secure
|
|
||||||
* IPC channel established in the preload script.
|
|
||||||
* - Otherwise → Browser fallback using `window.print()`.
|
|
||||||
*
|
|
||||||
* This component renders nothing — it is purely a side-effect listener.
|
|
||||||
* Mount it anywhere in the React tree; it will auto-cleanup on unmount.
|
|
||||||
*
|
|
||||||
* **Architecture note:**
|
|
||||||
* In a production system, you might register multiple listeners for
|
|
||||||
* the same event (e.g., one for printing, another for analytics).
|
|
||||||
* The event bus supports unlimited subscribers per event.
|
|
||||||
*/
|
|
||||||
export function PrinterListener({ onLog }: PrinterListenerProps) {
|
|
||||||
useAppEvent(DEVICE_EVENTS.PRINT_RECEIPT, (payload: PrintReceiptPayload) => {
|
|
||||||
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
|
|
||||||
|
|
||||||
if (isElectron) {
|
|
||||||
// ── Electron Path ──────────────────────────────────────────
|
|
||||||
// Uses the preload-exposed API. The Electron main process
|
|
||||||
// handles the actual OS-level print job via `webContents.print()`.
|
|
||||||
onLog(`[Electron] Sending receipt ${payload.receiptId} to OS printer via IPC bridge...`);
|
|
||||||
window.electronAPI!
|
|
||||||
.print({ silent: true, printBackground: true })
|
|
||||||
.then((result) => {
|
|
||||||
if (result.success) {
|
|
||||||
onLog(`[Electron] ✅ Receipt ${payload.receiptId} printed successfully.`);
|
|
||||||
} else {
|
|
||||||
onLog(`[Electron] ❌ Print failed: ${result.failureReason ?? 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err: Error) => {
|
|
||||||
onLog(`[Electron] ❌ IPC error: ${err.message}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// ── Browser Fallback ───────────────────────────────────────
|
|
||||||
// Opens the native browser print dialog. In production, you'd
|
|
||||||
// likely render a hidden print-optimized iframe first.
|
|
||||||
onLog(`[Browser] 🖨️ Receipt ${payload.receiptId} — opening browser print dialog...`);
|
|
||||||
onLog(` → Cashier: ${payload.cashierName} | Items: ${payload.items.length} | Total: $${payload.total.toFixed(2)}`);
|
|
||||||
window.print();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Headless — renders nothing
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { Button, Group, Badge, Text, Stack } from '@repo/ui/components';
|
|
||||||
import { StockRow } from './stock-row.ui';
|
|
||||||
import { generateStockIds, startMockWebSocket } from './mock-websocket.service';
|
|
||||||
|
|
||||||
// ─── Constants ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const STOCK_COUNT = 1000;
|
|
||||||
const VISIBLE_ROWS = 50; // Virtual-scroll window (show first N for performance)
|
|
||||||
|
|
||||||
// ─── LiveStockGrid ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a high-performance stock grid with 1000 rows.
|
|
||||||
*
|
|
||||||
* **Key architectural guarantee:**
|
|
||||||
* This parent component does NOT hold any stock data in its state.
|
|
||||||
* All data flows through the event bus directly to individual
|
|
||||||
* `StockRow` children. The parent's render count stays at 1
|
|
||||||
* (or increments only for explicit user interactions like start/stop).
|
|
||||||
*
|
|
||||||
* **Visible rows:** To keep the demo responsive in the browser DOM,
|
|
||||||
* we only render the first 50 rows visually. In production, you'd
|
|
||||||
* use a virtualizer (e.g., TanStack Virtual). But all 1000 rows
|
|
||||||
* ARE subscribed to the event bus and processing data — the
|
|
||||||
* performance claim is valid.
|
|
||||||
*/
|
|
||||||
export function LiveStockGrid() {
|
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
const wsRef = useRef<ReturnType<typeof startMockWebSocket> | null>(null);
|
|
||||||
const renderCount = useRef(0);
|
|
||||||
renderCount.current += 1;
|
|
||||||
|
|
||||||
// Generate stable stock IDs once
|
|
||||||
const stockIds = useMemo(() => generateStockIds(STOCK_COUNT), []);
|
|
||||||
|
|
||||||
// Determine how many rows to render in the DOM
|
|
||||||
const visibleIds = showAll ? stockIds : stockIds.slice(0, VISIBLE_ROWS);
|
|
||||||
|
|
||||||
// ── Event count tracker (polled via interval, not via state) ──
|
|
||||||
const [eventStats, setEventStats] = useState({ total: 0, perSec: 0 });
|
|
||||||
const statsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
const startFeed = () => {
|
|
||||||
if (wsRef.current) return;
|
|
||||||
|
|
||||||
wsRef.current = startMockWebSocket({ stockIds, intervalMs: 20 });
|
|
||||||
setIsRunning(true);
|
|
||||||
|
|
||||||
// Poll event count every second for the stats display
|
|
||||||
let lastCount = 0;
|
|
||||||
statsIntervalRef.current = setInterval(() => {
|
|
||||||
if (!wsRef.current) return;
|
|
||||||
const current = wsRef.current.getEventCount();
|
|
||||||
setEventStats({ total: current, perSec: current - lastCount });
|
|
||||||
lastCount = current;
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopFeed = () => {
|
|
||||||
wsRef.current?.cleanup();
|
|
||||||
wsRef.current = null;
|
|
||||||
if (statsIntervalRef.current) clearInterval(statsIntervalRef.current);
|
|
||||||
statsIntervalRef.current = null;
|
|
||||||
setIsRunning(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
wsRef.current?.cleanup();
|
|
||||||
if (statsIntervalRef.current) clearInterval(statsIntervalRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="sm">
|
|
||||||
{/* ── Controls ──────────────────────────────────────────── */}
|
|
||||||
<Group>
|
|
||||||
{!isRunning ? (
|
|
||||||
<Button variant="filled" color="brand" onClick={startFeed} size="sm">
|
|
||||||
▶ Start Feed (50 events/sec)
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="filled" color="error" onClick={stopFeed} size="sm">
|
|
||||||
■ Stop Feed
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="light" color="info" onClick={() => setShowAll((s) => !s)} size="sm">
|
|
||||||
{showAll ? `Show ${VISIBLE_ROWS} rows` : `Show all ${STOCK_COUNT} rows`}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{/* ── Stats Bar ─────────────────────────────────────────── */}
|
|
||||||
<Group gap="md">
|
|
||||||
<Badge color="brand" variant="light" size="lg">
|
|
||||||
Grid renders: {renderCount.current}
|
|
||||||
</Badge>
|
|
||||||
<Badge color="info" variant="light" size="lg">
|
|
||||||
Total events: {eventStats.total.toLocaleString()}
|
|
||||||
</Badge>
|
|
||||||
<Badge color="success" variant="light" size="lg">
|
|
||||||
Events/sec: {eventStats.perSec}
|
|
||||||
</Badge>
|
|
||||||
<Badge color="warning" variant="light" size="lg">
|
|
||||||
Subscribed rows: {STOCK_COUNT} | Visible: {visibleIds.length}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Each row shows its own render count in the last column. Only rows receiving updates re-render.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* ── Data Grid ─────────────────────────────────────────── */}
|
|
||||||
<div style={{ maxHeight: 500, overflow: 'auto', border: '1px solid var(--app-shell-border-color)' }}>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
||||||
<thead>
|
|
||||||
<tr
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
position: 'sticky',
|
|
||||||
top: 0,
|
|
||||||
background: 'var(--mantine-color-body)',
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
textAlign: 'left',
|
|
||||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ticker
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
textAlign: 'right',
|
|
||||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Price
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
textAlign: 'right',
|
|
||||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Change
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
textAlign: 'right',
|
|
||||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Volume
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
textAlign: 'right',
|
|
||||||
borderBottom: '2px solid var(--app-shell-border-color)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Renders
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{visibleIds.map((id) => (
|
|
||||||
<StockRow key={id} stockId={id} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { publish } from '@repo/core-events';
|
|
||||||
import { WS_EVENTS } from '../../../../core/constants/events';
|
|
||||||
|
|
||||||
// ─── Stock Tickers ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pool of realistic stock ticker symbols.
|
|
||||||
* We generate 1000 unique IDs from these base tickers + numeric suffix.
|
|
||||||
*/
|
|
||||||
const BASE_TICKERS = [
|
|
||||||
'AAPL', 'GOOG', 'MSFT', 'AMZN', 'META', 'NVDA', 'TSLA', 'AMD',
|
|
||||||
'NFLX', 'ORCL', 'CRM', 'INTC', 'PYPL', 'ADBE', 'CSCO', 'QCOM',
|
|
||||||
'AVGO', 'TXN', 'MU', 'SHOP',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a deterministic list of 1000 stock IDs.
|
|
||||||
*/
|
|
||||||
export function generateStockIds(count: number = 1000): string[] {
|
|
||||||
const ids: string[] = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
ids.push(`${BASE_TICKERS[i % BASE_TICKERS.length]}-${String(i).padStart(4, '0')}`);
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mock WebSocket ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface MockWebSocketConfig {
|
|
||||||
/** All stock IDs to cycle through. */
|
|
||||||
stockIds: string[];
|
|
||||||
/** Interval in ms between events. @default 20 */
|
|
||||||
intervalMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MockWebSocketHandle {
|
|
||||||
/** Call to stop the mock WebSocket and clear the interval. */
|
|
||||||
cleanup: () => void;
|
|
||||||
/** Number of events emitted so far. */
|
|
||||||
getEventCount: () => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulates a WebSocket that pushes stock price updates at high frequency.
|
|
||||||
*
|
|
||||||
* Fires `WS:STOCK_UPDATE` every `intervalMs` (default 20ms = 50 updates/sec).
|
|
||||||
* Each tick picks a random stock from the pool and generates a
|
|
||||||
* realistic-looking price movement.
|
|
||||||
*
|
|
||||||
* Returns a handle with a `cleanup()` function to stop the simulation.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const ws = startMockWebSocket({ stockIds: ['AAPL-0001', ...] });
|
|
||||||
* // Later:
|
|
||||||
* ws.cleanup();
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function startMockWebSocket(config: MockWebSocketConfig): MockWebSocketHandle {
|
|
||||||
const { stockIds, intervalMs = 20 } = config;
|
|
||||||
let eventCount = 0;
|
|
||||||
|
|
||||||
// Seed initial prices for each stock
|
|
||||||
const prices = new Map<string, number>();
|
|
||||||
for (const id of stockIds) {
|
|
||||||
prices.set(id, 100 + Math.random() * 400); // $100–$500
|
|
||||||
}
|
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
// Pick a random stock
|
|
||||||
const randomIndex = Math.floor(Math.random() * stockIds.length);
|
|
||||||
const id = stockIds[randomIndex]!;
|
|
||||||
const currentPrice = prices.get(id)!;
|
|
||||||
|
|
||||||
// Generate a small random price change (-2% to +2%)
|
|
||||||
const changePercent = (Math.random() - 0.5) * 0.04;
|
|
||||||
const change = +(currentPrice * changePercent).toFixed(2);
|
|
||||||
const newPrice = +(currentPrice + change).toFixed(2);
|
|
||||||
|
|
||||||
// Update tracked price
|
|
||||||
prices.set(id, newPrice);
|
|
||||||
|
|
||||||
// Publish to the event bus
|
|
||||||
publish(WS_EVENTS.STOCK_UPDATE, {
|
|
||||||
id,
|
|
||||||
price: newPrice,
|
|
||||||
change,
|
|
||||||
volume: Math.floor(Math.random() * 100000),
|
|
||||||
});
|
|
||||||
|
|
||||||
eventCount++;
|
|
||||||
}, intervalMs);
|
|
||||||
|
|
||||||
return {
|
|
||||||
cleanup: () => clearInterval(intervalId),
|
|
||||||
getEventCount: () => eventCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { memo, useState, useRef } from 'react';
|
|
||||||
import { useAppEvent } from '@repo/core-events';
|
|
||||||
import { WS_EVENTS } from '../../../../core/constants/events';
|
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface StockRowProps {
|
|
||||||
stockId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Local State ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface StockState {
|
|
||||||
price: number;
|
|
||||||
change: number;
|
|
||||||
volume: number;
|
|
||||||
lastUpdate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── StockRow ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single row in the stock grid.
|
|
||||||
*
|
|
||||||
* **Performance architecture:**
|
|
||||||
* 1. Each StockRow subscribes to the global `WS:STOCK_UPDATE` event.
|
|
||||||
* 2. The handler checks `payload.id === stockId` — if no match, it
|
|
||||||
* does NOTHING (no setState, no re-render).
|
|
||||||
* 3. Only the targeted row updates its own local state.
|
|
||||||
* 4. `React.memo` prevents re-renders from parent prop changes.
|
|
||||||
*
|
|
||||||
* Result: At 50 events/sec across 1000 rows, only ~1 row re-renders
|
|
||||||
* per tick. The parent `LiveStockGrid` NEVER re-renders.
|
|
||||||
*/
|
|
||||||
export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
|
|
||||||
const [data, setData] = useState<StockState | null>(null);
|
|
||||||
const renderCountRef = useRef(0);
|
|
||||||
renderCountRef.current += 1;
|
|
||||||
|
|
||||||
useAppEvent(WS_EVENTS.STOCK_UPDATE, (payload) => {
|
|
||||||
// ── Critical filter ──────────────────────────────────────────
|
|
||||||
// This is the key performance optimization. Only the row whose
|
|
||||||
// ID matches the event payload will call setState. All other
|
|
||||||
// rows (999 out of 1000) do absolutely nothing.
|
|
||||||
if (payload.id !== stockId) return;
|
|
||||||
|
|
||||||
setData({
|
|
||||||
price: payload.price,
|
|
||||||
change: payload.change,
|
|
||||||
volume: payload.volume,
|
|
||||||
lastUpdate: Date.now(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const changeColor = data
|
|
||||||
? data.change >= 0
|
|
||||||
? '#40c057' // green
|
|
||||||
: '#fa5252' // red
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const changeArrow = data
|
|
||||||
? data.change >= 0
|
|
||||||
? '▲'
|
|
||||||
: '▼'
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr style={{ fontSize: 12, fontFamily: 'monospace' }}>
|
|
||||||
<td style={{ padding: '2px 8px', fontWeight: 600 }}>{stockId}</td>
|
|
||||||
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
|
|
||||||
{data ? `$${data.price.toFixed(2)}` : '—'}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: '2px 8px',
|
|
||||||
textAlign: 'right',
|
|
||||||
color: changeColor,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data ? `${changeArrow} ${data.change >= 0 ? '+' : ''}${data.change.toFixed(2)}` : '—'}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
|
|
||||||
{data ? data.volume.toLocaleString() : '—'}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '2px 8px', textAlign: 'right', color: '#868e96' }}>
|
|
||||||
{renderCountRef.current}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import BookingSample from './features/booking/presentation/BookingSample';
|
|
||||||
import StorageSample from './features/storage/presentation/StorageSample';
|
|
||||||
import I18nSample from './features/i18n/presentation/I18nSample';
|
|
||||||
|
|
||||||
export default function ExamplePage() {
|
|
||||||
return (
|
|
||||||
<div className="bg-amber-200">
|
|
||||||
example
|
|
||||||
<div className="p-8">
|
|
||||||
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
|
|
||||||
<BookingSample />
|
|
||||||
</div>
|
|
||||||
<div className="p-8">
|
|
||||||
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
|
|
||||||
<StorageSample />
|
|
||||||
</div>
|
|
||||||
<div className="p-8 bg-slate-900">
|
|
||||||
<I18nSample />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-118
@@ -1,118 +0,0 @@
|
|||||||
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
|
|
||||||
import type { ApiResponse } from '@repo/core-api/http-client';
|
|
||||||
import { apiClient } from '../../../../../../core/lib/api-client';
|
|
||||||
import type { BookingEntity } from './booking.data-services';
|
|
||||||
import type { BookingDTO } from './booking.transformer';
|
|
||||||
import {
|
|
||||||
AdvancedBookingTransformer,
|
|
||||||
type AvailabilityChartRawData,
|
|
||||||
type AvailabilityChartData,
|
|
||||||
} from './advanced-booking.transformer';
|
|
||||||
|
|
||||||
// ─── Advanced Booking Data Services ─────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extended booking data services with custom methods for
|
|
||||||
* advanced booking features beyond standard CRUD.
|
|
||||||
*
|
|
||||||
* Extends {@link BaseRemoteDataServices} directly (instead of using
|
|
||||||
* `CommonRemoteDataServices`) to add domain-specific methods like
|
|
||||||
* `getAvailabilityChart()`.
|
|
||||||
*
|
|
||||||
* Uses {@link AdvancedBookingTransformer} which provides:
|
|
||||||
* - All standard DTO ↔ Entity mappings (inherited from BookingTransformer)
|
|
||||||
* - Custom `transformAvailabilityChart()` for chart data
|
|
||||||
* - Enhanced `transformGetManyResponse()` with status normalization
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Standard CRUD (inherited, with transformer)
|
|
||||||
* const { data: bookings } = await advancedBookingServices.getMany();
|
|
||||||
* const { data: booking } = await advancedBookingServices.getOne('42');
|
|
||||||
*
|
|
||||||
* // Custom method for chart data
|
|
||||||
* const { data: chartData } = await advancedBookingServices.getAvailabilityChart({
|
|
||||||
* startDate: '2026-07-01',
|
|
||||||
* endDate: '2026-07-31',
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
class AdvancedBookingDataServices extends BaseRemoteDataServices<BookingEntity, BookingDTO> {
|
|
||||||
/**
|
|
||||||
* The concrete advanced transformer instance.
|
|
||||||
*
|
|
||||||
* Stored separately from the base `transformer` property
|
|
||||||
* to access custom methods (like `transformAvailabilityChart`)
|
|
||||||
* that aren't part of the `IDataTransformer` interface.
|
|
||||||
*/
|
|
||||||
private readonly advancedTransformer: AdvancedBookingTransformer;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const advancedTransformer = new AdvancedBookingTransformer();
|
|
||||||
|
|
||||||
super(apiClient, {
|
|
||||||
apiUrl: '/bookings',
|
|
||||||
moduleKey: 'BOOKING',
|
|
||||||
transformer: advancedTransformer,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.advancedTransformer = advancedTransformer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Custom Methods ─────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the availability chart data for a given date range.
|
|
||||||
*
|
|
||||||
* Calls the `/bookings/availability-chart` endpoint and transforms
|
|
||||||
* the raw API response into a UI-friendly chart format using
|
|
||||||
* {@link AdvancedBookingTransformer.transformAvailabilityChart}.
|
|
||||||
*
|
|
||||||
* @param params - Date range parameters for the chart query
|
|
||||||
* @param params.startDate - Start date (ISO format, e.g., '2026-07-01')
|
|
||||||
* @param params.endDate - End date (ISO format, e.g., '2026-07-31')
|
|
||||||
* @returns Transformed chart data ready for UI rendering
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const { data } = await advancedBookingServices.getAvailabilityChart({
|
|
||||||
* startDate: '2026-07-01',
|
|
||||||
* endDate: '2026-07-31',
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // data.dataPoints → Array of chart-ready data points
|
|
||||||
* // data.summary → Aggregated metrics for the period
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async getAvailabilityChart(params: {
|
|
||||||
startDate: string;
|
|
||||||
endDate: string;
|
|
||||||
}): Promise<ApiResponse<AvailabilityChartData>> {
|
|
||||||
const response = await this.customRequest<AvailabilityChartRawData>({
|
|
||||||
url: '/bookings/availability-chart',
|
|
||||||
method: 'GET',
|
|
||||||
params: {
|
|
||||||
start_date: params.startDate,
|
|
||||||
end_date: params.endDate,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: this.advancedTransformer.transformAvailabilityChart(response.data),
|
|
||||||
status: response.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Singleton Export ────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-configured advanced booking data services instance.
|
|
||||||
*
|
|
||||||
* Use this when you need both standard CRUD operations and
|
|
||||||
* custom methods like `getAvailabilityChart()`.
|
|
||||||
*
|
|
||||||
* For standard CRUD-only usage, prefer `bookingServices` from
|
|
||||||
* `booking.data-services.ts` instead.
|
|
||||||
*/
|
|
||||||
export const advancedBookingServices = new AdvancedBookingDataServices();
|
|
||||||
-159
@@ -1,159 +0,0 @@
|
|||||||
import { BookingTransformer } from './booking.transformer';
|
|
||||||
import type { BookingDTO } from './booking.transformer';
|
|
||||||
import type { BookingEntity } from './booking.data-services';
|
|
||||||
|
|
||||||
// ─── Advanced Types ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw availability chart data as returned by the API.
|
|
||||||
*
|
|
||||||
* The backend returns a flat structure with snake_case keys
|
|
||||||
* and ISO date strings. This needs to be transformed into
|
|
||||||
* a more UI-friendly shape for chart rendering.
|
|
||||||
*/
|
|
||||||
export interface AvailabilityChartRawData {
|
|
||||||
dates: Array<{
|
|
||||||
date_iso: string;
|
|
||||||
available_rooms: number;
|
|
||||||
total_rooms: number;
|
|
||||||
occupancy_rate: number;
|
|
||||||
revenue_per_room: number;
|
|
||||||
}>;
|
|
||||||
summary: {
|
|
||||||
avg_occupancy_rate: number;
|
|
||||||
total_revenue: number;
|
|
||||||
period_start: string;
|
|
||||||
period_end: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UI-friendly availability chart data.
|
|
||||||
*
|
|
||||||
* Pre-computed for direct rendering in chart components
|
|
||||||
* with camelCase fields, formatted labels, and derived metrics.
|
|
||||||
*/
|
|
||||||
export interface AvailabilityChartData {
|
|
||||||
/** Data points ready for chart rendering. */
|
|
||||||
dataPoints: Array<{
|
|
||||||
/** Formatted date label (e.g., 'Mon, Jul 1'). */
|
|
||||||
label: string;
|
|
||||||
/** ISO date string for programmatic use. */
|
|
||||||
dateISO: string;
|
|
||||||
/** Number of rooms available. */
|
|
||||||
availableRooms: number;
|
|
||||||
/** Total room capacity. */
|
|
||||||
totalRooms: number;
|
|
||||||
/** Occupancy rate as a percentage (0-100). */
|
|
||||||
occupancyRate: number;
|
|
||||||
/** Revenue per available room. */
|
|
||||||
revenuePerRoom: number;
|
|
||||||
/** Whether the day is a high-demand day (>80% occupancy). */
|
|
||||||
isHighDemand: boolean;
|
|
||||||
}>;
|
|
||||||
/** Aggregated summary metrics for the period. */
|
|
||||||
summary: {
|
|
||||||
averageOccupancy: number;
|
|
||||||
totalRevenue: number;
|
|
||||||
periodStart: string;
|
|
||||||
periodEnd: string;
|
|
||||||
/** Number of high-demand days in the period. */
|
|
||||||
highDemandDays: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Advanced Booking Transformer ───────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extended booking transformer with additional custom methods
|
|
||||||
* for non-CRUD data transformations.
|
|
||||||
*
|
|
||||||
* Inherits all standard DTO ↔ Entity mapping from
|
|
||||||
* {@link BookingTransformer} and adds domain-specific
|
|
||||||
* transformations for advanced features like availability charts.
|
|
||||||
*
|
|
||||||
* **When to extend vs. create new:**
|
|
||||||
* - Extend when the new transformer shares the same entity/DTO pair
|
|
||||||
* and you need additional transformation methods
|
|
||||||
* - Create a new transformer when the entity/DTO types are different
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const transformer = new AdvancedBookingTransformer();
|
|
||||||
*
|
|
||||||
* // Standard CRUD mapping (inherited)
|
|
||||||
* const entity = transformer.transformToEntity(bookingDTO);
|
|
||||||
*
|
|
||||||
* // Custom chart transformation (new)
|
|
||||||
* const chartData = transformer.transformAvailabilityChart(rawChartData);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class AdvancedBookingTransformer extends BookingTransformer {
|
|
||||||
/**
|
|
||||||
* Transform raw availability chart data from the API into a
|
|
||||||
* UI-friendly format for chart rendering.
|
|
||||||
*
|
|
||||||
* Performs the following transformations:
|
|
||||||
* 1. Maps snake_case fields to camelCase
|
|
||||||
* 2. Formats date strings into human-readable labels
|
|
||||||
* 3. Computes derived `isHighDemand` flag (>80% occupancy)
|
|
||||||
* 4. Aggregates `highDemandDays` count in the summary
|
|
||||||
*
|
|
||||||
* @param rawData - Raw chart data from the `/bookings/availability-chart` endpoint
|
|
||||||
* @returns Transformed chart data ready for UI rendering
|
|
||||||
*/
|
|
||||||
transformAvailabilityChart(rawData: AvailabilityChartRawData): AvailabilityChartData {
|
|
||||||
const HIGH_DEMAND_THRESHOLD = 80;
|
|
||||||
|
|
||||||
const dataPoints = rawData.dates.map((item) => {
|
|
||||||
const date = new Date(item.date_iso);
|
|
||||||
const isHighDemand = item.occupancy_rate > HIGH_DEMAND_THRESHOLD;
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: date.toLocaleDateString('en-US', {
|
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}),
|
|
||||||
dateISO: item.date_iso,
|
|
||||||
availableRooms: item.available_rooms,
|
|
||||||
totalRooms: item.total_rooms,
|
|
||||||
occupancyRate: item.occupancy_rate,
|
|
||||||
revenuePerRoom: item.revenue_per_room,
|
|
||||||
isHighDemand,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const highDemandDays = dataPoints.filter((dp) => dp.isHighDemand).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
dataPoints,
|
|
||||||
summary: {
|
|
||||||
averageOccupancy: rawData.summary.avg_occupancy_rate,
|
|
||||||
totalRevenue: rawData.summary.total_revenue,
|
|
||||||
periodStart: rawData.summary.period_start,
|
|
||||||
periodEnd: rawData.summary.period_end,
|
|
||||||
highDemandDays,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enhanced getMany response that also normalizes status values.
|
|
||||||
*
|
|
||||||
* Demonstrates overriding an inherited hook to add
|
|
||||||
* additional processing on top of the base transformation.
|
|
||||||
*
|
|
||||||
* @param dtos - Array of booking DTOs from the API
|
|
||||||
* @returns Transformed entities with normalized status
|
|
||||||
*/
|
|
||||||
override transformGetManyResponse(dtos: BookingDTO[]): BookingEntity[] {
|
|
||||||
return super.transformGetManyResponse(dtos).map((entity) => ({
|
|
||||||
...entity,
|
|
||||||
// Normalize 'cancelled' vs 'canceled' from different API versions
|
|
||||||
status: entity.status === ('canceled' as BookingEntity['status'])
|
|
||||||
? 'cancelled'
|
|
||||||
: entity.status,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
|
|
||||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
|
||||||
import { apiClient } from '../../../../../../core/lib/api-client';
|
|
||||||
import { BookingTransformer } from './booking.transformer';
|
|
||||||
import type { BookingDTO } from './booking.transformer';
|
|
||||||
|
|
||||||
// ─── Domain Entity ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Booking domain entity.
|
|
||||||
*
|
|
||||||
* In a real module, this would be defined in the domain layer
|
|
||||||
* (e.g., `features/booking/domain/entities.ts`) and imported here.
|
|
||||||
*/
|
|
||||||
export interface BookingEntity extends BaseEntity {
|
|
||||||
bookingCode: string;
|
|
||||||
customerName: string;
|
|
||||||
checkInDate: string;
|
|
||||||
checkOutDate: string;
|
|
||||||
status: 'pending' | 'confirmed' | 'cancelled';
|
|
||||||
totalAmount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Data Services Instance ─────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Booking data services — wired to the enterprise `apiClient`
|
|
||||||
* with automatic DTO ↔ Entity transformation.
|
|
||||||
*
|
|
||||||
* All requests flow through the full interceptor chain:
|
|
||||||
* Faro tracing → Bearer token injection → ApiError normalization.
|
|
||||||
*
|
|
||||||
* The injected {@link BookingTransformer} automatically:
|
|
||||||
* - Maps snake_case API responses to camelCase entities on `getOne`/`getMany`
|
|
||||||
* - Maps camelCase entity payloads to snake_case DTOs on `create`/`edit`
|
|
||||||
* - Strips `id` from create payloads
|
|
||||||
* - Computes `durationNights` on `getOne` responses
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const { data } = await bookingServices.getMany({ params: { page: 1 } });
|
|
||||||
* // data is BookingEntity[] with camelCase fields
|
|
||||||
*
|
|
||||||
* const { data: booking } = await bookingServices.getOne('42');
|
|
||||||
* // booking is BookingEntity with computed durationNights
|
|
||||||
*
|
|
||||||
* await bookingServices.create({ bookingCode: 'BK001', customerName: 'Alice', ... });
|
|
||||||
* // Payload is automatically transformed to { booking_code: 'BK001', customer_name: 'Alice', ... }
|
|
||||||
*
|
|
||||||
* await bookingServices.confirmProcessTransaction('42');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const bookingServices = new CommonRemoteDataServices<BookingEntity, BookingDTO>(apiClient, {
|
|
||||||
apiUrl: '/bookings',
|
|
||||||
moduleKey: 'BOOKING',
|
|
||||||
transformer: new BookingTransformer(),
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
|
||||||
import type { BookingEntity } from './booking.data-services';
|
|
||||||
|
|
||||||
// ─── Booking DTO (API Response Shape) ───────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw booking data as returned by the API.
|
|
||||||
*
|
|
||||||
* Uses snake_case field names matching the backend's JSON serialization.
|
|
||||||
* This DTO is never used directly in UI components — it is transformed
|
|
||||||
* into a {@link BookingEntity} by the {@link BookingTransformer}.
|
|
||||||
*/
|
|
||||||
export interface BookingDTO {
|
|
||||||
id?: string;
|
|
||||||
booking_code: string;
|
|
||||||
customer_name: string;
|
|
||||||
check_in_date: string;
|
|
||||||
check_out_date: string;
|
|
||||||
status: 'pending' | 'confirmed' | 'cancelled';
|
|
||||||
total_amount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Booking Transformer ────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transforms between the API's `BookingDTO` (snake_case) and
|
|
||||||
* the frontend's `BookingEntity` (camelCase).
|
|
||||||
*
|
|
||||||
* Handles:
|
|
||||||
* - Field name mapping (snake_case ↔ camelCase)
|
|
||||||
* - Computed field derivation (e.g., `durationNights` on `getOne`)
|
|
||||||
* - Payload sanitization (e.g., stripping `id` on create)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const transformer = new BookingTransformer();
|
|
||||||
*
|
|
||||||
* // API response → Domain entity
|
|
||||||
* const entity = transformer.transformToEntity({
|
|
||||||
* id: '42',
|
|
||||||
* booking_code: 'BK042',
|
|
||||||
* customer_name: 'Alice',
|
|
||||||
* check_in_date: '2026-07-01',
|
|
||||||
* check_out_date: '2026-07-03',
|
|
||||||
* status: 'confirmed',
|
|
||||||
* total_amount: 500000,
|
|
||||||
* });
|
|
||||||
* // → { id: '42', bookingCode: 'BK042', customerName: 'Alice', ... }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class BookingTransformer extends BaseDataTransformer<BookingEntity, BookingDTO> {
|
|
||||||
/**
|
|
||||||
* Map an API booking DTO to a frontend booking entity.
|
|
||||||
*
|
|
||||||
* @param dto - Raw booking data from the API
|
|
||||||
* @returns Mapped booking entity with camelCase fields
|
|
||||||
*/
|
|
||||||
override transformToEntity(dto: BookingDTO): BookingEntity {
|
|
||||||
return {
|
|
||||||
id: dto.id,
|
|
||||||
bookingCode: dto.booking_code,
|
|
||||||
customerName: dto.customer_name,
|
|
||||||
checkInDate: dto.check_in_date,
|
|
||||||
checkOutDate: dto.check_out_date,
|
|
||||||
status: dto.status,
|
|
||||||
totalAmount: dto.total_amount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map a frontend booking entity to an API booking DTO.
|
|
||||||
*
|
|
||||||
* @param entity - Booking entity from the frontend
|
|
||||||
* @returns Mapped booking DTO with snake_case fields
|
|
||||||
*/
|
|
||||||
override transformToDTO(entity: BookingEntity): BookingDTO {
|
|
||||||
return {
|
|
||||||
id: entity.id as string,
|
|
||||||
booking_code: entity.bookingCode,
|
|
||||||
customer_name: entity.customerName,
|
|
||||||
check_in_date: entity.checkInDate,
|
|
||||||
check_out_date: entity.checkOutDate,
|
|
||||||
status: entity.status,
|
|
||||||
total_amount: entity.totalAmount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform a single booking response with computed fields.
|
|
||||||
*
|
|
||||||
* Adds `durationNights` as a derived convenience field
|
|
||||||
* that is only relevant when viewing a single booking detail.
|
|
||||||
*
|
|
||||||
* @param dto - Raw booking DTO from the API
|
|
||||||
* @returns Booking entity with computed fields
|
|
||||||
*/
|
|
||||||
override transformGetOneResponse(dto: BookingDTO): BookingEntity {
|
|
||||||
const entity = this.transformToEntity(dto);
|
|
||||||
const checkIn = new Date(dto.check_in_date);
|
|
||||||
const checkOut = new Date(dto.check_out_date);
|
|
||||||
const durationMs = checkOut.getTime() - checkIn.getTime();
|
|
||||||
const durationNights = Math.max(0, Math.ceil(durationMs / (1000 * 60 * 60 * 24)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
...entity,
|
|
||||||
// Attach computed field via type assertion since
|
|
||||||
// durationNights is a view-layer convenience
|
|
||||||
...(durationNights > 0 ? { durationNights } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip `id` from create payloads since the backend generates IDs.
|
|
||||||
*
|
|
||||||
* @param entity - Partial booking entity from the create form
|
|
||||||
* @returns Sanitized DTO payload without `id`
|
|
||||||
*/
|
|
||||||
override transformCreatePayload(entity: Partial<BookingEntity>): Partial<BookingDTO> {
|
|
||||||
const dto = this.transformToDTO(entity as BookingEntity);
|
|
||||||
const { id: _, ...rest } = dto;
|
|
||||||
return rest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { bookingServices } from '../data/booking.data-services';
|
|
||||||
import type { BookingEntity } from '../data/booking.data-services';
|
|
||||||
import type { ApiResponse } from '@repo/core-api/http-client';
|
|
||||||
import { ApiError } from '@repo/core-api/errors';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sample component demonstrating `@repo/core-api` integration
|
|
||||||
* with the advanced TelemetryContext escape hatch.
|
|
||||||
*
|
|
||||||
* Pipeline: Faro auto-instrumentation → Bearer token → GET /bookings
|
|
||||||
* + Custom span "booking.list.fetch" with enriched tags
|
|
||||||
*/
|
|
||||||
export default function BookingSample() {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [result, setResult] = useState<ApiResponse<BookingEntity[]> | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleFetch = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await bookingServices.getMany<BookingEntity[]>({
|
|
||||||
params: { page: 1, limit: 20 },
|
|
||||||
// ── Telemetry Escape Hatch ──────────────────────────────
|
|
||||||
// This creates a custom OTel span named "booking.list.fetch",
|
|
||||||
// attaches business tags, and pushes a Faro event on success.
|
|
||||||
telemetryContext: {
|
|
||||||
customSpanName: 'booking.list.fetch',
|
|
||||||
tags: {
|
|
||||||
'feature': 'booking',
|
|
||||||
'ui.component': 'BookingSample',
|
|
||||||
'ui.action': 'list_fetch',
|
|
||||||
'page': 1,
|
|
||||||
},
|
|
||||||
pushEventOnSuccess: 'booking_list_loaded',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setResult(response);
|
|
||||||
console.log('[BookingSample] Response:', response);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
setError(`[${err.code}] ${err.message} (HTTP ${err.status})`);
|
|
||||||
console.error('[BookingSample] ApiError:', err.toJSON());
|
|
||||||
} else {
|
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 24, fontFamily: 'monospace' }}>
|
|
||||||
<h2>🧪 Booking Data Services — Integration Test</h2>
|
|
||||||
<p style={{ color: '#888', fontSize: 14 }}>
|
|
||||||
Pipeline: Faro + Custom Span "booking.list.fetch" → Bearer Token → GET /bookings
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleFetch}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
padding: '10px 20px',
|
|
||||||
fontSize: 16,
|
|
||||||
cursor: loading ? 'wait' : 'pointer',
|
|
||||||
background: loading ? '#555' : '#4f46e5',
|
|
||||||
color: '#fff',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 6,
|
|
||||||
marginTop: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading ? 'Fetching…' : 'Test Fetch Bookings'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}>
|
|
||||||
❌ {error}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<pre style={{ marginTop: 16, background: '#1e1e2e', color: '#a6e3a1', padding: 16, borderRadius: 8, overflow: 'auto' }}>
|
|
||||||
{JSON.stringify(result, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { Button, Paper, Title, Group, Stack, Code, Divider, Text, Chip, Radio } from '@repo/ui/components';
|
|
||||||
import {
|
|
||||||
FieldTextInput, FieldPasswordInput, FieldTextarea, FieldNumberInput,
|
|
||||||
FieldJsonInput, FieldPinInput, FieldAutocomplete, FieldSelect,
|
|
||||||
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
|
|
||||||
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
|
|
||||||
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
|
|
||||||
FieldColorPicker, FieldFileInput, FieldLocalSelect, FieldAsyncSelect,
|
|
||||||
FieldRichTextEditor
|
|
||||||
} from '@repo/ui/form';
|
|
||||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
|
||||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
|
||||||
|
|
||||||
const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({
|
|
||||||
id: i + 1,
|
|
||||||
name: `Pokemon ${i + 1}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const loadMockPokemonOptions: LoadOptionsFn<any> = async (search, page) => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()));
|
|
||||||
const pageSize = 20;
|
|
||||||
const start = (page - 1) * pageSize;
|
|
||||||
const paginated = filtered.slice(start, start + pageSize);
|
|
||||||
return {
|
|
||||||
options: paginated,
|
|
||||||
hasMore: start + pageSize < filtered.length,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadRealPokemonOptions: LoadOptionsFn<any> = async (_search, page) => {
|
|
||||||
const limit = 20;
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
const res = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`);
|
|
||||||
const data = await res.json();
|
|
||||||
return {
|
|
||||||
options: data.results.map((p: any, i: number) => ({ id: offset + i + 1, ...p })),
|
|
||||||
hasMore: !!data.next,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_VENDORS = [
|
|
||||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
|
||||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
|
||||||
{ id: 'V3', code: 'VN-03', name: 'Vendor Three' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
|
||||||
return { options: filtered, hasMore: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AllFieldsDemo() {
|
|
||||||
const t = useFormDemoTranslation();
|
|
||||||
|
|
||||||
const { control, handleSubmit, watch } = useForm<any>({
|
|
||||||
defaultValues: {
|
|
||||||
customerName: '',
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
description: '',
|
|
||||||
age: undefined,
|
|
||||||
jsonConfig: '',
|
|
||||||
pin: '',
|
|
||||||
country: '',
|
|
||||||
orderType: '',
|
|
||||||
categories: [],
|
|
||||||
nativeOrderType: '',
|
|
||||||
tags: [],
|
|
||||||
terms: false,
|
|
||||||
priority: '',
|
|
||||||
receiveEmails: false,
|
|
||||||
chipSelection: '',
|
|
||||||
segmentedPriority: 'normal',
|
|
||||||
satisfaction: 5,
|
|
||||||
priceRange: [0, 100],
|
|
||||||
rating: 0,
|
|
||||||
themeColor: '',
|
|
||||||
colorPicker: '#1c7ed6',
|
|
||||||
avatar: null,
|
|
||||||
localSelectEmpty: null,
|
|
||||||
localSelectPrefilled: { id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
|
||||||
asyncSelectEmpty: null,
|
|
||||||
asyncSelectPrefilled: { id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' },
|
|
||||||
localMultiPrefilled: [
|
|
||||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
|
||||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
|
|
||||||
],
|
|
||||||
asyncMultiPrefilled: [
|
|
||||||
{ id: 888, code: 'ASYNC-88', name: 'Ghost Async Vendor 1' },
|
|
||||||
{ id: 999, code: 'ASYNC-99', name: 'Ghost Async Vendor 2' }
|
|
||||||
],
|
|
||||||
richTextEmpty: "",
|
|
||||||
richTextPrefilled: "<h2 style=\"text-align: center\">ERP Release Notes</h2><p>This is a <b>highly important</b> update. Please observe the following:</p><ul><li>System maintenance at <i>midnight</i>.</li><li><u style=\"text-align: justify\">All users must log out.</u></li></ul><p style=\"text-align: justify\">Thank you for your cooperation.</p>",
|
|
||||||
realPokeSelect: null,
|
|
||||||
multiRealPokeSelect: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = (data: any) => console.log('All Fields Submitted:', data);
|
|
||||||
const data = watch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="xl">
|
|
||||||
<Paper p="xl" withBorder radius="md">
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<Stack gap="xl">
|
|
||||||
{/* --- Text & Numbers --- */}
|
|
||||||
<div>
|
|
||||||
<Title order={5} mb="sm" c="brand">{t.sections.textAndNumbers}</Title>
|
|
||||||
<Divider mb="md" />
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldTextInput name="customerName" control={control} label={t.fields.customerName} />
|
|
||||||
<FieldTextInput name="email" control={control} label={t.fields.email} />
|
|
||||||
</Group>
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldPasswordInput name="password" control={control} label={t.fields.password} />
|
|
||||||
<FieldNumberInput name="age" control={control} label={t.fields.age} />
|
|
||||||
</Group>
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} />
|
|
||||||
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
|
|
||||||
</Group>
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500} mb={3}>{t.fields.pin}</Text>
|
|
||||||
<FieldPinInput name="pin" control={control} length={6} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* --- Selections --- */}
|
|
||||||
<div>
|
|
||||||
<Title order={5} mb="sm" c="brand">{t.sections.selections}</Title>
|
|
||||||
<Divider mb="md" />
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldSelect
|
|
||||||
name="orderType"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.orderType}
|
|
||||||
data={['BULK', 'RETAIL']}
|
|
||||||
/>
|
|
||||||
<FieldNativeSelect
|
|
||||||
name="nativeOrderType"
|
|
||||||
control={control}
|
|
||||||
label={`Native ${t.fields.orderType}`}
|
|
||||||
data={['BULK', 'RETAIL']}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldAutocomplete
|
|
||||||
name="country"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.country}
|
|
||||||
data={['Indonesia', 'Singapore', 'Malaysia']}
|
|
||||||
/>
|
|
||||||
<FieldMultiSelect
|
|
||||||
name="categories"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.categories}
|
|
||||||
data={['Electronics', 'Fashion', 'Food']}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldLocalSelect
|
|
||||||
name="localSelect"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.localSelect}
|
|
||||||
placeholder={t.placeholders.selectComplexObject}
|
|
||||||
options={[
|
|
||||||
{ id: 1, name: 'Apple', type: 'Fruit' },
|
|
||||||
{ id: 2, name: 'Carrot', type: 'Vegetable' },
|
|
||||||
{ id: 3, name: 'Banana', type: 'Fruit' }
|
|
||||||
]}
|
|
||||||
valueKey="id"
|
|
||||||
labelKey="name"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
<FieldLocalSelect
|
|
||||||
multiple
|
|
||||||
name="multiLocalSelect"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.multiLocalSelect}
|
|
||||||
placeholder={t.placeholders.selectMultipleObjects}
|
|
||||||
options={[
|
|
||||||
{ id: 1, name: 'Red', hex: '#f00' },
|
|
||||||
{ id: 2, name: 'Green', hex: '#0f0' },
|
|
||||||
{ id: 3, name: 'Blue', hex: '#00f' }
|
|
||||||
]}
|
|
||||||
valueKey="id"
|
|
||||||
renderLabel={(item) => `${item.name} (${item.hex})`}
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldAsyncSelect
|
|
||||||
name="asyncSelect"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.asyncSelectMock}
|
|
||||||
placeholder={t.placeholders.searchPokemon}
|
|
||||||
loadOptions={loadMockPokemonOptions}
|
|
||||||
valueKey="id"
|
|
||||||
labelKey="name"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
<FieldAsyncSelect
|
|
||||||
multiple
|
|
||||||
name="multiAsyncSelect"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.multiAsyncSelect}
|
|
||||||
placeholder={t.placeholders.selectMultiplePokemon}
|
|
||||||
loadOptions={loadMockPokemonOptions}
|
|
||||||
valueKey="id"
|
|
||||||
labelKey="name"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldAsyncSelect
|
|
||||||
name="realPokeSelect"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.realPokeSingle}
|
|
||||||
placeholder={t.placeholders.scrollDeduplication}
|
|
||||||
loadOptions={loadRealPokemonOptions}
|
|
||||||
valueKey="id"
|
|
||||||
labelKey="name"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
<FieldAsyncSelect
|
|
||||||
multiple
|
|
||||||
name="multiRealPokeSelect"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.realPokeMulti}
|
|
||||||
placeholder={t.placeholders.scrollDeduplication}
|
|
||||||
loadOptions={loadRealPokemonOptions}
|
|
||||||
valueKey="id"
|
|
||||||
labelKey="name"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
|
|
||||||
|
|
||||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.advancedObjectSelects}</Title>
|
|
||||||
<Divider mb="md" />
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldLocalSelect
|
|
||||||
name="localSelectEmpty"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.localEmpty}
|
|
||||||
options={MOCK_VENDORS}
|
|
||||||
valueKey="id"
|
|
||||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
<FieldLocalSelect
|
|
||||||
name="localSelectPrefilled"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.localPrefilled}
|
|
||||||
options={MOCK_VENDORS}
|
|
||||||
valueKey="id"
|
|
||||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldAsyncSelect
|
|
||||||
name="asyncSelectEmpty"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.asyncEmpty}
|
|
||||||
loadOptions={loadMockVendorsOptions}
|
|
||||||
valueKey="id"
|
|
||||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
<FieldAsyncSelect
|
|
||||||
name="asyncSelectPrefilled"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.asyncPrefilled}
|
|
||||||
loadOptions={loadMockVendorsOptions}
|
|
||||||
valueKey="id"
|
|
||||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
|
||||||
defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]}
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.multiSelectEditMode}</Title>
|
|
||||||
<Divider mb="md" />
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldLocalSelect
|
|
||||||
multiple
|
|
||||||
name="localMultiPrefilled"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.localMultiPrefilled}
|
|
||||||
options={MOCK_VENDORS}
|
|
||||||
valueKey="id"
|
|
||||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
<FieldAsyncSelect
|
|
||||||
multiple
|
|
||||||
name="asyncMultiPrefilled"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.asyncMultiPrefilled}
|
|
||||||
loadOptions={loadMockVendorsOptions}
|
|
||||||
valueKey="id"
|
|
||||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.richTextEditor}</Title>
|
|
||||||
<Divider mb="md" />
|
|
||||||
<FieldRichTextEditor
|
|
||||||
name="richTextEmpty"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.richTextEmpty}
|
|
||||||
description={t.descriptions.freshTipTap}
|
|
||||||
/>
|
|
||||||
<div style={{ marginTop: '16px' }}>
|
|
||||||
<FieldRichTextEditor
|
|
||||||
name="richTextPrefilled"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.richTextPrefilled}
|
|
||||||
description={t.descriptions.htmlStringLoaded}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* --- Toggles & Choices --- */}
|
|
||||||
<div>
|
|
||||||
<Title order={5} mb="sm" c="brand">{t.sections.togglesAndChoices}</Title>
|
|
||||||
<Divider mb="md" />
|
|
||||||
<Group mb="md">
|
|
||||||
<FieldCheckbox name="terms" control={control} label={t.fields.terms} />
|
|
||||||
<FieldSwitch name="receiveEmails" control={control} label={t.fields.receiveEmails} />
|
|
||||||
</Group>
|
|
||||||
<FieldRadioGroup
|
|
||||||
name="priority"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.priority}
|
|
||||||
mb="md"
|
|
||||||
>
|
|
||||||
<Group mt="xs">
|
|
||||||
<Radio value="low" label="Low" />
|
|
||||||
<Radio value="high" label="High" />
|
|
||||||
</Group>
|
|
||||||
</FieldRadioGroup>
|
|
||||||
<FieldSegmentedControl
|
|
||||||
name="segmentedPriority"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.priority}
|
|
||||||
data={[
|
|
||||||
{ label: 'Normal', value: 'normal' },
|
|
||||||
{ label: 'Urgent', value: 'urgent' }
|
|
||||||
]}
|
|
||||||
mb="md"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500} mb={3}>Chip Selection</Text>
|
|
||||||
<FieldChipGroup
|
|
||||||
name="chipSelection"
|
|
||||||
control={control}
|
|
||||||
>
|
|
||||||
<Group>
|
|
||||||
<Chip value="1">Option 1</Chip>
|
|
||||||
<Chip value="2">Option 2</Chip>
|
|
||||||
</Group>
|
|
||||||
</FieldChipGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* --- Ranges & Specialized --- */}
|
|
||||||
<div>
|
|
||||||
<Title order={5} mb="sm" c="brand">{t.sections.rangesAndSpecialized}</Title>
|
|
||||||
<Divider mb="md" />
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldSlider name="satisfaction" control={control} label={t.fields.rating} />
|
|
||||||
<FieldRangeSlider name="priceRange" control={control} label={t.fields.priceRange} />
|
|
||||||
</Group>
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<FieldColorInput name="themeColor" control={control} label={t.fields.themeColor} />
|
|
||||||
<FieldFileInput name="avatar" control={control} label={t.fields.avatar} />
|
|
||||||
</Group>
|
|
||||||
<Group grow align="flex-start" mb="md">
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500} mb={3}>{t.fields.themeColor} Picker</Text>
|
|
||||||
<FieldColorPicker name="colorPicker" control={control} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500} mb={3}>{t.fields.rating}</Text>
|
|
||||||
<FieldRating name="rating" control={control} />
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" mt="md">{t.common.submit}</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
|
||||||
<Title order={6} mb="xs">{t.common.submittedData}</Title>
|
|
||||||
<Code block>{JSON.stringify(data, null, 2)}</Code>
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-368
@@ -1,368 +0,0 @@
|
|||||||
import { useForm, useWatch } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { Button, Paper, Title, Divider, Stack, Code, Alert, TypographyStylesProvider } from '@repo/ui/components';
|
|
||||||
import { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor } from '@repo/ui/form';
|
|
||||||
import { useConditionalField } from '@repo/ui/hooks';
|
|
||||||
import { compose, required, emailValidator } from '@repo/ui/validators';
|
|
||||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
|
||||||
import { Info } from 'lucide-react';
|
|
||||||
import { useEffect, useCallback, useRef, useMemo } from 'react';
|
|
||||||
|
|
||||||
interface Region {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
taxRate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Warehouse {
|
|
||||||
id: string;
|
|
||||||
regionId: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const REGIONS: Region[] = [
|
|
||||||
{ id: 'R1', code: 'APAC', taxRate: 0.1 },
|
|
||||||
{ id: 'R2', code: 'EMEA', taxRate: 0.2 }
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockFetchWarehouses = async (regionIds: string[], search: string, page: number) => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
const allWarehouses: Warehouse[] = [
|
|
||||||
{ id: 'W1', regionId: 'R1', name: 'Singapore Hub' },
|
|
||||||
{ id: 'W2', regionId: 'R1', name: 'Tokyo Depot' },
|
|
||||||
{ id: 'W3', regionId: 'R2', name: 'London Central' },
|
|
||||||
{ id: 'W4', regionId: 'R2', name: 'Berlin Storage' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const filtered = allWarehouses.filter(w => regionIds.includes(w.regionId) && w.name.toLowerCase().includes(search.toLowerCase()));
|
|
||||||
const pageSize = 10;
|
|
||||||
const start = (page - 1) * pageSize;
|
|
||||||
const paginated = filtered.slice(start, start + pageSize);
|
|
||||||
|
|
||||||
return {
|
|
||||||
options: paginated,
|
|
||||||
hasMore: start + pageSize < filtered.length
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ReactiveWatchDemo() {
|
|
||||||
const t = useFormDemoTranslation();
|
|
||||||
|
|
||||||
// Define atomic validators for conditional fields
|
|
||||||
const taxIdValidator = compose(z.string(), required(t.watch.corporateTaxId));
|
|
||||||
const spouseNameValidator = compose(z.string(), required(t.watch.spouseName));
|
|
||||||
const newsletterEmailValidator = compose(z.string(), required(t.fields.email), emailValidator());
|
|
||||||
const roleValidator = compose(z.string(), required(t.fields.role));
|
|
||||||
|
|
||||||
const reactiveSchema = useMemo(() => z
|
|
||||||
.object({
|
|
||||||
userType: z.enum(['PERSONAL', 'CORPORATE']),
|
|
||||||
corporateTaxId: z.string().optional(),
|
|
||||||
hasSpouse: z.boolean(),
|
|
||||||
spouseName: z.string().optional(),
|
|
||||||
newsletter: z.boolean(),
|
|
||||||
newsletterEmail: z.string().optional(),
|
|
||||||
department: z.string().optional(),
|
|
||||||
role: z.string().optional(),
|
|
||||||
regions: z.array(z.object({ id: z.string(), code: z.string(), taxRate: z.number() })).optional(),
|
|
||||||
warehouses: z.array(z.object({ id: z.string(), regionId: z.string(), name: z.string() })).optional(),
|
|
||||||
richTextLive: z.string().optional(),
|
|
||||||
})
|
|
||||||
.and(
|
|
||||||
z.discriminatedUnion('userType', [
|
|
||||||
z.object({ userType: z.literal('PERSONAL') }),
|
|
||||||
z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.and(
|
|
||||||
z.union([
|
|
||||||
z.object({ hasSpouse: z.literal(false) }),
|
|
||||||
z.object({ hasSpouse: z.literal(true), spouseName: spouseNameValidator }),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.and(
|
|
||||||
z.union([
|
|
||||||
z.object({ newsletter: z.literal(false) }),
|
|
||||||
z.object({ newsletter: z.literal(true), newsletterEmail: newsletterEmailValidator }),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.and(
|
|
||||||
z.union([
|
|
||||||
z.object({ department: z.string().min(1), role: roleValidator }),
|
|
||||||
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
|
|
||||||
]),
|
|
||||||
), [t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator]);
|
|
||||||
|
|
||||||
const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({
|
|
||||||
resolver: zodResolver(reactiveSchema as any),
|
|
||||||
defaultValues: {
|
|
||||||
userType: 'PERSONAL',
|
|
||||||
corporateTaxId: '',
|
|
||||||
hasSpouse: false,
|
|
||||||
spouseName: '',
|
|
||||||
newsletter: false,
|
|
||||||
newsletterEmail: '',
|
|
||||||
department: '',
|
|
||||||
role: '',
|
|
||||||
regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }],
|
|
||||||
warehouses: [
|
|
||||||
{ id: 'W-99', regionId: 'R1', name: 'APAC Central Hub' },
|
|
||||||
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' }
|
|
||||||
],
|
|
||||||
richTextLive: "<p>Start typing to see the live preview...</p>",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Micro-subscriptions via useWatch
|
|
||||||
const userType = useWatch({ control, name: 'userType' });
|
|
||||||
const hasSpouse = useWatch({ control, name: 'hasSpouse' });
|
|
||||||
const newsletter = useWatch({ control, name: 'newsletter' });
|
|
||||||
const department = useWatch({ control, name: 'department' });
|
|
||||||
const role = useWatch({ control, name: 'role' });
|
|
||||||
const regions = useWatch({ control, name: 'regions' });
|
|
||||||
const watchedRichTextLive = useWatch({ control, name: 'richTextLive' });
|
|
||||||
|
|
||||||
// Use the custom hook to cleanly unregister and reset fields when hidden
|
|
||||||
useConditionalField({
|
|
||||||
condition: userType === 'CORPORATE',
|
|
||||||
name: 'corporateTaxId',
|
|
||||||
setValue,
|
|
||||||
unregister,
|
|
||||||
mode: 'unregister',
|
|
||||||
defaultValue: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
useConditionalField({
|
|
||||||
condition: hasSpouse === true,
|
|
||||||
name: 'spouseName',
|
|
||||||
setValue,
|
|
||||||
unregister,
|
|
||||||
mode: 'unregister',
|
|
||||||
defaultValue: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use the reset mode to clear values and errors without unregistering the field
|
|
||||||
useConditionalField({
|
|
||||||
condition: newsletter === true,
|
|
||||||
name: 'newsletterEmail',
|
|
||||||
setValue,
|
|
||||||
clearErrors,
|
|
||||||
mode: 'reset',
|
|
||||||
defaultValue: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cascading Dropdown Logic: Department -> Role
|
|
||||||
const roleOptions: Record<string, { value: string; label: string }[]> = {
|
|
||||||
IT: [
|
|
||||||
{ value: 'FRONTEND', label: 'Frontend Engineer' },
|
|
||||||
{ value: 'BACKEND', label: 'Backend Engineer' },
|
|
||||||
],
|
|
||||||
HR: [
|
|
||||||
{ value: 'RECRUITER', label: 'Technical Recruiter' },
|
|
||||||
{ value: 'MANAGER', label: 'HR Manager' },
|
|
||||||
],
|
|
||||||
FINANCE: [
|
|
||||||
{ value: 'ACCOUNTANT', label: 'Accountant' },
|
|
||||||
{ value: 'ANALYST', label: 'Financial Analyst' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentRoleOptions = department ? roleOptions[department] : [];
|
|
||||||
const isRoleValid = !role || (!!department && currentRoleOptions.some((opt) => opt.value === role));
|
|
||||||
|
|
||||||
// Reset Mode: Automatically clears the 'role' field value and errors if the department changes
|
|
||||||
// and the currently selected role is no longer valid for the new department.
|
|
||||||
useConditionalField({
|
|
||||||
condition: isRoleValid,
|
|
||||||
name: 'role',
|
|
||||||
setValue,
|
|
||||||
clearErrors,
|
|
||||||
mode: 'reset',
|
|
||||||
defaultValue: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const isMounted = useRef(false);
|
|
||||||
const prevRegionIds = useRef<string[]>(regions?.map((r: Region) => r.id) || []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isMounted.current) {
|
|
||||||
isMounted.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIds = regions?.map((r: Region) => r.id) || [];
|
|
||||||
const prevIds = prevRegionIds.current;
|
|
||||||
|
|
||||||
const hasChanged = currentIds.length !== prevIds.length || currentIds.some((id: string) => !prevIds.includes(id));
|
|
||||||
|
|
||||||
if (hasChanged) {
|
|
||||||
setValue('warehouses', []);
|
|
||||||
clearErrors('warehouses');
|
|
||||||
prevRegionIds.current = currentIds;
|
|
||||||
}
|
|
||||||
}, [regions, setValue, clearErrors]);
|
|
||||||
|
|
||||||
// Use watch only to display the JSON output at the bottom
|
|
||||||
const allValues = useWatch({ control });
|
|
||||||
|
|
||||||
const onSubmit = (data: any) => console.log('Reactive Passed:', data);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="xl">
|
|
||||||
<Paper p="xl" withBorder radius="md">
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Title order={5} mb="sm" c="brand">
|
|
||||||
{t.sections.reactiveWatchCascading}
|
|
||||||
</Title>
|
|
||||||
<Divider mb="sm" />
|
|
||||||
|
|
||||||
<Alert icon={<Info size={16} />} title="Micro-subscription Pattern" color="blue" variant="light">
|
|
||||||
This form demonstrates isolated re-rendering using <code>useWatch</code>. Instead of re-rendering the
|
|
||||||
entire form root when typing, only the specific conditional fields update their display states.
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Divider label="Hidden/Unmounted Pattern" labelPosition="center" my="sm" />
|
|
||||||
|
|
||||||
<FieldSelect
|
|
||||||
name="userType"
|
|
||||||
control={control}
|
|
||||||
label={t.watch.userType}
|
|
||||||
data={[
|
|
||||||
{ value: 'PERSONAL', label: t.watch.typePersonal || 'Personal' },
|
|
||||||
{ value: 'CORPORATE', label: t.watch.typeCorporate || 'Corporate' },
|
|
||||||
]}
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
|
|
||||||
{userType === 'CORPORATE' && (
|
|
||||||
<FieldTextInput name="corporateTaxId" control={control} label={t.watch.corporateTaxId} withAsterisk />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FieldSwitch name="hasSpouse" control={control} label={t.watch.hasSpouse} mt="md" />
|
|
||||||
|
|
||||||
{hasSpouse && (
|
|
||||||
<FieldTextInput name="spouseName" control={control} label={t.watch.spouseName} withAsterisk />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider label="Visible but Disabled Pattern" labelPosition="center" my="md" />
|
|
||||||
|
|
||||||
<FieldSwitch name="newsletter" control={control} label="Subscribe to Newsletter" />
|
|
||||||
|
|
||||||
<FieldTextInput
|
|
||||||
name="newsletterEmail"
|
|
||||||
control={control}
|
|
||||||
label="Newsletter Email"
|
|
||||||
disabled={!newsletter}
|
|
||||||
placeholder="Enter your email to subscribe"
|
|
||||||
withAsterisk={newsletter}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider label="Reset Mode (Cascading Dependencies)" labelPosition="center" my="md" />
|
|
||||||
|
|
||||||
<FieldSelect
|
|
||||||
name="department"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.department}
|
|
||||||
placeholder={t.placeholders.selectComplexObject}
|
|
||||||
data={[
|
|
||||||
{ value: 'IT', label: 'Information Technology' },
|
|
||||||
{ value: 'HR', label: 'Human Resources' },
|
|
||||||
{ value: 'FINANCE', label: 'Finance' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* * ⚠️ CRITICAL UI FIX: DYNAMIC KEY
|
|
||||||
* Why bind the 'key' to the parent dependency (department)?
|
|
||||||
* * Mantine's Select component caches its internal visual state. When the parent
|
|
||||||
* 'department' changes, our useConditionalField hook successfully clears the RHF
|
|
||||||
* payload state, but Mantine might visually retain the old text on the screen.
|
|
||||||
* * By changing the 'key' whenever the department changes, we force React to
|
|
||||||
* completely unmount and remount this component. This destroys Mantine's old
|
|
||||||
* internal cache and guarantees a perfectly clean UI sync.
|
|
||||||
*/}
|
|
||||||
<FieldSelect
|
|
||||||
key={`role-select-${department}`}
|
|
||||||
name="role"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.role}
|
|
||||||
placeholder={t.placeholders.selectComplexObject}
|
|
||||||
disabled={!department}
|
|
||||||
data={currentRoleOptions}
|
|
||||||
withAsterisk={!!department}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Title order={5} mb="sm" c="brand" mt="lg">
|
|
||||||
{t.sections.reactiveWatchCascading}
|
|
||||||
</Title>
|
|
||||||
<Divider mb="sm" />
|
|
||||||
|
|
||||||
<FieldLocalSelect<Region>
|
|
||||||
multiple
|
|
||||||
name="regions"
|
|
||||||
control={control as any}
|
|
||||||
label={t.fields.regions}
|
|
||||||
options={REGIONS}
|
|
||||||
valueKey="id"
|
|
||||||
labelKey="code"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldAsyncSelect<Warehouse>
|
|
||||||
multiple
|
|
||||||
key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`}
|
|
||||||
name="warehouses"
|
|
||||||
control={control as any}
|
|
||||||
label={t.fields.warehouses}
|
|
||||||
disabled={!regions || regions.length === 0}
|
|
||||||
loadOptions={useCallback(async (search, page) => {
|
|
||||||
if (!regions || regions.length === 0) return { options: [], hasMore: false };
|
|
||||||
return mockFetchWarehouses(regions.map((r: any) => r.id), search, page);
|
|
||||||
}, [regions])}
|
|
||||||
valueKey="id"
|
|
||||||
renderLabel={(item) => `[${item.id}] ${item.name}`}
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
|
|
||||||
{regions && regions.length > 0 && (
|
|
||||||
<Alert mt="sm" color="teal">
|
|
||||||
{t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Title order={5} mb="sm" c="brand" mt="lg">
|
|
||||||
{t.sections.reactiveRichTextPreview}
|
|
||||||
</Title>
|
|
||||||
<Divider mb="sm" />
|
|
||||||
|
|
||||||
<FieldRichTextEditor
|
|
||||||
name="richTextLive"
|
|
||||||
control={control as any}
|
|
||||||
label={t.fields.liveEditor}
|
|
||||||
description={t.descriptions.typeToSeePreview}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Paper p="md" withBorder radius="md" mt="sm">
|
|
||||||
<Title order={6} mb="xs">{t.sections.liveHtmlPreview}</Title>
|
|
||||||
<TypographyStylesProvider>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: watchedRichTextLive }} />
|
|
||||||
</TypographyStylesProvider>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Button type="submit" mt="md">
|
|
||||||
{t.common.submitReactive}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
|
||||||
<Title order={6} mb="xs">
|
|
||||||
{t.common?.submittedData || 'Submitted Data'}
|
|
||||||
</Title>
|
|
||||||
<Code block>{JSON.stringify(allValues, null, 2)}</Code>
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-254
@@ -1,254 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
|
|
||||||
import {
|
|
||||||
FieldTextInput, FieldPasswordInput, FieldNumberInput,
|
|
||||||
FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor
|
|
||||||
} from '@repo/ui/form';
|
|
||||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
|
||||||
|
|
||||||
interface Department {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
costCenter: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Assignee {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_DEPARTMENTS: Department[] = [
|
|
||||||
{ code: 'IT', name: 'Information Technology', costCenter: 'CC-100' },
|
|
||||||
{ code: 'HR', name: 'Human Resources', costCenter: 'CC-200' },
|
|
||||||
{ code: 'FIN', name: 'Finance', costCenter: 'CC-300' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockFetchUsers: LoadOptionsFn<Assignee> = async (search, page) => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
const allUsers = Array.from({ length: 20 }, (_, i) => ({
|
|
||||||
id: i + 1,
|
|
||||||
email: `user${i + 1}@company.com`
|
|
||||||
}));
|
|
||||||
const filtered = allUsers.filter(u => u.email.toLowerCase().includes(search.toLowerCase()));
|
|
||||||
const pageSize = 5;
|
|
||||||
const start = (page - 1) * pageSize;
|
|
||||||
const paginated = filtered.slice(start, start + pageSize);
|
|
||||||
|
|
||||||
return {
|
|
||||||
options: paginated,
|
|
||||||
hasMore: start + pageSize < filtered.length
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_VENDORS = [
|
|
||||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
|
||||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
|
||||||
return { options: filtered, hasMore: false };
|
|
||||||
};
|
|
||||||
import {
|
|
||||||
compose, required, rangeLength,
|
|
||||||
positiveNumber, simplePassword,
|
|
||||||
complexPassword, phoneValidator, rangeValue
|
|
||||||
} from '@repo/ui/validators';
|
|
||||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
|
||||||
|
|
||||||
export default function ValidationBankDemo() {
|
|
||||||
const t = useFormDemoTranslation();
|
|
||||||
|
|
||||||
// Compose the Zod schema using the atomic validators
|
|
||||||
const validationSchema = useMemo(() => z.object({
|
|
||||||
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
|
|
||||||
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
|
|
||||||
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
|
|
||||||
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
|
|
||||||
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
|
|
||||||
phone: compose(z.string(), required(t.validation.phone), phoneValidator()),
|
|
||||||
department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }),
|
|
||||||
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees),
|
|
||||||
prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
|
|
||||||
emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
|
|
||||||
prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, t.errors.min1Vendor),
|
|
||||||
richTextNotes: z.string().min(15, t.errors.notesMin15),
|
|
||||||
}), [t]);
|
|
||||||
|
|
||||||
type ValidationFormValues = z.infer<typeof validationSchema>;
|
|
||||||
|
|
||||||
const { control, handleSubmit, watch } = useForm<ValidationFormValues>({
|
|
||||||
resolver: zodResolver(validationSchema),
|
|
||||||
defaultValues: {
|
|
||||||
username: '',
|
|
||||||
simplePass: '',
|
|
||||||
complexPass: '',
|
|
||||||
age: undefined as any,
|
|
||||||
score: undefined as any,
|
|
||||||
phone: '',
|
|
||||||
department: null as any,
|
|
||||||
assignees: [],
|
|
||||||
prefilledVendor: { id: 'V1', code: 'VN-01', name: 'Vendor One' } as any,
|
|
||||||
emptyVendor: null as any,
|
|
||||||
prefilledAsyncMulti: [
|
|
||||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
|
||||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
|
|
||||||
] as any,
|
|
||||||
richTextNotes: '',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = (data: ValidationFormValues) => console.log('Validation Passed:', data);
|
|
||||||
const data = watch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="xl">
|
|
||||||
<Paper p="xl" withBorder radius="md">
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Title order={5} c="brand">{t.sections.validationBankTitle}</Title>
|
|
||||||
<Divider mb="sm" />
|
|
||||||
|
|
||||||
<FieldTextInput
|
|
||||||
name="username"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.customerName}
|
|
||||||
description={t.validation.usernameRange}
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group grow align="flex-start">
|
|
||||||
<FieldPasswordInput
|
|
||||||
name="simplePass"
|
|
||||||
control={control}
|
|
||||||
label={t.validation.simplePassword}
|
|
||||||
description={t.descriptions.min6Chars}
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
<FieldPasswordInput
|
|
||||||
name="complexPass"
|
|
||||||
control={control}
|
|
||||||
label={t.validation.complexPassword}
|
|
||||||
description={t.descriptions.min8Complex}
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group grow align="flex-start">
|
|
||||||
<FieldNumberInput
|
|
||||||
name="age"
|
|
||||||
control={control}
|
|
||||||
label={t.fields.age}
|
|
||||||
description={t.validation.ageRange}
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
<FieldNumberInput
|
|
||||||
name="score"
|
|
||||||
control={control}
|
|
||||||
label={t.validation.score}
|
|
||||||
description={t.descriptions.mustBePositive}
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<FieldTextInput
|
|
||||||
name="phone"
|
|
||||||
control={control}
|
|
||||||
label={t.validation.phone}
|
|
||||||
description={t.descriptions.formatPhone}
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Title order={5} c="brand" mt="md">{t.sections.objectLevelValidations}</Title>
|
|
||||||
<Divider mb="sm" />
|
|
||||||
|
|
||||||
<FieldLocalSelect<Department>
|
|
||||||
name="department"
|
|
||||||
control={control as any}
|
|
||||||
label={t.fields.department}
|
|
||||||
options={MOCK_DEPARTMENTS}
|
|
||||||
valueKey="code"
|
|
||||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
|
||||||
clearable
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldAsyncSelect<Assignee>
|
|
||||||
multiple
|
|
||||||
name="assignees"
|
|
||||||
control={control as any}
|
|
||||||
label={t.fields.assignees}
|
|
||||||
loadOptions={mockFetchUsers}
|
|
||||||
valueKey="id"
|
|
||||||
labelKey="email"
|
|
||||||
searchable
|
|
||||||
clearable
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Title order={5} c="brand" mt="lg">{t.sections.validatedPrefilledObjects}</Title>
|
|
||||||
<Divider mb="sm" />
|
|
||||||
|
|
||||||
<Group grow align="flex-start">
|
|
||||||
<FieldAsyncSelect
|
|
||||||
name="emptyVendor"
|
|
||||||
control={control as any}
|
|
||||||
label={t.fields.emptyVendor}
|
|
||||||
loadOptions={mockFetchVendors}
|
|
||||||
valueKey="id"
|
|
||||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
|
||||||
clearable
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
<FieldAsyncSelect
|
|
||||||
name="prefilledVendor"
|
|
||||||
control={control as any}
|
|
||||||
label={t.fields.prefilledVendor}
|
|
||||||
loadOptions={mockFetchVendors}
|
|
||||||
valueKey="id"
|
|
||||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
|
||||||
defaultOptions={[{ id: 'V1', code: 'VN-01', name: 'Vendor One' }]}
|
|
||||||
clearable
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<FieldAsyncSelect
|
|
||||||
multiple
|
|
||||||
name="prefilledAsyncMulti"
|
|
||||||
control={control as any}
|
|
||||||
label={t.fields.prefilledAsyncMulti}
|
|
||||||
loadOptions={mockFetchVendors}
|
|
||||||
valueKey="id"
|
|
||||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
|
||||||
clearable
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Title order={5} c="brand" mt="lg">{t.sections.richTextValidations}</Title>
|
|
||||||
<Divider mb="sm" />
|
|
||||||
|
|
||||||
<FieldRichTextEditor
|
|
||||||
name="richTextNotes"
|
|
||||||
control={control as any}
|
|
||||||
label={t.fields.importantNotes}
|
|
||||||
description={t.descriptions.zodMinLengthString}
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit" mt="md">{t.common.submit}</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
|
||||||
<Title order={6} mb="xs">{t.common.submittedData}</Title>
|
|
||||||
<Code block>{JSON.stringify(data, null, 2)}</Code>
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Tabs } from '@repo/ui/components';
|
|
||||||
import AllFieldsDemo from './components/all-fields-demo';
|
|
||||||
import ValidationBankDemo from './components/validation-bank-demo';
|
|
||||||
import ReactiveWatchDemo from './components/reactive-watch-demo';
|
|
||||||
import { useFormDemoTranslation } from './i18n/useFormDemoTranslation';
|
|
||||||
|
|
||||||
export default function FormDemoView() {
|
|
||||||
const t = useFormDemoTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs defaultValue="all-fields" variant="outline" radius="md">
|
|
||||||
<Tabs.List mb="md">
|
|
||||||
<Tabs.Tab value="all-fields">{t.tabs.allFields}</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="validation-bank">{t.tabs.validationBank}</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="reactive-watch">{t.tabs.reactiveWatch}</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Panel value="all-fields">
|
|
||||||
<AllFieldsDemo />
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="validation-bank">
|
|
||||||
<ValidationBankDemo />
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="reactive-watch">
|
|
||||||
<ReactiveWatchDemo />
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
{
|
|
||||||
"tabs": {
|
|
||||||
"allFields": "All 22 Fields Demo",
|
|
||||||
"validationBank": "Validation Bank",
|
|
||||||
"reactiveWatch": "Reactive Watch (useWatch)"
|
|
||||||
},
|
|
||||||
"common": {
|
|
||||||
"submit": "Submit Data",
|
|
||||||
"submitReactive": "Submit Reactive Form",
|
|
||||||
"reset": "Reset Form",
|
|
||||||
"submittedData": "Submitted Data"
|
|
||||||
},
|
|
||||||
"sections": {
|
|
||||||
"validationBankTitle": "Validation Bank (Atomic Registry)",
|
|
||||||
"objectLevelValidations": "Object Level Validations (Local & Async)",
|
|
||||||
"validatedPrefilledObjects": "Validated Prefilled Objects",
|
|
||||||
"richTextValidations": "Rich Text Editor Validations",
|
|
||||||
"textAndNumbers": "Text & Numbers",
|
|
||||||
"selections": "Selections",
|
|
||||||
"advancedObjectSelects": "Advanced Object Selects (Custom Labels & Default Values)",
|
|
||||||
"multiSelectEditMode": "Multi-Select Edit Mode (No defaultOptions fallback)",
|
|
||||||
"richTextEditor": "Rich Text Editor (TipTap)",
|
|
||||||
"togglesAndChoices": "Toggles & Choices",
|
|
||||||
"rangesAndSpecialized": "Ranges & Specialized",
|
|
||||||
"reactiveWatchCascading": "Reactive Watch (Cascading)",
|
|
||||||
"reactiveRichTextPreview": "Reactive Rich Text Preview",
|
|
||||||
"liveHtmlPreview": "Live HTML Preview Render"
|
|
||||||
},
|
|
||||||
"fields": {
|
|
||||||
"customerName": "Customer Name",
|
|
||||||
"email": "Email Address",
|
|
||||||
"priority": "Production Priority",
|
|
||||||
"password": "Password",
|
|
||||||
"description": "Description",
|
|
||||||
"age": "Age",
|
|
||||||
"jsonConfig": "JSON Config",
|
|
||||||
"tags": "Tags",
|
|
||||||
"terms": "I agree to the terms and conditions",
|
|
||||||
"receiveEmails": "Receive marketing emails",
|
|
||||||
"rating": "Satisfaction Rating",
|
|
||||||
"themeColor": "Theme Color",
|
|
||||||
"avatar": "Avatar Upload",
|
|
||||||
"orderType": "Order Type",
|
|
||||||
"quantity": "Quantity",
|
|
||||||
"fabricColor": "Fabric Color",
|
|
||||||
"pin": "Security PIN",
|
|
||||||
"department": "Department",
|
|
||||||
"assignees": "Assignees",
|
|
||||||
"emptyVendor": "Empty Vendor",
|
|
||||||
"prefilledVendor": "Prefilled Vendor",
|
|
||||||
"prefilledAsyncMulti": "Prefilled Async Multi (No Fallback)",
|
|
||||||
"importantNotes": "Important Notes",
|
|
||||||
"country": "Country",
|
|
||||||
"categories": "Categories",
|
|
||||||
"localSelect": "Local Select",
|
|
||||||
"multiLocalSelect": "Multi Local Select",
|
|
||||||
"asyncSelectMock": "Async Select (Mock API)",
|
|
||||||
"multiAsyncSelect": "Multi Async Select",
|
|
||||||
"realPokeSingle": "Real PokeAPI (Single - Tests Deduplication)",
|
|
||||||
"realPokeMulti": "Real PokeAPI (Multi - Tests Deduplication)",
|
|
||||||
"localEmpty": "Local Empty",
|
|
||||||
"localPrefilled": "Local Prefilled",
|
|
||||||
"asyncEmpty": "Async Empty",
|
|
||||||
"asyncPrefilled": "Async Prefilled (Edit Mode)",
|
|
||||||
"localMultiPrefilled": "Local Multi Prefilled",
|
|
||||||
"asyncMultiPrefilled": "Async Multi Prefilled (Ghost Items)",
|
|
||||||
"richTextEmpty": "Rich Text (Empty)",
|
|
||||||
"richTextPrefilled": "Rich Text (Prefilled / Edit Mode)",
|
|
||||||
"priceRange": "Price Range",
|
|
||||||
"role": "Role",
|
|
||||||
"regions": "Regions",
|
|
||||||
"warehouses": "Warehouses",
|
|
||||||
"liveEditor": "Live Editor"
|
|
||||||
},
|
|
||||||
"placeholders": {
|
|
||||||
"selectComplexObject": "Select a complex object",
|
|
||||||
"selectMultipleObjects": "Select multiple objects",
|
|
||||||
"searchPokemon": "Search pokemon...",
|
|
||||||
"selectMultiplePokemon": "Select multiple pokemon...",
|
|
||||||
"scrollDeduplication": "Scroll to test deduplication..."
|
|
||||||
},
|
|
||||||
"descriptions": {
|
|
||||||
"min6Chars": "Min 6 chars",
|
|
||||||
"min8Complex": "Min 8, 1 uppercase, 1 number, 1 special",
|
|
||||||
"mustBePositive": "Must be > 0",
|
|
||||||
"formatPhone": "Format: +62...",
|
|
||||||
"zodMinLengthString": "This uses Zod minimum length string validation",
|
|
||||||
"freshTipTap": "A fresh TipTap editor instance",
|
|
||||||
"htmlStringLoaded": "HTML string successfully loaded from default values",
|
|
||||||
"typeToSeePreview": "Type to see instantaneous reactive rendering below",
|
|
||||||
"selectedRegionsTax": "Selected regions tax rates:"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"departmentRequired": "Department is required",
|
|
||||||
"vendorRequired": "Vendor is required",
|
|
||||||
"min2Assignees": "Select at least 2 assignees",
|
|
||||||
"min1Vendor": "Select at least 1 vendor",
|
|
||||||
"notesMin15": "Notes must be at least 15 characters long (including HTML tags)",
|
|
||||||
"selectRegionFirst": "Select a region first to load warehouses"
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"simplePassword": "Simple Password",
|
|
||||||
"complexPassword": "Complex Password",
|
|
||||||
"score": "Score (Positive)",
|
|
||||||
"ageRange": "Age Range (18-65)",
|
|
||||||
"usernameRange": "Username Length (3-15)",
|
|
||||||
"phone": "Phone Number (+62)"
|
|
||||||
},
|
|
||||||
"watch": {
|
|
||||||
"userType": "User Type",
|
|
||||||
"typePersonal": "Personal",
|
|
||||||
"typeCorporate": "Corporate",
|
|
||||||
"corporateTaxId": "Corporate Tax ID",
|
|
||||||
"hasSpouse": "Do you have a spouse?",
|
|
||||||
"spouseName": "Spouse Name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
{
|
|
||||||
"tabs": {
|
|
||||||
"allFields": "Demo 22 Field",
|
|
||||||
"validationBank": "Bank Validasi",
|
|
||||||
"reactiveWatch": "Reactive Watch (useWatch)"
|
|
||||||
},
|
|
||||||
"common": {
|
|
||||||
"submit": "Kirim Data",
|
|
||||||
"submitReactive": "Kirim Form Reaktif",
|
|
||||||
"reset": "Reset Form",
|
|
||||||
"submittedData": "Data Terkirim"
|
|
||||||
},
|
|
||||||
"sections": {
|
|
||||||
"validationBankTitle": "Bank Validasi (Registri Atomik)",
|
|
||||||
"objectLevelValidations": "Validasi Tingkat Objek (Lokal & Async)",
|
|
||||||
"validatedPrefilledObjects": "Objek Terisi yang Divalidasi",
|
|
||||||
"richTextValidations": "Validasi Rich Text Editor",
|
|
||||||
"textAndNumbers": "Teks & Angka",
|
|
||||||
"selections": "Pilihan",
|
|
||||||
"advancedObjectSelects": "Pemilihan Objek Tingkat Lanjut (Label Kustom & Nilai Default)",
|
|
||||||
"multiSelectEditMode": "Mode Edit Multi-Select (Tanpa fallback defaultOptions)",
|
|
||||||
"richTextEditor": "Rich Text Editor (TipTap)",
|
|
||||||
"togglesAndChoices": "Tombol Sakelar & Pilihan",
|
|
||||||
"rangesAndSpecialized": "Rentang & Khusus",
|
|
||||||
"reactiveWatchCascading": "Reactive Watch (Berjenjang)",
|
|
||||||
"reactiveRichTextPreview": "Pratinjau Rich Text Reaktif",
|
|
||||||
"liveHtmlPreview": "Render Pratinjau HTML Langsung"
|
|
||||||
},
|
|
||||||
"fields": {
|
|
||||||
"customerName": "Nama Pelanggan",
|
|
||||||
"email": "Alamat Email",
|
|
||||||
"priority": "Prioritas Produksi",
|
|
||||||
"password": "Kata Sandi",
|
|
||||||
"description": "Deskripsi",
|
|
||||||
"age": "Usia",
|
|
||||||
"jsonConfig": "Konfigurasi JSON",
|
|
||||||
"tags": "Label (Tags)",
|
|
||||||
"terms": "Saya setuju dengan syarat dan ketentuan",
|
|
||||||
"receiveEmails": "Terima email pemasaran",
|
|
||||||
"rating": "Peringkat Kepuasan",
|
|
||||||
"themeColor": "Warna Tema",
|
|
||||||
"avatar": "Unggah Avatar",
|
|
||||||
"orderType": "Tipe Pesanan",
|
|
||||||
"quantity": "Jumlah",
|
|
||||||
"fabricColor": "Warna Kain",
|
|
||||||
"pin": "PIN Keamanan",
|
|
||||||
"department": "Departemen",
|
|
||||||
"assignees": "Penerima Tugas",
|
|
||||||
"emptyVendor": "Vendor Kosong",
|
|
||||||
"prefilledVendor": "Vendor Terisi",
|
|
||||||
"prefilledAsyncMulti": "Multi Async Terisi (Tanpa Fallback)",
|
|
||||||
"importantNotes": "Catatan Penting",
|
|
||||||
"country": "Negara",
|
|
||||||
"categories": "Kategori",
|
|
||||||
"localSelect": "Pilihan Lokal",
|
|
||||||
"multiLocalSelect": "Pilihan Lokal Multi",
|
|
||||||
"asyncSelectMock": "Pilihan Async (Mock API)",
|
|
||||||
"multiAsyncSelect": "Pilihan Async Multi",
|
|
||||||
"realPokeSingle": "API Pokemon Asli (Tunggal - Uji Deduplikasi)",
|
|
||||||
"realPokeMulti": "API Pokemon Asli (Multi - Uji Deduplikasi)",
|
|
||||||
"localEmpty": "Lokal Kosong",
|
|
||||||
"localPrefilled": "Lokal Terisi",
|
|
||||||
"asyncEmpty": "Async Kosong",
|
|
||||||
"asyncPrefilled": "Async Terisi (Mode Edit)",
|
|
||||||
"localMultiPrefilled": "Multi Lokal Terisi",
|
|
||||||
"asyncMultiPrefilled": "Multi Async Terisi (Item Hantu)",
|
|
||||||
"richTextEmpty": "Rich Text (Kosong)",
|
|
||||||
"richTextPrefilled": "Rich Text (Terisi / Mode Edit)",
|
|
||||||
"priceRange": "Rentang Harga",
|
|
||||||
"role": "Peran",
|
|
||||||
"regions": "Wilayah",
|
|
||||||
"warehouses": "Gudang",
|
|
||||||
"liveEditor": "Editor Langsung"
|
|
||||||
},
|
|
||||||
"placeholders": {
|
|
||||||
"selectComplexObject": "Pilih objek yang kompleks",
|
|
||||||
"selectMultipleObjects": "Pilih beberapa objek",
|
|
||||||
"searchPokemon": "Cari pokemon...",
|
|
||||||
"selectMultiplePokemon": "Pilih beberapa pokemon...",
|
|
||||||
"scrollDeduplication": "Gulir untuk menguji deduplikasi..."
|
|
||||||
},
|
|
||||||
"descriptions": {
|
|
||||||
"min6Chars": "Minimal 6 karakter",
|
|
||||||
"min8Complex": "Min 8, 1 huruf besar, 1 angka, 1 karakter khusus",
|
|
||||||
"mustBePositive": "Harus > 0",
|
|
||||||
"formatPhone": "Format: +62...",
|
|
||||||
"zodMinLengthString": "Ini menggunakan validasi panjang string minimum Zod",
|
|
||||||
"freshTipTap": "Instance editor TipTap yang baru",
|
|
||||||
"htmlStringLoaded": "String HTML berhasil dimuat dari nilai default",
|
|
||||||
"typeToSeePreview": "Ketik untuk melihat render reaktif seketika di bawah",
|
|
||||||
"selectedRegionsTax": "Tarif pajak wilayah yang dipilih:"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"departmentRequired": "Departemen wajib diisi",
|
|
||||||
"vendorRequired": "Vendor wajib diisi",
|
|
||||||
"min2Assignees": "Pilih minimal 2 penerima tugas",
|
|
||||||
"min1Vendor": "Pilih minimal 1 vendor",
|
|
||||||
"notesMin15": "Catatan minimal harus terdiri dari 15 karakter (termasuk tag HTML)",
|
|
||||||
"selectRegionFirst": "Pilih wilayah terlebih dahulu untuk memuat gudang"
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"simplePassword": "Sandi Sederhana",
|
|
||||||
"complexPassword": "Sandi Kompleks",
|
|
||||||
"score": "Skor (Positif)",
|
|
||||||
"ageRange": "Rentang Usia (18-65)",
|
|
||||||
"usernameRange": "Panjang Username (3-15)",
|
|
||||||
"phone": "Nomor Telepon (+62)"
|
|
||||||
},
|
|
||||||
"watch": {
|
|
||||||
"userType": "Tipe Pengguna",
|
|
||||||
"typePersonal": "Personal",
|
|
||||||
"typeCorporate": "Perusahaan",
|
|
||||||
"corporateTaxId": "NPWP Perusahaan",
|
|
||||||
"hasSpouse": "Apakah Anda memiliki pasangan?",
|
|
||||||
"spouseName": "Nama Pasangan"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import en from './en.json';
|
|
||||||
import id from './id.json';
|
|
||||||
|
|
||||||
export type FormDemoI18n = typeof en;
|
|
||||||
|
|
||||||
export function useFormDemoTranslation(): FormDemoI18n {
|
|
||||||
const { i18n } = useTranslation();
|
|
||||||
return (i18n.language === 'id' ? id : en) as FormDemoI18n;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from './form-demo-view';
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"module_name": "Purchasing",
|
|
||||||
"select_date": "Select Date",
|
|
||||||
"header": {
|
|
||||||
"title": "Transaction List",
|
|
||||||
"subtitle": "Manage all your transactions here"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"module_name": "Pembelanjaan",
|
|
||||||
"select_date": "Pilih Tanggal",
|
|
||||||
"header": {
|
|
||||||
"title": "Daftar Transaksi",
|
|
||||||
"subtitle": "Kelola semua transaksi Anda di sini"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useTranslation, changeLanguage, applyTenantOverrides, i18n } from '@repo/core-i18n';
|
|
||||||
import { secureIndexedDB, AppStorageKey } from '../../../../../../core/storage/local';
|
|
||||||
|
|
||||||
// Decentralized languages imports
|
|
||||||
import bookingId from '../languages/id/booking.json';
|
|
||||||
import bookingEn from '../languages/en/booking.json';
|
|
||||||
|
|
||||||
// ─── Shared Styles ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
const sectionStyle = {
|
|
||||||
marginTop: 24,
|
|
||||||
padding: 24,
|
|
||||||
border: '1px solid #334155',
|
|
||||||
borderRadius: 8,
|
|
||||||
background: '#0f172a',
|
|
||||||
};
|
|
||||||
|
|
||||||
const btnStyle = (color: string, isActive: boolean = false) => ({
|
|
||||||
padding: '8px 16px',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: isActive ? 700 : 600,
|
|
||||||
cursor: 'pointer' as const,
|
|
||||||
background: color,
|
|
||||||
color: '#fff',
|
|
||||||
border: isActive ? '2px solid #fff' : '2px solid transparent',
|
|
||||||
borderRadius: 6,
|
|
||||||
marginRight: 8,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Module-Level Flag ──────────────────────────────────────────
|
|
||||||
// Bendera penanda statis agar kamus hanya dimuat satu kali
|
|
||||||
let isBookingDictLoaded = false;
|
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function I18nSample() {
|
|
||||||
// 1. Eksekusi SINKRONUS tepat sebelum render pertama
|
|
||||||
if (!isBookingDictLoaded) {
|
|
||||||
i18n.addResourceBundle('id', 'booking', bookingId, true, false);
|
|
||||||
i18n.addResourceBundle('en', 'booking', bookingEn, true, false);
|
|
||||||
isBookingDictLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Sekarang useTranslation dijamin mendapat kamus yang sudah terisi penuh
|
|
||||||
const { t } = useTranslation(['common', 'booking']);
|
|
||||||
|
|
||||||
// State untuk melacak bahasa aktif secara real-time
|
|
||||||
const [activeLang, setActiveLang] = useState(i18n.language);
|
|
||||||
const [syncStatus, setSyncStatus] = useState<string>('');
|
|
||||||
const [activeTenant, setActiveTenant] = useState<string>('default');
|
|
||||||
const [isFetchingConfig, setIsFetchingConfig] = useState(false);
|
|
||||||
|
|
||||||
// Dengarkan perubahan bahasa dari engine
|
|
||||||
useEffect(() => {
|
|
||||||
const handleLangChange = (lng: string) => setActiveLang(lng);
|
|
||||||
i18n.on('languageChanged', handleLangChange);
|
|
||||||
return () => {
|
|
||||||
i18n.off('languageChanged', handleLangChange);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ─── Admin Panel State ──────────────────────────────────────────
|
|
||||||
const [adminModuleName, setAdminModuleName] = useState('PENGELUARAN');
|
|
||||||
const [adminHeaderTitle, setAdminHeaderTitle] = useState('Daftar Pengeluaran');
|
|
||||||
const [dbPayloadStr, setDbPayloadStr] = useState<string>('No data in DB');
|
|
||||||
|
|
||||||
const MOCK_DB_KEY = AppStorageKey.MOCK_DB_COMPANY_A;
|
|
||||||
|
|
||||||
const loadDbPayload = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await secureIndexedDB.getItem<any>(MOCK_DB_KEY);
|
|
||||||
setDbPayloadStr(data ? JSON.stringify(data, null, 2) : 'No data in DB');
|
|
||||||
setAdminHeaderTitle(data?.overrides?.header?.title || 'Daftar Pengeluaran');
|
|
||||||
setAdminModuleName(data?.overrides?.module_name || 'PENGELUARAN');
|
|
||||||
} catch (e) {
|
|
||||||
setDbPayloadStr('Error reading DB');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDbPayload();
|
|
||||||
}, [loadDbPayload]);
|
|
||||||
|
|
||||||
const handleAdminSave = async () => {
|
|
||||||
const payload = {
|
|
||||||
namespace: 'booking',
|
|
||||||
overrides: {
|
|
||||||
module_name: adminModuleName,
|
|
||||||
header: { title: adminHeaderTitle },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await secureIndexedDB.setItem(MOCK_DB_KEY, payload);
|
|
||||||
setSyncStatus('✅ Saved tenant config to IndexedDB!');
|
|
||||||
await loadDbPayload();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Mock API ───────────────────────────────────────────────────
|
|
||||||
const mockFetchTenantConfig = async (companyId: string): Promise<any> => {
|
|
||||||
if (companyId === 'company-a') {
|
|
||||||
const data = await secureIndexedDB.getItem<any>(MOCK_DB_KEY);
|
|
||||||
if (!data) {
|
|
||||||
throw new Error('Company A config not found in DB. Please save via Admin Panel first.');
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
} else if (companyId === 'company-b') {
|
|
||||||
return {
|
|
||||||
namespace: 'booking',
|
|
||||||
overrides: {
|
|
||||||
module_name: 'PROCUREMENT (B)',
|
|
||||||
header: { title: 'Procurement List (B)' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw new Error('Unknown company');
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Section A: Language Switcher ──────────────────────────────
|
|
||||||
|
|
||||||
const handleLanguageChange = async (newLng: string, shouldFail: boolean = false) => {
|
|
||||||
setSyncStatus('Syncing with backend...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await changeLanguage(newLng, async (lng, _prevLng) => {
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (shouldFail) {
|
|
||||||
reject(new Error('Mock API 500: Failed to save preference'));
|
|
||||||
} else {
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
setSyncStatus(`✅ Successfully synced language '${lng}' to backend.`);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setSyncStatus(`❌ Rollback triggered: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Section B: Tenant Overrides (Real-World Flow) ─────────────
|
|
||||||
|
|
||||||
const handleSimulateLogin = async (companyId: string) => {
|
|
||||||
setIsFetchingConfig(true);
|
|
||||||
setActiveTenant(companyId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = await mockFetchTenantConfig(companyId);
|
|
||||||
applyTenantOverrides(config.namespace, config.overrides, 'id');
|
|
||||||
applyTenantOverrides(config.namespace, config.overrides, 'en');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch config', err);
|
|
||||||
} finally {
|
|
||||||
setIsFetchingConfig(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetTenant = () => {
|
|
||||||
i18n.addResourceBundle('id', 'booking', bookingId, true, true);
|
|
||||||
i18n.addResourceBundle('en', 'booking', bookingEn, true, true);
|
|
||||||
setActiveTenant('default');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ fontFamily: 'sans-serif', color: '#f8fafc' }}>
|
|
||||||
<h2 style={{ fontSize: 24, fontWeight: 'bold' }}>🌐 Enterprise i18n Demo</h2>
|
|
||||||
<p style={{ color: '#94a3b8' }}>
|
|
||||||
Current Active Language: <strong style={{ color: '#38bdf8' }}>{activeLang}</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* ─── Admin Panel ──────────────────────────────────────────── */}
|
|
||||||
<div style={sectionStyle}>
|
|
||||||
<h3 style={{ fontSize: 18, marginBottom: 16, color: '#fbbf24' }}>Admin Panel (Company A Config)</h3>
|
|
||||||
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
|
|
||||||
Simulate a backend CMS. Save the vocabulary overrides to IndexedDB.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
|
|
||||||
<label style={{ fontSize: 14 }}>
|
|
||||||
<span style={{ display: 'inline-block', width: 120 }}>Module Name:</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={adminModuleName}
|
|
||||||
onChange={(e) => setAdminModuleName(e.target.value)}
|
|
||||||
style={{
|
|
||||||
padding: 6,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: '#1e293b',
|
|
||||||
border: '1px solid #475569',
|
|
||||||
color: '#fff',
|
|
||||||
width: 250,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label style={{ fontSize: 14 }}>
|
|
||||||
<span style={{ display: 'inline-block', width: 120 }}>Header Title:</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={adminHeaderTitle}
|
|
||||||
onChange={(e) => setAdminHeaderTitle(e.target.value)}
|
|
||||||
style={{
|
|
||||||
padding: 6,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: '#1e293b',
|
|
||||||
border: '1px solid #475569',
|
|
||||||
color: '#fff',
|
|
||||||
width: 250,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={handleAdminSave} style={btnStyle('#d97706')}>
|
|
||||||
Save to Database (IndexedDB)
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 16, padding: 12, background: '#1e293b', borderRadius: 6 }}>
|
|
||||||
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 4 }}>Raw JSON in DB:</div>
|
|
||||||
<pre style={{ margin: 0, fontSize: 12, color: '#a7f3d0' }}>
|
|
||||||
<code>{dbPayloadStr}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─── Section A ────────────────────────────────────────────── */}
|
|
||||||
<div style={sectionStyle}>
|
|
||||||
<h3 style={{ fontSize: 18, marginBottom: 16 }}>A. Language Switcher & Backend Sync</h3>
|
|
||||||
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
|
|
||||||
Change the language. The callback simulates a 1-second backend API request.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => handleLanguageChange('id')}
|
|
||||||
style={btnStyle(activeLang === 'id' ? '#1d4ed8' : '#0ea5e9', activeLang === 'id')}
|
|
||||||
>
|
|
||||||
ID (Lokal & Sync)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleLanguageChange('en')}
|
|
||||||
style={btnStyle(activeLang === 'en' ? '#1d4ed8' : '#0ea5e9', activeLang === 'en')}
|
|
||||||
>
|
|
||||||
EN (Lokal & Sync)
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleLanguageChange('en', true)} style={btnStyle('#dc2626')}>
|
|
||||||
Force Error (Test Rollback)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{syncStatus && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
padding: 12,
|
|
||||||
background: '#1e293b',
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{syncStatus}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* UI Result untuk Section A */}
|
|
||||||
<div style={{ background: '#1e293b', padding: 16, borderRadius: 8, marginTop: 16 }}>
|
|
||||||
<h4 style={{ color: '#cbd5e1', marginBottom: 12 }}>UI Result (Live Dictionary):</h4>
|
|
||||||
<p style={{ margin: '4px 0', display: 'flex', alignItems: 'center' }}>
|
|
||||||
<code style={{ color: '#94a3b8', width: 180, display: 'inline-block' }}>common:save</code>
|
|
||||||
<strong style={{ fontSize: 16, color: '#10b981' }}>{t('common:save')}</strong>
|
|
||||||
</p>
|
|
||||||
<p style={{ margin: '4px 0', display: 'flex', alignItems: 'center' }}>
|
|
||||||
<code style={{ color: '#94a3b8', width: 180, display: 'inline-block' }}>booking:select_date</code>
|
|
||||||
<strong style={{ fontSize: 16, color: '#10b981' }}>{t('booking:select_date')}</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─── Section B ────────────────────────────────────────────── */}
|
|
||||||
<div style={sectionStyle}>
|
|
||||||
<h3 style={{ fontSize: 18, marginBottom: 16 }}>B. Dynamic Tenant Overrides (End-to-End)</h3>
|
|
||||||
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
|
|
||||||
Simulates a user logging in. It fetches the config directly from IndexedDB (mock database) and applies the
|
|
||||||
deep-merge override.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
|
|
||||||
<button
|
|
||||||
onClick={resetTenant}
|
|
||||||
style={btnStyle(activeTenant === 'default' ? '#16a34a' : '#475569', activeTenant === 'default')}
|
|
||||||
>
|
|
||||||
Default Company
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSimulateLogin('company-a')}
|
|
||||||
style={btnStyle(activeTenant === 'company-a' ? '#16a34a' : '#475569', activeTenant === 'company-a')}
|
|
||||||
disabled={isFetchingConfig}
|
|
||||||
>
|
|
||||||
Simulate Login as Company A
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSimulateLogin('company-b')}
|
|
||||||
style={btnStyle(activeTenant === 'company-b' ? '#16a34a' : '#475569', activeTenant === 'company-b')}
|
|
||||||
disabled={isFetchingConfig}
|
|
||||||
>
|
|
||||||
Simulate Login as Company B
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isFetchingConfig && (
|
|
||||||
<div style={{ marginBottom: 16, color: '#fbbf24', fontSize: 14 }}>⏳ Fetching tenant config...</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Display the localized strings */}
|
|
||||||
<div style={{ background: '#1e293b', padding: 16, borderRadius: 8 }}>
|
|
||||||
<h4 style={{ color: '#cbd5e1', marginBottom: 12 }}>UI Result (Tenant Overlay):</h4>
|
|
||||||
<table style={{ width: '100%', textAlign: 'left', borderCollapse: 'collapse' }}>
|
|
||||||
<tbody>
|
|
||||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
|
||||||
<th style={{ padding: 8, color: '#94a3b8' }}>Key</th>
|
|
||||||
<th style={{ padding: 8, color: '#94a3b8' }}>Value</th>
|
|
||||||
</tr>
|
|
||||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
|
||||||
<td style={{ padding: 8 }}>
|
|
||||||
<code>booking:module_name</code>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:module_name')}</td>
|
|
||||||
</tr>
|
|
||||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
|
||||||
<td style={{ padding: 8 }}>
|
|
||||||
<code>booking:header.title</code>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:header.title')}</td>
|
|
||||||
</tr>
|
|
||||||
<tr style={{ borderBottom: '1px solid #334155' }}>
|
|
||||||
<td style={{ padding: 8 }}>
|
|
||||||
<code>booking:header.subtitle</code>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:header.subtitle')}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { secureStorage, secureIndexedDB, AppStorageKey } from '../../../../../../core/storage/local';
|
|
||||||
|
|
||||||
// ─── Demo Data ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface DemoUser {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DemoDraft {
|
|
||||||
id: number;
|
|
||||||
type: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEMO_USER: DemoUser = {
|
|
||||||
id: 'u-123',
|
|
||||||
name: 'Firman Ramdhani',
|
|
||||||
role: 'admin',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEMO_DRAFT: DemoDraft = { id: 101, type: 'offline_draft', content: 'Draft data saved offline' };
|
|
||||||
|
|
||||||
const LS_KEY = AppStorageKey.USER_PROFILE; // Encrypted at rest (in ENCRYPTED_KEYS)
|
|
||||||
const IDB_KEY = AppStorageKey.OFFLINE_DRAFT; // Plain key for IndexedDB demo
|
|
||||||
|
|
||||||
// ─── Shared Styles ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
const btnStyle = (color: string) => ({
|
|
||||||
padding: '8px 16px',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600 as const,
|
|
||||||
cursor: 'pointer' as const,
|
|
||||||
background: color,
|
|
||||||
color: '#fff',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 6,
|
|
||||||
});
|
|
||||||
|
|
||||||
const preStyle = {
|
|
||||||
marginTop: 16,
|
|
||||||
background: '#1e1e2e',
|
|
||||||
color: '#a6e3a1',
|
|
||||||
padding: 16,
|
|
||||||
borderRadius: 8,
|
|
||||||
minHeight: 60,
|
|
||||||
overflow: 'auto' as const,
|
|
||||||
fontSize: 13,
|
|
||||||
};
|
|
||||||
|
|
||||||
const logContainerStyle = {
|
|
||||||
background: '#0f0f17',
|
|
||||||
color: '#94a3b8',
|
|
||||||
padding: 12,
|
|
||||||
borderRadius: 8,
|
|
||||||
maxHeight: 200,
|
|
||||||
overflow: 'auto' as const,
|
|
||||||
fontSize: 12,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Reusable CRUD Button Row ───────────────────────────────────
|
|
||||||
|
|
||||||
interface CRUDAction {
|
|
||||||
label: string;
|
|
||||||
handler: () => void;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CRUDButtons({ actions }: { actions: CRUDAction[] }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
{actions.map(({ label, handler, color }) => (
|
|
||||||
<button key={label} onClick={handler} style={btnStyle(color)}>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interactive demo for `@repo/core-storage`.
|
|
||||||
*
|
|
||||||
* Demonstrates the full CRUD lifecycle for BOTH storage backends:
|
|
||||||
* - **localStorage** (encrypted via AES for sensitive keys)
|
|
||||||
* - **IndexedDB** (Promise-wrapped, suitable for large payloads)
|
|
||||||
*
|
|
||||||
* Open the browser's DevTools:
|
|
||||||
* - **Application → Local Storage** to see AES-encrypted payloads
|
|
||||||
* - **Application → IndexedDB → app_db → kv_store** to see IDB entries
|
|
||||||
*/
|
|
||||||
export default function StorageSample() {
|
|
||||||
const [lsResult, setLsResult] = useState<string>('(no data read yet)');
|
|
||||||
const [idbResult, setIdbResult] = useState<string>('(no data read yet)');
|
|
||||||
const [log, setLog] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const pushLog = useCallback((msg: string) => {
|
|
||||||
setLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
// ── localStorage CRUD ─────────────────────────────────────────
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const lsCreate = useCallback(async () => {
|
|
||||||
await secureStorage.setItem(LS_KEY, DEMO_USER);
|
|
||||||
pushLog(`[LS] CREATE → Stored encrypted: ${JSON.stringify(DEMO_USER)}`);
|
|
||||||
}, [pushLog]);
|
|
||||||
|
|
||||||
const lsRead = useCallback(async () => {
|
|
||||||
const result = await secureStorage.getItem<DemoUser>(LS_KEY);
|
|
||||||
if (result) {
|
|
||||||
setLsResult(JSON.stringify(result, null, 2));
|
|
||||||
pushLog(`[LS] READ → Decrypted: ${JSON.stringify(result)}`);
|
|
||||||
} else {
|
|
||||||
setLsResult('(null — no data found)');
|
|
||||||
pushLog('[LS] READ → null (key does not exist)');
|
|
||||||
}
|
|
||||||
}, [pushLog]);
|
|
||||||
|
|
||||||
const lsUpdate = useCallback(async () => {
|
|
||||||
const existing = await secureStorage.getItem<DemoUser>(LS_KEY);
|
|
||||||
if (!existing) {
|
|
||||||
pushLog('[LS] UPDATE → Failed: key does not exist. Create first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const updated: DemoUser = { ...existing, role: 'superadmin', id: existing.id + 1 };
|
|
||||||
await secureStorage.setItem(LS_KEY, updated);
|
|
||||||
pushLog(`[LS] UPDATE → Re-encrypted: ${JSON.stringify(updated)}`);
|
|
||||||
}, [pushLog]);
|
|
||||||
|
|
||||||
const lsDelete = useCallback(async () => {
|
|
||||||
await secureStorage.removeItem(LS_KEY);
|
|
||||||
setLsResult('(deleted)');
|
|
||||||
pushLog(`[LS] DELETE → Removed key "${LS_KEY}"`);
|
|
||||||
}, [pushLog]);
|
|
||||||
|
|
||||||
const lsClear = useCallback(async () => {
|
|
||||||
await secureStorage.clear();
|
|
||||||
setLsResult('(cleared)');
|
|
||||||
pushLog('[LS] CLEAR → All localStorage keys removed');
|
|
||||||
}, [pushLog]);
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
// ── IndexedDB CRUD ────────────────────────────────────────────
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const idbCreate = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await secureIndexedDB.setItem(IDB_KEY, DEMO_DRAFT);
|
|
||||||
pushLog(`[IDB] CREATE → Stored: ${JSON.stringify(DEMO_DRAFT)}`);
|
|
||||||
} catch (err) {
|
|
||||||
pushLog(`[IDB] CREATE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
}, [pushLog]);
|
|
||||||
|
|
||||||
const idbRead = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const result = await secureIndexedDB.getItem<DemoDraft>(IDB_KEY);
|
|
||||||
if (result) {
|
|
||||||
setIdbResult(JSON.stringify(result, null, 2));
|
|
||||||
pushLog(`[IDB] READ → Retrieved: ${JSON.stringify(result)}`);
|
|
||||||
} else {
|
|
||||||
setIdbResult('(null — no data found)');
|
|
||||||
pushLog('[IDB] READ → null (key does not exist)');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
pushLog(`[IDB] READ → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
}, [pushLog]);
|
|
||||||
|
|
||||||
const idbUpdate = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const existing = await secureIndexedDB.getItem<DemoDraft>(IDB_KEY);
|
|
||||||
if (!existing) {
|
|
||||||
pushLog('[IDB] UPDATE → Failed: key does not exist. Create first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const updated: DemoDraft = {
|
|
||||||
...existing,
|
|
||||||
id: existing.id + 1,
|
|
||||||
content: `Updated at ${new Date().toLocaleTimeString()}`,
|
|
||||||
};
|
|
||||||
await secureIndexedDB.setItem(IDB_KEY, updated);
|
|
||||||
pushLog(`[IDB] UPDATE → Persisted: ${JSON.stringify(updated)}`);
|
|
||||||
} catch (err) {
|
|
||||||
pushLog(`[IDB] UPDATE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
}, [pushLog]);
|
|
||||||
|
|
||||||
const idbDelete = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await secureIndexedDB.removeItem(IDB_KEY);
|
|
||||||
setIdbResult('(deleted)');
|
|
||||||
pushLog(`[IDB] DELETE → Removed key "${IDB_KEY}"`);
|
|
||||||
} catch (err) {
|
|
||||||
pushLog(`[IDB] DELETE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
}, [pushLog]);
|
|
||||||
|
|
||||||
const idbClear = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await secureIndexedDB.clear();
|
|
||||||
setIdbResult('(cleared)');
|
|
||||||
pushLog('[IDB] CLEAR → All IndexedDB entries removed');
|
|
||||||
} catch (err) {
|
|
||||||
pushLog(`[IDB] CLEAR → ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
}, [pushLog]);
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
// ── Render ────────────────────────────────────────────────────
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 24, fontFamily: 'monospace' }}>
|
|
||||||
<h2>🔐 @repo/core-storage — Dual Backend CRUD Demo</h2>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 16 }}>
|
|
||||||
{/* ── Left: localStorage ─────────────────────────────────── */}
|
|
||||||
<div>
|
|
||||||
<h3 style={{ color: '#22c55e' }}>📦 localStorage (AES Encrypted)</h3>
|
|
||||||
<p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>
|
|
||||||
Key: <code>{LS_KEY}</code> — stored encrypted at rest
|
|
||||||
<br />
|
|
||||||
Verify: <strong>DevTools → Application → Local Storage</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CRUDButtons
|
|
||||||
actions={[
|
|
||||||
{ label: '➕ Create', handler: lsCreate, color: '#22c55e' },
|
|
||||||
{ label: '📖 Read', handler: lsRead, color: '#3b82f6' },
|
|
||||||
{ label: '✏️ Update', handler: lsUpdate, color: '#f59e0b' },
|
|
||||||
{ label: '🗑️ Delete', handler: lsDelete, color: '#ef4444' },
|
|
||||||
{ label: '💣 Clear', handler: lsClear, color: '#6b7280' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<pre style={preStyle}>{lsResult}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Right: IndexedDB ───────────────────────────────────── */}
|
|
||||||
<div>
|
|
||||||
<h3 style={{ color: '#8b5cf6' }}>🗃️ IndexedDB (app_db / kv_store)</h3>
|
|
||||||
<p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>
|
|
||||||
Key: <code>{IDB_KEY}</code> — plain JSON (not in ENCRYPTED_KEYS)
|
|
||||||
<br />
|
|
||||||
Verify: <strong>DevTools → Application → IndexedDB → app_db</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CRUDButtons
|
|
||||||
actions={[
|
|
||||||
{ label: '➕ Create', handler: idbCreate, color: '#8b5cf6' },
|
|
||||||
{ label: '📖 Read', handler: idbRead, color: '#06b6d4' },
|
|
||||||
{ label: '✏️ Update', handler: idbUpdate, color: '#f59e0b' },
|
|
||||||
{ label: '🗑️ Delete', handler: idbDelete, color: '#ef4444' },
|
|
||||||
{ label: '💣 Clear', handler: idbClear, color: '#6b7280' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<pre style={preStyle}>{idbResult}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Shared Action Log ────────────────────────────────────── */}
|
|
||||||
<h3 style={{ marginTop: 24 }}>📋 Action Log</h3>
|
|
||||||
<div style={logContainerStyle}>
|
|
||||||
{log.length === 0 ? (
|
|
||||||
<span style={{ color: '#475569' }}>(no actions yet)</span>
|
|
||||||
) : (
|
|
||||||
log.map((entry, i) => <div key={i}>{entry}</div>)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { Button, Card, Group, Stack, Title, Text, Table, Badge } from '@repo/ui/components';
|
|
||||||
|
|
||||||
import { itemDB, posConfigDB, newItemDB } from '../../core/storage/pouch-db';
|
|
||||||
import type { ItemEntity, POSConfigurationEntity } from '../../core/storage/pouch-db/entities';
|
|
||||||
|
|
||||||
export default function PouchSample() {
|
|
||||||
const [configs, setConfigs] = useState<POSConfigurationEntity[]>([]);
|
|
||||||
const [items, setItems] = useState<ItemEntity[]>([]);
|
|
||||||
const [newItems, setNewItems] = useState<ItemEntity[]>([]);
|
|
||||||
|
|
||||||
// Load initial data
|
|
||||||
const loadData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const allConfigs = await posConfigDB.find({ selector: {} });
|
|
||||||
setConfigs(allConfigs);
|
|
||||||
|
|
||||||
const allItems = await itemDB.find({ selector: {} });
|
|
||||||
setItems(allItems);
|
|
||||||
|
|
||||||
// We can use getAll() or find() on Envelope DB. It's automatically scoped.
|
|
||||||
const allNewItems = await newItemDB.getAll();
|
|
||||||
console.log('allNewItems', allNewItems);
|
|
||||||
setNewItems(allNewItems as ItemEntity[]);
|
|
||||||
|
|
||||||
console.log({ allConfigs, allItems, allNewItems });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load PouchDB data', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 1. Initial Data Load
|
|
||||||
loadData();
|
|
||||||
|
|
||||||
// 2. Setup Real-Time Listeners
|
|
||||||
const unsubscribeItems = itemDB.onChange(() => {
|
|
||||||
loadData();
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubscribePos = posConfigDB.onChange(() => {
|
|
||||||
loadData();
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubscribeNewItems = newItemDB.onChange(() => {
|
|
||||||
loadData();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. CRITICAL: Cleanup to prevent memory leaks on unmount
|
|
||||||
return () => {
|
|
||||||
unsubscribeItems();
|
|
||||||
unsubscribePos();
|
|
||||||
unsubscribeNewItems();
|
|
||||||
};
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
// ─── POS Configuration Handlers ─────────────────────────────────
|
|
||||||
|
|
||||||
const handleSeedConfig = async () => {
|
|
||||||
try {
|
|
||||||
const id = `pos-${Date.now()}`;
|
|
||||||
await posConfigDB.create({
|
|
||||||
_id: id,
|
|
||||||
pos_number: '1111111111666',
|
|
||||||
pos_name: 'Premium Test POS TESTING COUNCH',
|
|
||||||
items: items, // mapping current items
|
|
||||||
payment_methods: [{ id: 'cash', name: 'Cash' }],
|
|
||||||
});
|
|
||||||
loadData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to seed config', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfig = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await posConfigDB.delete(id);
|
|
||||||
loadData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete config', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Items Inventory Handlers ───────────────────────────────────
|
|
||||||
|
|
||||||
const handleAddItem = async () => {
|
|
||||||
try {
|
|
||||||
const id = `item-${Date.now()}`;
|
|
||||||
await itemDB.create({
|
|
||||||
_id: id,
|
|
||||||
name: 'PLAYGROUND ALL DAY TESTING POUCH',
|
|
||||||
base_price: '75000',
|
|
||||||
item_type: 'wahana',
|
|
||||||
usage_type: 'ticket',
|
|
||||||
item_category: [{ name: 'Entertainment' }],
|
|
||||||
item_rates: [
|
|
||||||
{ season_period: 'weekday', price: 50000 },
|
|
||||||
{ season_period: 'weekend', price: 75000 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to add item', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteItem = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await itemDB.delete(id);
|
|
||||||
loadData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete item', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── New Items Envelope Handlers ────────────────────────────────
|
|
||||||
|
|
||||||
const handleAddNewItem = async () => {
|
|
||||||
try {
|
|
||||||
const id = `new-item-${Date.now()}`;
|
|
||||||
await newItemDB.create({
|
|
||||||
_id: id,
|
|
||||||
name: 'ENVELOPE ITEM TEST',
|
|
||||||
base_price: '100000',
|
|
||||||
item_type: 'souvenir',
|
|
||||||
usage_type: 'retail',
|
|
||||||
item_category: [{ name: 'Merchandise' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to add new item', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteNewItem = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await newItemDB.delete(id);
|
|
||||||
loadData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete new item', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
|
||||||
try {
|
|
||||||
await posConfigDB.cleanAllData();
|
|
||||||
await itemDB.cleanAllData();
|
|
||||||
await newItemDB.cleanAllData();
|
|
||||||
loadData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to clear data', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="xl">
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Title order={2}>Enterprise PouchDB Sync</Title>
|
|
||||||
<Button color="error" variant="outline" onClick={handleClearAll}>
|
|
||||||
Clear All Local Data
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{/* Items Inventory Table */}
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Group justify="space-between" mb="md">
|
|
||||||
<Title order={4}>Items Database (Standard)</Title>
|
|
||||||
<Button onClick={handleAddItem} color="success">
|
|
||||||
Inject Mock ERP Item
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<div className="max-h-[400px] overflow-y-auto border border-gray-200 rounded-lg scrollbar-thin scrollbar-thumb-gray-300">
|
|
||||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">ID</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Name</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Type</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Base Price</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Rates Count</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Actions</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{items.length > 0 ? (
|
|
||||||
items.map((item) => (
|
|
||||||
<Table.Tr key={item._id}>
|
|
||||||
<Table.Td>{item._id}</Table.Td>
|
|
||||||
<Table.Td>{item.name}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge color="brand" variant="light">
|
|
||||||
{item.item_type}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>${Number(item.base_price).toFixed(2)}</Table.Td>
|
|
||||||
<Table.Td>{item.item_rates?.length || 0}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Button size="xs" color="error" variant="subtle" onClick={() => handleDeleteItem(item._id)}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td colSpan={6} align="center">
|
|
||||||
<Text c="dimmed">No items found.</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* New Items Envelope Table */}
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Group justify="space-between" mb="md">
|
|
||||||
<Title order={4}>New Items Database (Envelope Pattern)</Title>
|
|
||||||
<Button onClick={handleAddNewItem} color="brand">
|
|
||||||
Inject Mock Envelope Item
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<div className="max-h-[400px] overflow-y-auto border border-gray-200 rounded-lg scrollbar-thin scrollbar-thumb-gray-300">
|
|
||||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">ID</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Name</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Type</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Base Price</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Rates Count</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Actions</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{newItems.length > 0 ? (
|
|
||||||
newItems.map((item) => (
|
|
||||||
<Table.Tr key={item._id}>
|
|
||||||
<Table.Td>{item._id}</Table.Td>
|
|
||||||
<Table.Td>{item.name}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge color="violet" variant="light">
|
|
||||||
{item.item_type}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>${Number(item.base_price).toFixed(2)}</Table.Td>
|
|
||||||
<Table.Td>{item.item_rates?.length || 0}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Button size="xs" color="error" variant="subtle" onClick={() => handleDeleteNewItem(item._id)}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td colSpan={6} align="center">
|
|
||||||
<Text c="dimmed">No items found.</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* POS Configuration Table */}
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Group justify="space-between" mb="md">
|
|
||||||
<Title order={4}>POS Configurations</Title>
|
|
||||||
<Button onClick={handleSeedConfig} variant="light" color="brand">
|
|
||||||
Inject Mock POS Config
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<div className="max-h-[400px] overflow-y-auto border border-gray-200 rounded-lg scrollbar-thin scrollbar-thumb-gray-300">
|
|
||||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">ID</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">POS Name</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">POS Number</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Mapped Items</Table.Th>
|
|
||||||
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Actions</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{configs.length > 0 ? (
|
|
||||||
configs.map((cfg) => (
|
|
||||||
<Table.Tr key={cfg._id}>
|
|
||||||
<Table.Td>{cfg._id}</Table.Td>
|
|
||||||
<Table.Td>{cfg.pos_name}</Table.Td>
|
|
||||||
<Table.Td>{cfg.pos_number}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge color="info" variant="outline">
|
|
||||||
{cfg.items?.length || 0} Items
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Button size="xs" color="error" variant="subtle" onClick={() => handleDeleteConfig(cfg._id)}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td colSpan={5} align="center">
|
|
||||||
<Text c="dimmed">No configurations found.</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// 1. Import hooks yang baru saja dibuat Opus
|
|
||||||
import { Button } from '@repo/ui/components';
|
|
||||||
import { useElectronPrinter } from '../../core/hooks/use-electron-printer';
|
|
||||||
import { useElectronUpdater } from '../../core/hooks/use-electron-updater';
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
// 2. Panggil hooks-nya
|
|
||||||
const { printers, refreshPrinters } = useElectronPrinter();
|
|
||||||
const { status } = useElectronUpdater();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '20px', border: '2px solid blue', margin: '20px' }}>
|
|
||||||
<h2>🧪 Test Integrasi Electron</h2>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>Status Auto-Update:</strong> {status}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button variant="filled" color="brand" onClick={refreshPrinters}>
|
|
||||||
Refresh Printer
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<h3>🖨️ Daftar Printer di Komputer Ini:</h3>
|
|
||||||
<ul>
|
|
||||||
{printers.length === 0 ? (
|
|
||||||
<li>Mencari printer... (Atau tidak ada printer terdeteksi)</li>
|
|
||||||
) : (
|
|
||||||
printers.map((printer, index) => (
|
|
||||||
<li key={index}>
|
|
||||||
{printer.name} {printer.isDefault ? '(Default)' : ''}
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { lazy } from 'react';
|
|
||||||
import { Layers, Table2 } from 'lucide-react';
|
|
||||||
|
|
||||||
export const COMPONENTS_REGISTRY = {
|
|
||||||
actionTools: {
|
|
||||||
id: 'action-tools',
|
|
||||||
name: 'Action Tools',
|
|
||||||
description: 'Showcase for PageActions and RowActions components',
|
|
||||||
icon: Layers,
|
|
||||||
component: lazy(() => import('./components/ActionToolsShowcase')),
|
|
||||||
},
|
|
||||||
agGrid: {
|
|
||||||
id: 'ag-grid',
|
|
||||||
name: 'AG Grid',
|
|
||||||
description: 'Enterprise Data Grid with Mantine Theme Integration',
|
|
||||||
icon: Table2,
|
|
||||||
component: lazy(() => import('./components/AgGridShowcase')),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
import { useState, useMemo, ReactNode } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import { Box, Button, Group, SegmentedControl, Stack, Text, Paper, Switch, Select, Burger } from '@repo/ui/components';
|
|
||||||
import {
|
|
||||||
CoreAppShell,
|
|
||||||
CorePageContainer,
|
|
||||||
CoreAppShellConfig,
|
|
||||||
LayoutVariant,
|
|
||||||
DesktopCollapseVariant,
|
|
||||||
useCoreAppShell,
|
|
||||||
} from '@repo/ui/components';
|
|
||||||
import { Home, BarChart2, Settings as SettingsIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
// Sub-component to test hook methods
|
|
||||||
function LayoutControls() {
|
|
||||||
const { toggleDesktop, toggleMobile, sidebarVariant, setSidebarVariant, toggleAside, toggleNavbarPanel } =
|
|
||||||
useCoreAppShell();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group mb="md">
|
|
||||||
<Button onClick={toggleDesktop} variant="default" size="xs">
|
|
||||||
Toggle Desktop Sidebar
|
|
||||||
</Button>
|
|
||||||
<Button onClick={toggleMobile} variant="default" size="xs" hiddenFrom="sm">
|
|
||||||
Toggle Mobile Sidebar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setSidebarVariant(sidebarVariant === 'expanded' ? 'mini' : 'expanded')}
|
|
||||||
variant="default"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Toggle Sidebar Variant (Current: {sidebarVariant})
|
|
||||||
</Button>
|
|
||||||
<Button onClick={toggleNavbarPanel} variant="light" color="grape" size="xs">
|
|
||||||
Toggle Secondary Panel (Double Sidebar)
|
|
||||||
</Button>
|
|
||||||
<Button onClick={toggleAside} variant="filled" color="cyan" size="xs">
|
|
||||||
Toggle Aside via Context
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MockHeader() {
|
|
||||||
const { mobileOpened, toggleMobile } = useCoreAppShell();
|
|
||||||
return (
|
|
||||||
<Group
|
|
||||||
h="100%"
|
|
||||||
px="md"
|
|
||||||
justify="space-between"
|
|
||||||
bg="green.1"
|
|
||||||
c="green.9"
|
|
||||||
style={{ borderBottom: '1px solid var(--mantine-color-green-3)' }}
|
|
||||||
>
|
|
||||||
<Group>
|
|
||||||
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
|
||||||
<Text fw={700} size="lg">
|
|
||||||
Mock Header (bg="green.1")
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MockMobileDrawer() {
|
|
||||||
return (
|
|
||||||
<Box p="md" h="100%" bg="yellow.1" c="yellow.9">
|
|
||||||
<Text fw={700} mb="sm">
|
|
||||||
Mock Mobile Drawer (bg="yellow.1")
|
|
||||||
</Text>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Button variant="light" color="yellow" justify="flex-start" fullWidth>
|
|
||||||
Mobile Dashboard
|
|
||||||
</Button>
|
|
||||||
<Button variant="light" color="yellow" justify="flex-start" fullWidth>
|
|
||||||
Mobile Settings
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingRow({ title, description, control }: { title: string; description: string; control: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Paper withBorder p="md" radius="md">
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text fw={500}>{title}</Text>
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Box>{control}</Box>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ShellDemo() {
|
|
||||||
const [layoutVariant, setLayoutVariant] = useState<LayoutVariant>('header-first');
|
|
||||||
const [collapseVariant, setCollapseVariant] = useState<DesktopCollapseVariant>('hide');
|
|
||||||
const [withUtilityBar, setWithUtilityBar] = useState(true);
|
|
||||||
const [withAside, setWithAside] = useState(true);
|
|
||||||
const [withFooter, setWithFooter] = useState(true);
|
|
||||||
const [withDoubleSidebar, setWithDoubleSidebar] = useState(true);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const liveConfig = useMemo(() => {
|
|
||||||
return {
|
|
||||||
variant: layoutVariant,
|
|
||||||
features: {
|
|
||||||
desktopCollapseVariant: collapseVariant,
|
|
||||||
withUtilityBar: withUtilityBar ? undefined : false,
|
|
||||||
withAside: withAside ? undefined : false,
|
|
||||||
withFooter: withFooter ? undefined : false,
|
|
||||||
withDoubleSidebar,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [layoutVariant, collapseVariant, withUtilityBar, withAside, withFooter, withDoubleSidebar]);
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
navigator.clipboard.writeText(JSON.stringify(liveConfig, null, 2));
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const config: CoreAppShellConfig = {
|
|
||||||
variant: layoutVariant,
|
|
||||||
features: {
|
|
||||||
desktopCollapseVariant: collapseVariant,
|
|
||||||
withUtilityBar: withUtilityBar ? undefined : false,
|
|
||||||
withAside: withAside ? undefined : false,
|
|
||||||
withFooter: withFooter ? undefined : false,
|
|
||||||
withDoubleSidebar,
|
|
||||||
persistState: false, // Don't persist for the demo to avoid confusing other showcases
|
|
||||||
disabled: false, // Allow nested render overrides if needed
|
|
||||||
zIndex: 100,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CoreAppShell
|
|
||||||
config={config}
|
|
||||||
slots={{
|
|
||||||
utilityBar: (
|
|
||||||
<Group h="100%" px="md" justify="flex-end" bg="blue.1" c="blue.9">
|
|
||||||
<Text size="xs" fw={600}>
|
|
||||||
Mock Utility Bar (bg="blue.1")
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
header: <MockHeader />,
|
|
||||||
sidebarMobile: <MockMobileDrawer />,
|
|
||||||
sidebar: !withDoubleSidebar ? (
|
|
||||||
<Box
|
|
||||||
p="md"
|
|
||||||
h="100%"
|
|
||||||
bg="grape.1"
|
|
||||||
c="grape.9"
|
|
||||||
style={{ borderRight: '1px solid var(--mantine-color-grape-3)' }}
|
|
||||||
>
|
|
||||||
<Text fw={700} mb="sm">
|
|
||||||
Mock Standard Navbar (bg="grape.1")
|
|
||||||
</Text>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
|
||||||
Dashboard
|
|
||||||
</Button>
|
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
|
||||||
Users
|
|
||||||
</Button>
|
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
|
||||||
Reports
|
|
||||||
</Button>
|
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
) : undefined,
|
|
||||||
sidebarRail: withDoubleSidebar ? (
|
|
||||||
<Stack align="center" gap="lg" pt="md" h="100%" bg="orange.1" c="orange.9">
|
|
||||||
<Text size="xs" fw={700} style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>
|
|
||||||
Mock Rail (bg="orange.1")
|
|
||||||
</Text>
|
|
||||||
<Home size={24} />
|
|
||||||
<BarChart2 size={24} />
|
|
||||||
<SettingsIcon size={24} />
|
|
||||||
</Stack>
|
|
||||||
) : undefined,
|
|
||||||
sidebarPanel: withDoubleSidebar ? (
|
|
||||||
<Box
|
|
||||||
p="md"
|
|
||||||
h="100%"
|
|
||||||
bg="grape.1"
|
|
||||||
c="grape.9"
|
|
||||||
style={{ borderRight: '1px solid var(--mantine-color-grape-3)' }}
|
|
||||||
>
|
|
||||||
<Text fw={700} mb="sm">
|
|
||||||
Mock Panel (bg="grape.1")
|
|
||||||
</Text>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
|
||||||
Dashboard
|
|
||||||
</Button>
|
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
|
||||||
Users
|
|
||||||
</Button>
|
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
|
||||||
Reports
|
|
||||||
</Button>
|
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
) : undefined,
|
|
||||||
aside: (
|
|
||||||
<Box p="md" h="100%" bg="cyan.1" c="cyan.9">
|
|
||||||
<Text fw={700} mb="md">
|
|
||||||
Mock Aside
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">This area could be used for notifications, help text, or contextual settings.</Text>
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
footer: (
|
|
||||||
<Group
|
|
||||||
h="100%"
|
|
||||||
px="md"
|
|
||||||
justify="space-between"
|
|
||||||
bg="gray.1"
|
|
||||||
style={{ borderTop: '1px solid var(--app-shell-border-color)' }}
|
|
||||||
>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
Mock Footer (bg="gray.1")
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CorePageContainer
|
|
||||||
headerSlot={
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<Text component="h1" size="xl" fw={700} m={0}>
|
|
||||||
Layout Engine Interactive Demo
|
|
||||||
</Text>
|
|
||||||
<Group>
|
|
||||||
<Button component={Link} to="/showcase" leftSection={<ArrowLeft size={16} />} variant="default">
|
|
||||||
Back to Showcase
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
stickyHeader
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Paper withBorder p="md" bg="var(--mantine-color-gray-0)">
|
|
||||||
<LayoutControls />
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Layout Variant"
|
|
||||||
description="Switch between standard cloud or SaaS layout styles."
|
|
||||||
control={
|
|
||||||
<Select
|
|
||||||
value={layoutVariant}
|
|
||||||
onChange={(value) => setLayoutVariant((value as LayoutVariant) || 'sidebar-first')}
|
|
||||||
data={[
|
|
||||||
{ label: 'Sidebar First (Alt)', value: 'sidebar-first' },
|
|
||||||
{ label: 'Header First (Default)', value: 'header-first' },
|
|
||||||
{ label: 'Top Nav (Hidden Sidebar)', value: 'top-nav' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Desktop Collapse Strategy"
|
|
||||||
description="Determine if the sidebar shrinks to icons or slides out completely."
|
|
||||||
control={
|
|
||||||
<SegmentedControl
|
|
||||||
value={collapseVariant}
|
|
||||||
onChange={(value) => setCollapseVariant(value as DesktopCollapseVariant)}
|
|
||||||
data={[
|
|
||||||
{ label: 'Hide (Slide Out)', value: 'hide' },
|
|
||||||
{ label: 'Mini (Shrink)', value: 'mini' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Enable Double Sidebar"
|
|
||||||
description="Activate the Google-style rail and contextual panel navigation."
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={withDoubleSidebar}
|
|
||||||
onChange={(event) => setWithDoubleSidebar(event.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Render Utility Bar"
|
|
||||||
description="Show a system-level announcement bar above the main header."
|
|
||||||
control={
|
|
||||||
<Switch checked={withUtilityBar} onChange={(event) => setWithUtilityBar(event.currentTarget.checked)} />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Render Aside"
|
|
||||||
description="Toggle the right-hand properties or filter panel."
|
|
||||||
control={<Switch checked={withAside} onChange={(event) => setWithAside(event.currentTarget.checked)} />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Render Footer"
|
|
||||||
description="Toggle the bottom application footer."
|
|
||||||
control={<Switch checked={withFooter} onChange={(event) => setWithFooter(event.currentTarget.checked)} />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box mt="xl">
|
|
||||||
<Group justify="space-between" mb="sm">
|
|
||||||
<Text fw={700} size="lg">
|
|
||||||
Configuration Preview
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
variant={copied ? 'filled' : 'light'}
|
|
||||||
color={copied ? 'teal' : 'blue'}
|
|
||||||
size="xs"
|
|
||||||
onClick={handleCopy}
|
|
||||||
>
|
|
||||||
{copied ? 'Copied to Clipboard!' : 'Copy JSON'}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
<Paper withBorder p="md" bg="dark.8" c="gray.0" style={{ fontFamily: 'monospace', overflowX: 'auto' }}>
|
|
||||||
<pre style={{ margin: 0 }}>{JSON.stringify(liveConfig, null, 2)}</pre>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</CorePageContainer>
|
|
||||||
</CoreAppShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
import { useState, Suspense } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { ColorSchemeType, DensityType } from '@repo/ui/provider';
|
|
||||||
import { useThemeStore } from '../../core/stores/theme.store';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Container,
|
|
||||||
Select,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
TextInput,
|
|
||||||
Checkbox,
|
|
||||||
PasswordInput,
|
|
||||||
NumberInput,
|
|
||||||
Textarea,
|
|
||||||
Switch,
|
|
||||||
Table,
|
|
||||||
Badge,
|
|
||||||
Divider,
|
|
||||||
Tabs,
|
|
||||||
Box,
|
|
||||||
Paper,
|
|
||||||
StatusBadge,
|
|
||||||
STATUS_DATA,
|
|
||||||
} from '@repo/ui/components';
|
|
||||||
import { ShieldCheck, Database, Lock, Layout, Activity, Printer, FileText, LayoutDashboard } from 'lucide-react';
|
|
||||||
import { Globe } from 'lucide-react';
|
|
||||||
import { useTranslation } from '@repo/core-i18n';
|
|
||||||
import PrinterList from './printer-list';
|
|
||||||
import ExamplePage from './example/example.page';
|
|
||||||
import EventsDemoPage from './events-demo';
|
|
||||||
import PouchSample from './pouch-sample';
|
|
||||||
import FormDemoView from './example/features/form-demo';
|
|
||||||
import { COMPONENTS_REGISTRY } from './registry';
|
|
||||||
|
|
||||||
interface ShowcaseViewProps {
|
|
||||||
density: DensityType;
|
|
||||||
setDensity: (val: DensityType) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ShowcaseView({ density, setDensity }: ShowcaseViewProps) {
|
|
||||||
const { colorScheme, setColorScheme } = useThemeStore();
|
|
||||||
const [activeTab, setActiveTab] = useState<string | null>('ui-components');
|
|
||||||
const { i18n } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// Mock data for the table
|
|
||||||
const tableData = [
|
|
||||||
{ id: 'ORD-001', customer: 'John Doe', status: 'Shipped', total: '$120.00' },
|
|
||||||
{ id: 'ORD-002', customer: 'Jane Smith', status: 'Pending', total: '$85.50' },
|
|
||||||
{ id: 'ORD-003', customer: 'Acme Corp', status: 'Delivered', total: '$1,250.00' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const getSubtitle = () => {
|
|
||||||
const registryItem = Object.values(COMPONENTS_REGISTRY).find((item) => item.id === activeTab);
|
|
||||||
if (registryItem) {
|
|
||||||
return registryItem.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'rbac':
|
|
||||||
return 'Role-Based Access Control and Permissions';
|
|
||||||
case 'storage':
|
|
||||||
return 'Offline-First PouchDB Synchronization';
|
|
||||||
case 'auth':
|
|
||||||
return 'Authentication & Security Layers';
|
|
||||||
case 'ui-components':
|
|
||||||
return 'Theme, Typography, Forms & Data Grids';
|
|
||||||
case 'forms':
|
|
||||||
return 'Enterprise Form Engine & Zod Validation';
|
|
||||||
case 'events':
|
|
||||||
return 'Global Event Bus Synchronization';
|
|
||||||
case 'hardware':
|
|
||||||
return 'Hardware Integration & Printers';
|
|
||||||
case 'layout-engine':
|
|
||||||
return 'Core Layout Engine & Variants';
|
|
||||||
default:
|
|
||||||
return 'Architecture Showcase';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="min-h-screen" style={{ backgroundColor: 'var(--mantine-color-body)' }}>
|
|
||||||
<Tabs
|
|
||||||
orientation="vertical"
|
|
||||||
placement="left"
|
|
||||||
value={activeTab}
|
|
||||||
onChange={(val) => {
|
|
||||||
if (val === 'layout-engine') {
|
|
||||||
navigate('/shell-demo');
|
|
||||||
} else if (val === 'apps') {
|
|
||||||
navigate('/app');
|
|
||||||
} else {
|
|
||||||
setActiveTab(val);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
variant="pills"
|
|
||||||
radius="md"
|
|
||||||
className="h-screen"
|
|
||||||
styles={{
|
|
||||||
root: { display: 'flex', height: '100vh', overflow: 'hidden' },
|
|
||||||
list: {
|
|
||||||
minWidth: 260,
|
|
||||||
padding: '1rem',
|
|
||||||
borderRight: '1px solid var(--app-shell-border-color)',
|
|
||||||
backgroundColor: 'var(--mantine-color-default-element-bg)',
|
|
||||||
},
|
|
||||||
panel: { flex: 1, display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' },
|
|
||||||
tab: { justifyContent: 'flex-start' }, // Ensure the entire tab content aligns left
|
|
||||||
tabLabel: { textAlign: 'left', flex: 1 }, // Ensure the text pushes to fill and aligns left
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs.List>
|
|
||||||
<Box mb="xl" px="sm">
|
|
||||||
<Title order={3} c="brand.7">
|
|
||||||
Eigen ERP
|
|
||||||
</Title>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Architecture Showcase
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Tabs.Tab value="ui-components" leftSection={<Layout size={18} />}>
|
|
||||||
UI Components
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="layout-engine" leftSection={<Layout size={18} />}>
|
|
||||||
Layout Engine
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="forms" leftSection={<FileText size={18} />}>
|
|
||||||
Form Engine
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="storage" leftSection={<Database size={18} />}>
|
|
||||||
Offline Storage
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="rbac" leftSection={<ShieldCheck size={18} />}>
|
|
||||||
RBAC Engine
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="auth" leftSection={<Lock size={18} />}>
|
|
||||||
Auth & Security
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="events" leftSection={<Activity size={18} />}>
|
|
||||||
Events
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="hardware" leftSection={<Printer size={18} />}>
|
|
||||||
Hardware
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="apps" leftSection={<LayoutDashboard size={18} />}>
|
|
||||||
Apps
|
|
||||||
</Tabs.Tab>
|
|
||||||
|
|
||||||
{Object.values(COMPONENTS_REGISTRY).map((registryItem) => {
|
|
||||||
const Icon = registryItem.icon;
|
|
||||||
return (
|
|
||||||
<Tabs.Tab key={registryItem.id} value={registryItem.id} leftSection={<Icon size={18} />}>
|
|
||||||
{registryItem.name}
|
|
||||||
</Tabs.Tab>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Panel value={activeTab as string}>
|
|
||||||
{/* Header */}
|
|
||||||
<Paper
|
|
||||||
p="md"
|
|
||||||
radius={0}
|
|
||||||
withBorder
|
|
||||||
style={{
|
|
||||||
borderTop: 0,
|
|
||||||
borderLeft: 0,
|
|
||||||
borderRight: 0,
|
|
||||||
zIndex: 10,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Title order={3}>Architecture Showcase</Title>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{getSubtitle()}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Select
|
|
||||||
w={180}
|
|
||||||
size="sm"
|
|
||||||
variant="filled"
|
|
||||||
leftSection={<Globe size={16} />}
|
|
||||||
data={[
|
|
||||||
{ value: 'en', label: 'English' },
|
|
||||||
{ value: 'id', label: 'Bahasa Indonesia' },
|
|
||||||
]}
|
|
||||||
value={i18n.resolvedLanguage || i18n.language}
|
|
||||||
onChange={(val) => val && i18n.changeLanguage(val)}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Scrollable Content Area */}
|
|
||||||
<Box className="flex-1 overflow-y-auto p-6" style={{ height: 'calc(100vh - 80px)' }}>
|
|
||||||
<Container size="xl" m={0} p={0}>
|
|
||||||
{/* --- UI COMPONENTS TAB --- */}
|
|
||||||
{activeTab === 'ui-components' && (
|
|
||||||
<Stack gap="xl">
|
|
||||||
{/* Control Panel */}
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
Theme Controls
|
|
||||||
</Title>
|
|
||||||
<Group grow align="flex-end">
|
|
||||||
<Select
|
|
||||||
label="Color Scheme"
|
|
||||||
value={colorScheme}
|
|
||||||
onChange={(val) => setColorScheme((val as ColorSchemeType) || 'light')}
|
|
||||||
data={[
|
|
||||||
{ value: 'light', label: 'Light' },
|
|
||||||
{ value: 'dark', label: 'Dark' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Density (Spacing & Sizing)"
|
|
||||||
value={density}
|
|
||||||
onChange={(val) => setDensity((val as DensityType) || 'standard')}
|
|
||||||
data={[
|
|
||||||
{ value: 'compact', label: 'Compact (ERP Mode)' },
|
|
||||||
{ value: 'standard', label: 'Standard (UI Mode)' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Typography & Buttons */}
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Stack gap="lg">
|
|
||||||
<div>
|
|
||||||
<Title order={4} mb="xs">
|
|
||||||
Typography & Badges
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
This is dimmed small text indicating a subtitle.
|
|
||||||
</Text>
|
|
||||||
<Group mt="md">
|
|
||||||
<Badge color="brand">Brand Badge</Badge>
|
|
||||||
<Badge color="success" variant="light">
|
|
||||||
Success Status
|
|
||||||
</Badge>
|
|
||||||
<Badge color="error" variant="outline">
|
|
||||||
Error State
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
<Divider />
|
|
||||||
<div>
|
|
||||||
<Title order={4} mb="xs">
|
|
||||||
Enterprise Status Badges
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
|
||||||
Pre-configured status badges for transaction and master data.
|
|
||||||
</Text>
|
|
||||||
<Group>
|
|
||||||
<StatusBadge status={STATUS_DATA.DRAFT} />
|
|
||||||
<StatusBadge status={STATUS_DATA.PENDING} />
|
|
||||||
<StatusBadge status={STATUS_DATA.PROCESS} />
|
|
||||||
<StatusBadge status={STATUS_DATA.APPROVED} />
|
|
||||||
<StatusBadge status={STATUS_DATA.CANCELLED} />
|
|
||||||
<StatusBadge status={STATUS_DATA.WAITING} />
|
|
||||||
<StatusBadge status={STATUS_DATA.ON_HOLD} />
|
|
||||||
<StatusBadge status={STATUS_DATA.REFUNDED} />
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
<Divider />
|
|
||||||
<div>
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
Buttons
|
|
||||||
</Title>
|
|
||||||
<Group>
|
|
||||||
<Button variant="filled" color="brand">
|
|
||||||
Filled Button
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" color="brand">
|
|
||||||
Outline Button
|
|
||||||
</Button>
|
|
||||||
<Button variant="light" color="info">
|
|
||||||
Light Info
|
|
||||||
</Button>
|
|
||||||
<Button variant="subtle" color="error">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Forms */}
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
Form Elements
|
|
||||||
</Title>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group grow align="flex-start">
|
|
||||||
<TextInput label="First Name" placeholder="Enter your first name" withAsterisk />
|
|
||||||
<TextInput label="Last Name" placeholder="Enter your last name" />
|
|
||||||
</Group>
|
|
||||||
<Group grow align="flex-start">
|
|
||||||
<NumberInput label="Age" placeholder="25" min={0} max={100} />
|
|
||||||
<PasswordInput label="Password" placeholder="Your secret password" withAsterisk />
|
|
||||||
</Group>
|
|
||||||
<Textarea label="Bio" placeholder="Tell us about yourself" minRows={3} />
|
|
||||||
<Group mt="sm">
|
|
||||||
<Checkbox label="I agree to the terms and conditions" defaultChecked />
|
|
||||||
<Switch label="Enable notifications" defaultChecked />
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Data Grid */}
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
Data Grid
|
|
||||||
</Title>
|
|
||||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Order ID</Table.Th>
|
|
||||||
<Table.Th>Customer</Table.Th>
|
|
||||||
<Table.Th>Status</Table.Th>
|
|
||||||
<Table.Th>Total</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{tableData.map((row) => (
|
|
||||||
<Table.Tr key={row.id}>
|
|
||||||
<Table.Td>{row.id}</Table.Td>
|
|
||||||
<Table.Td>{row.customer}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
color={
|
|
||||||
row.status === 'Delivered' ? 'success' : row.status === 'Shipped' ? 'info' : 'warning'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{row.status}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{row.total}</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* --- FORMS TAB --- */}
|
|
||||||
{activeTab === 'forms' && (
|
|
||||||
<Stack gap="xl">
|
|
||||||
<FormDemoView />
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* --- STORAGE TAB --- */}
|
|
||||||
{activeTab === 'storage' && (
|
|
||||||
<Stack gap="xl">
|
|
||||||
<PouchSample />
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* --- RBAC TAB --- */}
|
|
||||||
{activeTab === 'rbac' && (
|
|
||||||
<Stack gap="xl">
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
RBAC Engine
|
|
||||||
</Title>
|
|
||||||
<Text c="dimmed">RBAC Demo Component Coming Soon...</Text>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* --- AUTH TAB --- */}
|
|
||||||
{activeTab === 'auth' && (
|
|
||||||
<Stack gap="xl">
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
Auth & Security
|
|
||||||
</Title>
|
|
||||||
<Text c="dimmed">Auth Demo Component Coming Soon...</Text>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* --- EVENTS TAB --- */}
|
|
||||||
{activeTab === 'events' && (
|
|
||||||
<Stack gap="xl">
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
Nested Showcase Example
|
|
||||||
</Title>
|
|
||||||
<Text mb="md">This is an example of a nested showcase component.</Text>
|
|
||||||
<ExamplePage />
|
|
||||||
</Card>
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<EventsDemoPage />
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* --- HARDWARE TAB --- */}
|
|
||||||
{activeTab === 'hardware' && (
|
|
||||||
<Stack gap="xl">
|
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
|
||||||
<PrinterList />
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* --- DYNAMIC COMPONENTS FROM REGISTRY --- */}
|
|
||||||
{Object.values(COMPONENTS_REGISTRY).map((registryItem) => {
|
|
||||||
const Component = registryItem.component;
|
|
||||||
return (
|
|
||||||
activeTab === registryItem.id && (
|
|
||||||
<Stack gap="xl" key={registryItem.id}>
|
|
||||||
<Suspense fallback={<Text>Loading {registryItem.name}...</Text>}>
|
|
||||||
<Component />
|
|
||||||
</Suspense>
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user