create-freakpunch 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -3
- package/bin/create-freakpunch.mjs +227 -0
- package/files/README.md +19 -0
- package/files/app.config.ts +50 -0
- package/files/locales.ts +10 -0
- package/files/registry.ts +17 -0
- package/package.json +23 -6
- package/template/.dockerignore +17 -0
- package/template/.oxlintrc.json +9 -0
- package/template/AGENTS.md +6 -0
- package/template/BACKEND.md +108 -0
- package/template/CLAUDE.md +297 -0
- package/template/Dockerfile +19 -0
- package/template/_gitignore +26 -0
- package/template/components.json +27 -0
- package/template/design/COMPONENTS.md +145 -0
- package/template/design/DESIGN.md +212 -0
- package/template/design/PATTERNS.md +110 -0
- package/template/index.html +25 -0
- package/template/nginx.conf +32 -0
- package/template/package-lock.json +6484 -0
- package/template/package.json +51 -0
- package/template/public/favicon.svg +1 -0
- package/template/public/robots.txt +2 -0
- package/template/src/app.config.ts +50 -0
- package/template/src/app.roles.ts +24 -0
- package/template/src/components/ui/alert-dialog.tsx +181 -0
- package/template/src/components/ui/avatar.tsx +116 -0
- package/template/src/components/ui/badge.tsx +57 -0
- package/template/src/components/ui/breadcrumb.tsx +130 -0
- package/template/src/components/ui/button.tsx +58 -0
- package/template/src/components/ui/calendar.tsx +277 -0
- package/template/src/components/ui/card.tsx +99 -0
- package/template/src/components/ui/chart.tsx +370 -0
- package/template/src/components/ui/checkbox.tsx +28 -0
- package/template/src/components/ui/combobox.tsx +329 -0
- package/template/src/components/ui/command.tsx +191 -0
- package/template/src/components/ui/dialog.tsx +162 -0
- package/template/src/components/ui/dropdown-menu.tsx +271 -0
- package/template/src/components/ui/empty.tsx +103 -0
- package/template/src/components/ui/field.tsx +236 -0
- package/template/src/components/ui/input-group.tsx +263 -0
- package/template/src/components/ui/input.tsx +45 -0
- package/template/src/components/ui/kbd.tsx +31 -0
- package/template/src/components/ui/label.tsx +17 -0
- package/template/src/components/ui/pagination.tsx +134 -0
- package/template/src/components/ui/popover.tsx +87 -0
- package/template/src/components/ui/radio-group.tsx +35 -0
- package/template/src/components/ui/select.tsx +204 -0
- package/template/src/components/ui/separator.tsx +25 -0
- package/template/src/components/ui/sheet.tsx +139 -0
- package/template/src/components/ui/sidebar.tsx +741 -0
- package/template/src/components/ui/skeleton.tsx +13 -0
- package/template/src/components/ui/sonner.tsx +78 -0
- package/template/src/components/ui/spinner.tsx +42 -0
- package/template/src/components/ui/switch.tsx +29 -0
- package/template/src/components/ui/table.tsx +121 -0
- package/template/src/components/ui/tabs.tsx +191 -0
- package/template/src/components/ui/textarea.tsx +17 -0
- package/template/src/components/ui/toggle-group.tsx +94 -0
- package/template/src/components/ui/toggle.tsx +48 -0
- package/template/src/components/ui/tooltip.tsx +91 -0
- package/template/src/core/api/demo.ts +8 -0
- package/template/src/core/api/http.ts +76 -0
- package/template/src/core/api/mock.ts +88 -0
- package/template/src/core/api/rest.ts +67 -0
- package/template/src/core/api/types.ts +21 -0
- package/template/src/core/audit/audit.service.ts +94 -0
- package/template/src/core/audit/hooks.ts +19 -0
- package/template/src/core/auth/auth-store.ts +52 -0
- package/template/src/core/auth/auth.service.ts +86 -0
- package/template/src/core/auth/can.tsx +39 -0
- package/template/src/core/auth/dev-role-switcher.tsx +64 -0
- package/template/src/core/auth/local-credentials.ts +46 -0
- package/template/src/core/auth/permissions.ts +115 -0
- package/template/src/core/auth/require-auth.tsx +16 -0
- package/template/src/core/auth/require-permission.tsx +24 -0
- package/template/src/core/auth/roles.ts +9 -0
- package/template/src/core/auth/types.ts +20 -0
- package/template/src/core/auth/use-auth.ts +19 -0
- package/template/src/core/company/company.service.ts +94 -0
- package/template/src/core/company/hooks.ts +40 -0
- package/template/src/core/company/password-policy.ts +40 -0
- package/template/src/core/company/types.ts +46 -0
- package/template/src/core/company/use-app-name.ts +7 -0
- package/template/src/core/components/button-link.tsx +14 -0
- package/template/src/core/components/confirm-dialog.tsx +116 -0
- package/template/src/core/components/data-table/data-table.tsx +297 -0
- package/template/src/core/components/data-table/features.ts +24 -0
- package/template/src/core/components/data-table/table-header-cell.tsx +13 -0
- package/template/src/core/components/data-table/toolbar.tsx +100 -0
- package/template/src/core/components/data-table/use-list-url-state.ts +108 -0
- package/template/src/core/components/date-picker.tsx +71 -0
- package/template/src/core/components/file-attachments.tsx +84 -0
- package/template/src/core/components/form-error.tsx +9 -0
- package/template/src/core/components/form-page.tsx +93 -0
- package/template/src/core/components/image-upload.tsx +86 -0
- package/template/src/core/components/page.tsx +75 -0
- package/template/src/core/components/phone-input.tsx +58 -0
- package/template/src/core/components/search-select.tsx +57 -0
- package/template/src/core/components/segmented.tsx +41 -0
- package/template/src/core/components/states.tsx +97 -0
- package/template/src/core/components/status-badge.tsx +26 -0
- package/template/src/core/components/user-avatar.tsx +23 -0
- package/template/src/core/config/runtime.ts +16 -0
- package/template/src/core/config/types.ts +37 -0
- package/template/src/core/i18n/i18n.ts +69 -0
- package/template/src/core/i18n/i18next.d.ts +12 -0
- package/template/src/core/i18n/keys.ts +37 -0
- package/template/src/core/i18n/language-switcher.tsx +45 -0
- package/template/src/core/i18n/types.ts +17 -0
- package/template/src/core/i18n/use-format.ts +31 -0
- package/template/src/core/layout/app-shell.tsx +50 -0
- package/template/src/core/layout/app-sidebar.tsx +285 -0
- package/template/src/core/layout/command-menu.tsx +117 -0
- package/template/src/core/layout/top-bar.tsx +100 -0
- package/template/src/core/lib/csv.ts +27 -0
- package/template/src/core/lib/format.ts +87 -0
- package/template/src/core/lib/phone.ts +50 -0
- package/template/src/core/lib/storage.ts +27 -0
- package/template/src/core/modules/lazy-page.ts +9 -0
- package/template/src/core/modules/navigation.ts +39 -0
- package/template/src/core/modules/types.ts +55 -0
- package/template/src/core/notifications/hooks.ts +84 -0
- package/template/src/core/notifications/notification-center.tsx +251 -0
- package/template/src/core/notifications/notifications.service.ts +132 -0
- package/template/src/core/notifications/types.ts +70 -0
- package/template/src/core/preferences/preferences-store.ts +70 -0
- package/template/src/core/setup/setup-gate.tsx +20 -0
- package/template/src/core/setup/setup.service.ts +101 -0
- package/template/src/core/theme/brand-context.tsx +53 -0
- package/template/src/core/theme/brand.ts +29 -0
- package/template/src/core/theme/theme-context.tsx +62 -0
- package/template/src/core/theme/theme-options.ts +8 -0
- package/template/src/core/theme/theme-toggle.tsx +45 -0
- package/template/src/core/users/hooks.ts +51 -0
- package/template/src/core/users/types.ts +37 -0
- package/template/src/core/users/users.service.ts +167 -0
- package/template/src/hooks/use-mobile.ts +19 -0
- package/template/src/index.css +321 -0
- package/template/src/lib/utils.ts +1 -0
- package/template/src/locales/en/account.ts +180 -0
- package/template/src/locales/en/auth.ts +18 -0
- package/template/src/locales/en/common.ts +151 -0
- package/template/src/locales/en/company.ts +380 -0
- package/template/src/locales/en/dashboard.ts +46 -0
- package/template/src/locales/en/errors.ts +18 -0
- package/template/src/locales/en/notifications.ts +43 -0
- package/template/src/locales/en/settings.ts +25 -0
- package/template/src/locales/en/setup.ts +45 -0
- package/template/src/locales/index.ts +58 -0
- package/template/src/locales/tr/account.ts +184 -0
- package/template/src/locales/tr/auth.ts +22 -0
- package/template/src/locales/tr/common.ts +154 -0
- package/template/src/locales/tr/company.ts +384 -0
- package/template/src/locales/tr/dashboard.ts +50 -0
- package/template/src/locales/tr/errors.ts +22 -0
- package/template/src/locales/tr/notifications.ts +47 -0
- package/template/src/locales/tr/settings.ts +29 -0
- package/template/src/locales/tr/setup.ts +49 -0
- package/template/src/main.tsx +16 -0
- package/template/src/modules/locales.ts +10 -0
- package/template/src/modules/registry.ts +17 -0
- package/template/src/pages/dashboard/dashboard-page.tsx +55 -0
- package/template/src/pages/dashboard/quick-actions.tsx +37 -0
- package/template/src/pages/login-page.tsx +152 -0
- package/template/src/pages/no-access-page.tsx +11 -0
- package/template/src/pages/not-found-page.tsx +61 -0
- package/template/src/pages/settings/account/account.service.ts +128 -0
- package/template/src/pages/settings/account/components/mock-qr.tsx +50 -0
- package/template/src/pages/settings/account/components/password-strength.tsx +38 -0
- package/template/src/pages/settings/account/components/two-factor-dialog.tsx +140 -0
- package/template/src/pages/settings/account/hooks.ts +73 -0
- package/template/src/pages/settings/account/notifications-page.tsx +103 -0
- package/template/src/pages/settings/account/preferences-page.tsx +157 -0
- package/template/src/pages/settings/account/profile-page.tsx +242 -0
- package/template/src/pages/settings/account/security-page.tsx +364 -0
- package/template/src/pages/settings/account/types.ts +37 -0
- package/template/src/pages/settings/company/audit-log-page.tsx +126 -0
- package/template/src/pages/settings/company/branding-page.tsx +278 -0
- package/template/src/pages/settings/company/components/invite-dialog.tsx +106 -0
- package/template/src/pages/settings/company/components/read-only-notice.tsx +13 -0
- package/template/src/pages/settings/company/danger-zone-page.tsx +127 -0
- package/template/src/pages/settings/company/general-page.tsx +234 -0
- package/template/src/pages/settings/company/integrations/api-keys-panel.tsx +170 -0
- package/template/src/pages/settings/company/integrations/hooks.ts +37 -0
- package/template/src/pages/settings/company/integrations/integrations-page.tsx +57 -0
- package/template/src/pages/settings/company/integrations/integrations.service.ts +153 -0
- package/template/src/pages/settings/company/integrations/settings-forms.tsx +299 -0
- package/template/src/pages/settings/company/integrations/webhooks-panel.tsx +179 -0
- package/template/src/pages/settings/company/modules-page.tsx +65 -0
- package/template/src/pages/settings/company/roles-page.tsx +116 -0
- package/template/src/pages/settings/company/security-policies-page.tsx +164 -0
- package/template/src/pages/settings/company/users-page.tsx +262 -0
- package/template/src/pages/settings/settings-layout.tsx +57 -0
- package/template/src/pages/settings/settings-nav.ts +69 -0
- package/template/src/pages/settings/settings-routes.tsx +49 -0
- package/template/src/pages/setup-page.tsx +387 -0
- package/template/src/providers.tsx +68 -0
- package/template/src/router.tsx +59 -0
- package/template/tools/check-design.mjs +73 -0
- package/template/tools/new-module.mjs +90 -0
- package/template/tools/templates/module/api/__id__.mock.ts.tpl +17 -0
- package/template/tools/templates/module/api/__id__.service.ts.tpl +51 -0
- package/template/tools/templates/module/hooks.ts.tpl +57 -0
- package/template/tools/templates/module/index.ts.tpl +44 -0
- package/template/tools/templates/module/locales/en.ts.tpl +51 -0
- package/template/tools/templates/module/locales/tr.ts.tpl +55 -0
- package/template/tools/templates/module/pages/__id__-detail-page.tsx.tpl +103 -0
- package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -0
- package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -0
- package/template/tools/templates/module/types.ts.tpl +32 -0
- package/template/tsconfig.app.json +32 -0
- package/template/tsconfig.json +12 -0
- package/template/tsconfig.node.json +23 -0
- package/template/vite.config.ts +47 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useId, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { Trans, useTranslation } from 'react-i18next'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AlertDialog,
|
|
6
|
+
AlertDialogAction,
|
|
7
|
+
AlertDialogCancel,
|
|
8
|
+
AlertDialogContent,
|
|
9
|
+
AlertDialogDescription,
|
|
10
|
+
AlertDialogFooter,
|
|
11
|
+
AlertDialogHeader,
|
|
12
|
+
AlertDialogTitle,
|
|
13
|
+
} from '@/components/ui/alert-dialog'
|
|
14
|
+
import { Field, FieldLabel } from '@/components/ui/field'
|
|
15
|
+
import { Input } from '@/components/ui/input'
|
|
16
|
+
|
|
17
|
+
type ConfirmOptions = {
|
|
18
|
+
title: string
|
|
19
|
+
description?: ReactNode
|
|
20
|
+
confirmLabel?: string
|
|
21
|
+
cancelLabel?: string
|
|
22
|
+
/** Red confirm button for irreversible actions. */
|
|
23
|
+
destructive?: boolean
|
|
24
|
+
/** Require typing this exact value (e.g. the company name) before confirming. */
|
|
25
|
+
typeToConfirm?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Confirm = (options: ConfirmOptions) => Promise<boolean>
|
|
29
|
+
|
|
30
|
+
const ConfirmContext = createContext<Confirm | null>(null)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Promise-based confirm dialog:
|
|
34
|
+
* const confirm = useConfirm()
|
|
35
|
+
* if (await confirm({ title: t('…'), destructive: true })) …
|
|
36
|
+
*/
|
|
37
|
+
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|
38
|
+
const [options, setOptions] = useState<ConfirmOptions | null>(null)
|
|
39
|
+
const [open, setOpen] = useState(false)
|
|
40
|
+
const [typed, setTyped] = useState('')
|
|
41
|
+
const resolver = useRef<(value: boolean) => void>(undefined)
|
|
42
|
+
const inputId = useId()
|
|
43
|
+
const { t } = useTranslation()
|
|
44
|
+
|
|
45
|
+
const confirm = useCallback<Confirm>((opts) => {
|
|
46
|
+
setOptions(opts)
|
|
47
|
+
setTyped('')
|
|
48
|
+
setOpen(true)
|
|
49
|
+
return new Promise<boolean>((resolve) => {
|
|
50
|
+
resolver.current = resolve
|
|
51
|
+
})
|
|
52
|
+
}, [])
|
|
53
|
+
|
|
54
|
+
const settle = (value: boolean) => {
|
|
55
|
+
resolver.current?.(value)
|
|
56
|
+
resolver.current = undefined
|
|
57
|
+
setOpen(false)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const expected = options?.typeToConfirm
|
|
61
|
+
const blocked = expected !== undefined && typed.trim() !== expected
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<ConfirmContext value={confirm}>
|
|
65
|
+
{children}
|
|
66
|
+
<AlertDialog open={open} onOpenChange={(next) => !next && settle(false)}>
|
|
67
|
+
<AlertDialogContent size={expected ? 'default' : 'sm'}>
|
|
68
|
+
<AlertDialogHeader>
|
|
69
|
+
<AlertDialogTitle>{options?.title}</AlertDialogTitle>
|
|
70
|
+
{options?.description && <AlertDialogDescription>{options.description}</AlertDialogDescription>}
|
|
71
|
+
</AlertDialogHeader>
|
|
72
|
+
{expected !== undefined && (
|
|
73
|
+
<Field>
|
|
74
|
+
<FieldLabel htmlFor={inputId} className="block font-normal text-muted-foreground">
|
|
75
|
+
<Trans
|
|
76
|
+
t={t}
|
|
77
|
+
i18nKey="confirm.typeToConfirm"
|
|
78
|
+
values={{ value: expected }}
|
|
79
|
+
components={{ b: <span className="tabular-nums font-medium text-foreground" /> }}
|
|
80
|
+
/>
|
|
81
|
+
</FieldLabel>
|
|
82
|
+
<Input
|
|
83
|
+
id={inputId}
|
|
84
|
+
value={typed}
|
|
85
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
86
|
+
autoComplete="off"
|
|
87
|
+
autoFocus
|
|
88
|
+
aria-label={t('confirm.typeToConfirmLabel')}
|
|
89
|
+
onKeyDown={(e) => {
|
|
90
|
+
if (e.key === 'Enter' && !blocked) settle(true)
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
</Field>
|
|
94
|
+
)}
|
|
95
|
+
<AlertDialogFooter>
|
|
96
|
+
<AlertDialogCancel>{options?.cancelLabel ?? t('actions.cancel')}</AlertDialogCancel>
|
|
97
|
+
<AlertDialogAction
|
|
98
|
+
variant={options?.destructive ? 'destructive' : 'default'}
|
|
99
|
+
disabled={blocked}
|
|
100
|
+
onClick={() => settle(true)}
|
|
101
|
+
>
|
|
102
|
+
{options?.confirmLabel ?? t('actions.confirm')}
|
|
103
|
+
</AlertDialogAction>
|
|
104
|
+
</AlertDialogFooter>
|
|
105
|
+
</AlertDialogContent>
|
|
106
|
+
</AlertDialog>
|
|
107
|
+
</ConfirmContext>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// eslint-disable-next-line react/only-export-components
|
|
112
|
+
export function useConfirm() {
|
|
113
|
+
const ctx = useContext(ConfirmContext)
|
|
114
|
+
if (!ctx) throw new Error('useConfirm must be used within <ConfirmProvider>')
|
|
115
|
+
return ctx
|
|
116
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { useMemo, type ReactNode } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import {
|
|
4
|
+
createColumnHelper,
|
|
5
|
+
useTable,
|
|
6
|
+
type OnChangeFn,
|
|
7
|
+
type PaginationState,
|
|
8
|
+
type RowData,
|
|
9
|
+
type RowSelectionState,
|
|
10
|
+
type SortingState,
|
|
11
|
+
} from '@tanstack/react-table'
|
|
12
|
+
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsUpDown } from 'lucide-react'
|
|
13
|
+
|
|
14
|
+
import { Button } from '@/components/ui/button'
|
|
15
|
+
import { Checkbox } from '@/components/ui/checkbox'
|
|
16
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
17
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
18
|
+
import { Table, TableBody, TableCell, TableHeader, TableRow } from '@/components/ui/table'
|
|
19
|
+
import { EmptyState, ErrorState } from '@/core/components/states'
|
|
20
|
+
import { useFormat } from '@/core/i18n/use-format'
|
|
21
|
+
import { cn } from '@/lib/utils'
|
|
22
|
+
import { dataTableFeatures, type DataTableColumn, type DataTableFeatures } from './features'
|
|
23
|
+
import { TableHeaderCell } from './table-header-cell'
|
|
24
|
+
|
|
25
|
+
const PAGE_SIZES = [10, 20, 50]
|
|
26
|
+
const EMPTY: never[] = []
|
|
27
|
+
const NO_SELECTION: RowSelectionState = {}
|
|
28
|
+
|
|
29
|
+
/** Row checkboxes plus a bar with bulk actions. Selection is by row id, so it survives paging. */
|
|
30
|
+
export type DataTableSelection = {
|
|
31
|
+
value: RowSelectionState
|
|
32
|
+
onChange: OnChangeFn<RowSelectionState>
|
|
33
|
+
/** Shown while rows are selected, e.g. a delete button. */
|
|
34
|
+
actions?: ReactNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type DataTableProps<TData extends RowData> = {
|
|
38
|
+
columns: DataTableColumn<TData>[]
|
|
39
|
+
data: TData[] | undefined
|
|
40
|
+
/** Total rows on the server (for page count). */
|
|
41
|
+
total: number
|
|
42
|
+
pagination: PaginationState
|
|
43
|
+
onPaginationChange: OnChangeFn<PaginationState>
|
|
44
|
+
sorting: SortingState
|
|
45
|
+
onSortingChange: OnChangeFn<SortingState>
|
|
46
|
+
getRowId: (row: TData) => string
|
|
47
|
+
isLoading?: boolean
|
|
48
|
+
isFetching?: boolean
|
|
49
|
+
error?: unknown
|
|
50
|
+
onRetry?: () => void
|
|
51
|
+
/** Rendered above the table: search, filters, bulk actions. */
|
|
52
|
+
toolbar?: ReactNode
|
|
53
|
+
empty?: ReactNode
|
|
54
|
+
/** Translated row label for the footer counter, e.g. "products". */
|
|
55
|
+
noun?: string
|
|
56
|
+
selection?: DataTableSelection
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Server-driven table: sorting and pagination are controlled from outside
|
|
61
|
+
* (usually URL state via useListUrlState) and sent to the service layer.
|
|
62
|
+
*/
|
|
63
|
+
export function DataTable<TData extends RowData>({
|
|
64
|
+
columns,
|
|
65
|
+
data,
|
|
66
|
+
total,
|
|
67
|
+
pagination,
|
|
68
|
+
onPaginationChange,
|
|
69
|
+
sorting,
|
|
70
|
+
onSortingChange,
|
|
71
|
+
getRowId,
|
|
72
|
+
isLoading,
|
|
73
|
+
isFetching,
|
|
74
|
+
error,
|
|
75
|
+
onRetry,
|
|
76
|
+
toolbar,
|
|
77
|
+
empty,
|
|
78
|
+
noun,
|
|
79
|
+
selection,
|
|
80
|
+
}: DataTableProps<TData>) {
|
|
81
|
+
const { t } = useTranslation()
|
|
82
|
+
const fmt = useFormat()
|
|
83
|
+
const selectable = Boolean(selection)
|
|
84
|
+
|
|
85
|
+
const allColumns = useMemo<DataTableColumn<TData>[]>(() => {
|
|
86
|
+
if (!selectable) return columns
|
|
87
|
+
const helper = createColumnHelper<DataTableFeatures, TData>()
|
|
88
|
+
return [
|
|
89
|
+
helper.display({
|
|
90
|
+
id: 'select',
|
|
91
|
+
meta: { className: 'w-10' },
|
|
92
|
+
header: ({ table }) => (
|
|
93
|
+
<Checkbox
|
|
94
|
+
aria-label={t('table.selectAll')}
|
|
95
|
+
checked={table.getIsAllPageRowsSelected()}
|
|
96
|
+
indeterminate={table.getIsSomePageRowsSelected()}
|
|
97
|
+
onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked)}
|
|
98
|
+
/>
|
|
99
|
+
),
|
|
100
|
+
cell: ({ row }) => (
|
|
101
|
+
<Checkbox
|
|
102
|
+
aria-label={t('table.selectRow')}
|
|
103
|
+
checked={row.getIsSelected()}
|
|
104
|
+
onCheckedChange={(checked) => row.toggleSelected(checked)}
|
|
105
|
+
/>
|
|
106
|
+
),
|
|
107
|
+
}),
|
|
108
|
+
...columns,
|
|
109
|
+
]
|
|
110
|
+
}, [columns, selectable, t])
|
|
111
|
+
|
|
112
|
+
const table = useTable({
|
|
113
|
+
features: dataTableFeatures,
|
|
114
|
+
columns: allColumns,
|
|
115
|
+
data: data ?? EMPTY,
|
|
116
|
+
getRowId,
|
|
117
|
+
rowCount: total,
|
|
118
|
+
state: { pagination, sorting, rowSelection: selection?.value ?? NO_SELECTION },
|
|
119
|
+
onPaginationChange,
|
|
120
|
+
onSortingChange,
|
|
121
|
+
onRowSelectionChange: selection?.onChange,
|
|
122
|
+
enableRowSelection: selectable,
|
|
123
|
+
manualPagination: true,
|
|
124
|
+
manualSorting: true,
|
|
125
|
+
enableSortingRemoval: true,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const rows = table.getRowModel().rows
|
|
129
|
+
const pageCount = Math.max(1, table.getPageCount())
|
|
130
|
+
const from = total === 0 ? 0 : pagination.pageIndex * pagination.pageSize + 1
|
|
131
|
+
const to = Math.min(total, (pagination.pageIndex + 1) * pagination.pageSize)
|
|
132
|
+
const colCount = allColumns.length
|
|
133
|
+
const selectedCount = Object.values(selection?.value ?? NO_SELECTION).filter(Boolean).length
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className="flex flex-col overflow-hidden rounded-xl border bg-card shadow-(--shadow-card)">
|
|
137
|
+
{toolbar && <div className="flex flex-wrap items-center gap-2 border-b p-3">{toolbar}</div>}
|
|
138
|
+
{selectedCount > 0 && (
|
|
139
|
+
<div className="flex flex-wrap items-center gap-2 border-b bg-fill-1 px-4 py-2 text-sm">
|
|
140
|
+
<span className="font-medium tabular-nums">{t('table.selected', { count: selectedCount })}</span>
|
|
141
|
+
<Button variant="ghost" size="sm" onClick={() => selection?.onChange({})}>
|
|
142
|
+
{t('table.clearSelection')}
|
|
143
|
+
</Button>
|
|
144
|
+
<div className="ml-auto flex flex-wrap items-center gap-2">{selection?.actions}</div>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
<div className={cn('relative transition-opacity', isFetching && !isLoading && 'opacity-60')} aria-busy={isFetching}>
|
|
149
|
+
<Table>
|
|
150
|
+
<TableHeader>
|
|
151
|
+
{table.getHeaderGroups().map((group) => (
|
|
152
|
+
<TableRow key={group.id} className="hover:bg-transparent">
|
|
153
|
+
{group.headers.map((header) => {
|
|
154
|
+
const meta = header.column.columnDef.meta
|
|
155
|
+
const sorted = header.column.getIsSorted()
|
|
156
|
+
return (
|
|
157
|
+
<TableHeaderCell
|
|
158
|
+
key={header.id}
|
|
159
|
+
aria-sort={sorted ? (sorted === 'asc' ? 'ascending' : 'descending') : undefined}
|
|
160
|
+
className={cn(
|
|
161
|
+
meta?.align === 'right' && 'text-right',
|
|
162
|
+
meta?.hideOnMobile && 'hidden sm:table-cell',
|
|
163
|
+
meta?.className,
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{header.isPlaceholder ? null : header.column.getCanSort() ? (
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
170
|
+
className={cn(
|
|
171
|
+
'-mx-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 uppercase outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40',
|
|
172
|
+
sorted && 'text-foreground',
|
|
173
|
+
meta?.align === 'right' && 'flex-row-reverse',
|
|
174
|
+
)}
|
|
175
|
+
>
|
|
176
|
+
<table.FlexRender header={header} />
|
|
177
|
+
{sorted === 'asc' ? (
|
|
178
|
+
<ArrowUp className="size-3" />
|
|
179
|
+
) : sorted === 'desc' ? (
|
|
180
|
+
<ArrowDown className="size-3" />
|
|
181
|
+
) : (
|
|
182
|
+
<ChevronsUpDown className="size-3 opacity-50" />
|
|
183
|
+
)}
|
|
184
|
+
</button>
|
|
185
|
+
) : (
|
|
186
|
+
<table.FlexRender header={header} />
|
|
187
|
+
)}
|
|
188
|
+
</TableHeaderCell>
|
|
189
|
+
)
|
|
190
|
+
})}
|
|
191
|
+
</TableRow>
|
|
192
|
+
))}
|
|
193
|
+
</TableHeader>
|
|
194
|
+
<TableBody>
|
|
195
|
+
{isLoading ? (
|
|
196
|
+
Array.from({ length: Math.min(pagination.pageSize, 8) }, (_, i) => (
|
|
197
|
+
<TableRow key={i} className="hover:bg-transparent">
|
|
198
|
+
{Array.from({ length: colCount }, (_, j) => (
|
|
199
|
+
<TableCell key={j} className="h-12 first:pl-4 last:pr-4">
|
|
200
|
+
<Skeleton className="h-4 w-full max-w-32" />
|
|
201
|
+
</TableCell>
|
|
202
|
+
))}
|
|
203
|
+
</TableRow>
|
|
204
|
+
))
|
|
205
|
+
) : error ? (
|
|
206
|
+
<TableRow className="hover:bg-transparent">
|
|
207
|
+
<TableCell colSpan={colCount}>
|
|
208
|
+
<ErrorState error={error} onRetry={onRetry} />
|
|
209
|
+
</TableCell>
|
|
210
|
+
</TableRow>
|
|
211
|
+
) : rows.length === 0 ? (
|
|
212
|
+
<TableRow className="hover:bg-transparent">
|
|
213
|
+
<TableCell colSpan={colCount}>{empty ?? <EmptyState title={t('table.noResults')} />}</TableCell>
|
|
214
|
+
</TableRow>
|
|
215
|
+
) : (
|
|
216
|
+
rows.map((row) => (
|
|
217
|
+
<TableRow key={row.id} data-state={row.getIsSelected() ? 'selected' : undefined}>
|
|
218
|
+
{row.getAllCells().map((cell) => {
|
|
219
|
+
const meta = cell.column.columnDef.meta
|
|
220
|
+
return (
|
|
221
|
+
<TableCell
|
|
222
|
+
key={cell.id}
|
|
223
|
+
className={cn(
|
|
224
|
+
'h-12 first:pl-4 last:pr-4',
|
|
225
|
+
meta?.align === 'right' && 'text-right tabular-nums',
|
|
226
|
+
meta?.hideOnMobile && 'hidden sm:table-cell',
|
|
227
|
+
meta?.className,
|
|
228
|
+
)}
|
|
229
|
+
>
|
|
230
|
+
<table.FlexRender cell={cell} />
|
|
231
|
+
</TableCell>
|
|
232
|
+
)
|
|
233
|
+
})}
|
|
234
|
+
</TableRow>
|
|
235
|
+
))
|
|
236
|
+
)}
|
|
237
|
+
</TableBody>
|
|
238
|
+
</Table>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground">
|
|
242
|
+
<span className="tabular-nums">
|
|
243
|
+
{t('table.range', {
|
|
244
|
+
from: fmt.number(from),
|
|
245
|
+
to: fmt.number(to),
|
|
246
|
+
total: fmt.number(total),
|
|
247
|
+
noun: noun ?? t('table.results'),
|
|
248
|
+
})}
|
|
249
|
+
</span>
|
|
250
|
+
<div className="flex items-center gap-3">
|
|
251
|
+
<div className="hidden items-center gap-2 sm:flex">
|
|
252
|
+
<span>{t('table.rows')}</span>
|
|
253
|
+
<Select
|
|
254
|
+
items={PAGE_SIZES.map((n) => ({ value: n, label: String(n) }))}
|
|
255
|
+
value={pagination.pageSize}
|
|
256
|
+
onValueChange={(value) => value != null && table.setPageSize(Number(value))}
|
|
257
|
+
>
|
|
258
|
+
<SelectTrigger size="sm" aria-label={t('table.rowsPerPage')}>
|
|
259
|
+
<SelectValue />
|
|
260
|
+
</SelectTrigger>
|
|
261
|
+
<SelectContent>
|
|
262
|
+
{PAGE_SIZES.map((n) => (
|
|
263
|
+
<SelectItem key={n} value={n}>
|
|
264
|
+
{n}
|
|
265
|
+
</SelectItem>
|
|
266
|
+
))}
|
|
267
|
+
</SelectContent>
|
|
268
|
+
</Select>
|
|
269
|
+
</div>
|
|
270
|
+
<span className="tabular-nums">
|
|
271
|
+
{t('table.page', { page: pagination.pageIndex + 1, count: pageCount })}
|
|
272
|
+
</span>
|
|
273
|
+
<div className="flex items-center gap-1">
|
|
274
|
+
<Button
|
|
275
|
+
variant="outline"
|
|
276
|
+
size="icon-sm"
|
|
277
|
+
aria-label={t('table.previousPage')}
|
|
278
|
+
disabled={!table.getCanPreviousPage()}
|
|
279
|
+
onClick={() => table.previousPage()}
|
|
280
|
+
>
|
|
281
|
+
<ChevronLeft />
|
|
282
|
+
</Button>
|
|
283
|
+
<Button
|
|
284
|
+
variant="outline"
|
|
285
|
+
size="icon-sm"
|
|
286
|
+
aria-label={t('table.nextPage')}
|
|
287
|
+
disabled={!table.getCanNextPage()}
|
|
288
|
+
onClick={() => table.nextPage()}
|
|
289
|
+
>
|
|
290
|
+
<ChevronRight />
|
|
291
|
+
</Button>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
rowPaginationFeature,
|
|
3
|
+
rowSelectionFeature,
|
|
4
|
+
rowSortingFeature,
|
|
5
|
+
tableFeatures,
|
|
6
|
+
type ColumnDef,
|
|
7
|
+
type RowData,
|
|
8
|
+
} from '@tanstack/react-table'
|
|
9
|
+
|
|
10
|
+
/** Features every DataTable registers. Build module columns with this type. */
|
|
11
|
+
export const dataTableFeatures = tableFeatures({ rowSortingFeature, rowPaginationFeature, rowSelectionFeature })
|
|
12
|
+
export type DataTableFeatures = typeof dataTableFeatures
|
|
13
|
+
|
|
14
|
+
export type DataTableColumn<TData extends RowData> = ColumnDef<DataTableFeatures, TData, any> // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
15
|
+
|
|
16
|
+
declare module '@tanstack/react-table' {
|
|
17
|
+
// Per-column layout hints read by DataTable.
|
|
18
|
+
interface ColumnMeta<TFeatures, TData, TValue> {
|
|
19
|
+
align?: 'left' | 'right'
|
|
20
|
+
className?: string
|
|
21
|
+
/** Hide below the sm breakpoint. */
|
|
22
|
+
hideOnMobile?: boolean
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ComponentProps } from 'react'
|
|
2
|
+
|
|
3
|
+
import { TableHead } from '@/components/ui/table'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Header cell in the shared table style: small uppercase label on a subtle
|
|
8
|
+
* fill, with the outer cells aligned to the card padding. Used by DataTable
|
|
9
|
+
* and by small static tables (roles matrix, sign-in history).
|
|
10
|
+
*/
|
|
11
|
+
export function TableHeaderCell({ className, ...props }: ComponentProps<typeof TableHead>) {
|
|
12
|
+
return <TableHead className={cn('label-caps h-10 bg-fill-1 first:pl-4 last:pr-4', className)} {...props} />
|
|
13
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Search, X } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
|
|
7
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
8
|
+
|
|
9
|
+
/** Debounced search box. Keeps typing snappy while the URL/query updates lazily. */
|
|
10
|
+
export function TableSearch({
|
|
11
|
+
value,
|
|
12
|
+
onChange,
|
|
13
|
+
placeholder,
|
|
14
|
+
}: {
|
|
15
|
+
value: string
|
|
16
|
+
onChange: (value: string) => void
|
|
17
|
+
placeholder?: string
|
|
18
|
+
}) {
|
|
19
|
+
const { t } = useTranslation()
|
|
20
|
+
const label = placeholder ?? t('table.search')
|
|
21
|
+
const [draft, setDraft] = useState(value)
|
|
22
|
+
const [synced, setSynced] = useState(value)
|
|
23
|
+
|
|
24
|
+
// Adopt outside changes (e.g. "Clear filters") without an effect.
|
|
25
|
+
if (value !== synced) {
|
|
26
|
+
setSynced(value)
|
|
27
|
+
setDraft(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (draft === value) return
|
|
32
|
+
const t = setTimeout(() => onChange(draft), 300)
|
|
33
|
+
return () => clearTimeout(t)
|
|
34
|
+
}, [draft, value, onChange])
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<InputGroup className="w-full sm:w-64">
|
|
38
|
+
<InputGroupAddon>
|
|
39
|
+
<Search />
|
|
40
|
+
</InputGroupAddon>
|
|
41
|
+
<InputGroupInput
|
|
42
|
+
type="search"
|
|
43
|
+
value={draft}
|
|
44
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
45
|
+
placeholder={label}
|
|
46
|
+
aria-label={label}
|
|
47
|
+
/>
|
|
48
|
+
</InputGroup>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type FilterOption = { value: string; label: string }
|
|
53
|
+
|
|
54
|
+
/** Single-value filter. Selecting the "All …" entry clears the filter. */
|
|
55
|
+
export function FilterSelect({
|
|
56
|
+
label,
|
|
57
|
+
allLabel,
|
|
58
|
+
value,
|
|
59
|
+
options,
|
|
60
|
+
onChange,
|
|
61
|
+
}: {
|
|
62
|
+
/** Accessible name, e.g. "Status". */
|
|
63
|
+
label: string
|
|
64
|
+
/** First entry, clears the filter, e.g. "All statuses". */
|
|
65
|
+
allLabel: string
|
|
66
|
+
value: string | undefined
|
|
67
|
+
options: FilterOption[]
|
|
68
|
+
onChange: (value: string | undefined) => void
|
|
69
|
+
}) {
|
|
70
|
+
const ALL = '__all'
|
|
71
|
+
const items = [{ value: ALL, label: allLabel }, ...options]
|
|
72
|
+
return (
|
|
73
|
+
<Select
|
|
74
|
+
items={items}
|
|
75
|
+
value={value ?? ALL}
|
|
76
|
+
onValueChange={(next) => onChange(next === ALL || next == null ? undefined : String(next))}
|
|
77
|
+
>
|
|
78
|
+
<SelectTrigger aria-label={label} className="min-w-36">
|
|
79
|
+
<SelectValue />
|
|
80
|
+
</SelectTrigger>
|
|
81
|
+
<SelectContent>
|
|
82
|
+
{items.map((o) => (
|
|
83
|
+
<SelectItem key={o.value} value={o.value}>
|
|
84
|
+
{o.label}
|
|
85
|
+
</SelectItem>
|
|
86
|
+
))}
|
|
87
|
+
</SelectContent>
|
|
88
|
+
</Select>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function ClearFiltersButton({ onClick }: { onClick: () => void }) {
|
|
93
|
+
const { t } = useTranslation()
|
|
94
|
+
return (
|
|
95
|
+
<Button variant="ghost" onClick={onClick}>
|
|
96
|
+
<X data-icon="inline-start" />
|
|
97
|
+
{t('actions.clear')}
|
|
98
|
+
</Button>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useCallback, useMemo } from 'react'
|
|
2
|
+
import { useSearchParams } from 'react-router'
|
|
3
|
+
import type { OnChangeFn, PaginationState, SortingState, Updater } from '@tanstack/react-table'
|
|
4
|
+
|
|
5
|
+
import type { ListParams } from '@/core/api/types'
|
|
6
|
+
|
|
7
|
+
const resolve = <T,>(updater: Updater<T>, prev: T): T =>
|
|
8
|
+
typeof updater === 'function' ? (updater as (p: T) => T)(prev) : updater
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Keeps list state (page, size, sort, search, filters) in the URL so lists are
|
|
12
|
+
* shareable, survive refresh and work with the browser back button.
|
|
13
|
+
* ?page=2&size=20&sort=price.desc&q=lamp&status=active
|
|
14
|
+
*/
|
|
15
|
+
export function useListUrlState<TFilterKey extends string>({
|
|
16
|
+
filterKeys,
|
|
17
|
+
defaultPageSize = 10,
|
|
18
|
+
defaultSort,
|
|
19
|
+
}: {
|
|
20
|
+
filterKeys: readonly TFilterKey[]
|
|
21
|
+
defaultPageSize?: number
|
|
22
|
+
defaultSort?: SortingState[number]
|
|
23
|
+
}) {
|
|
24
|
+
const [params, setParams] = useSearchParams()
|
|
25
|
+
|
|
26
|
+
const page = Math.max(1, Number(params.get('page')) || 1)
|
|
27
|
+
const pageSize = Number(params.get('size')) || defaultPageSize
|
|
28
|
+
const search = params.get('q') ?? ''
|
|
29
|
+
const sortParam = params.get('sort')
|
|
30
|
+
|
|
31
|
+
const pagination = useMemo<PaginationState>(() => ({ pageIndex: page - 1, pageSize }), [page, pageSize])
|
|
32
|
+
|
|
33
|
+
const sorting = useMemo<SortingState>(() => {
|
|
34
|
+
if (sortParam === '') return []
|
|
35
|
+
if (!sortParam) return defaultSort ? [defaultSort] : []
|
|
36
|
+
const [id, dir] = sortParam.split('.')
|
|
37
|
+
return id ? [{ id, desc: dir === 'desc' }] : []
|
|
38
|
+
}, [sortParam, defaultSort])
|
|
39
|
+
|
|
40
|
+
const filterString = filterKeys.map((k) => params.get(k) ?? '').join('|')
|
|
41
|
+
const filters = useMemo(() => {
|
|
42
|
+
const values = filterString.split('|')
|
|
43
|
+
return Object.fromEntries(filterKeys.map((k, i) => [k, values[i] || undefined])) as Record<
|
|
44
|
+
TFilterKey,
|
|
45
|
+
string | undefined
|
|
46
|
+
>
|
|
47
|
+
}, [filterString, filterKeys])
|
|
48
|
+
|
|
49
|
+
const update = useCallback(
|
|
50
|
+
(patch: Record<string, string | number | undefined>, resetPage = true) => {
|
|
51
|
+
setParams(
|
|
52
|
+
(prev) => {
|
|
53
|
+
const next = new URLSearchParams(prev)
|
|
54
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
55
|
+
if (value === undefined || value === '') next.delete(key)
|
|
56
|
+
else next.set(key, String(value))
|
|
57
|
+
}
|
|
58
|
+
if (resetPage && !('page' in patch)) next.delete('page')
|
|
59
|
+
return next
|
|
60
|
+
},
|
|
61
|
+
{ replace: true },
|
|
62
|
+
)
|
|
63
|
+
},
|
|
64
|
+
[setParams],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
|
68
|
+
const next = resolve(updater, pagination)
|
|
69
|
+
update(
|
|
70
|
+
{
|
|
71
|
+
page: next.pageIndex > 0 ? next.pageIndex + 1 : undefined,
|
|
72
|
+
size: next.pageSize !== defaultPageSize ? next.pageSize : undefined,
|
|
73
|
+
},
|
|
74
|
+
next.pageSize !== pagination.pageSize,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const onSortingChange: OnChangeFn<SortingState> = (updater) => {
|
|
79
|
+
const [first] = resolve(updater, sorting)
|
|
80
|
+
// '' = explicitly unsorted (distinct from "use the default sort").
|
|
81
|
+
update({ sort: first ? `${first.id}.${first.desc ? 'desc' : 'asc'}` : '' })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const listParams = useMemo<ListParams<Record<TFilterKey, string | undefined>>>(
|
|
85
|
+
() => ({
|
|
86
|
+
page,
|
|
87
|
+
pageSize,
|
|
88
|
+
search: search || undefined,
|
|
89
|
+
sort: sorting[0] ? { field: sorting[0].id, direction: sorting[0].desc ? 'desc' : 'asc' } : undefined,
|
|
90
|
+
filters,
|
|
91
|
+
}),
|
|
92
|
+
[page, pageSize, search, sorting, filters],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
pagination,
|
|
97
|
+
sorting,
|
|
98
|
+
search,
|
|
99
|
+
filters,
|
|
100
|
+
listParams,
|
|
101
|
+
onPaginationChange,
|
|
102
|
+
onSortingChange,
|
|
103
|
+
setSearch: (q: string) => update({ q }),
|
|
104
|
+
setFilter: (key: TFilterKey, value: string | undefined) => update({ [key]: value }),
|
|
105
|
+
clearAll: () => update({ q: undefined, sort: undefined, ...Object.fromEntries(filterKeys.map((k) => [k, undefined])) }),
|
|
106
|
+
hasActiveFilters: Boolean(search) || Object.values(filters).some(Boolean),
|
|
107
|
+
}
|
|
108
|
+
}
|