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.
Files changed (216) hide show
  1. package/README.md +24 -3
  2. package/bin/create-freakpunch.mjs +227 -0
  3. package/files/README.md +19 -0
  4. package/files/app.config.ts +50 -0
  5. package/files/locales.ts +10 -0
  6. package/files/registry.ts +17 -0
  7. package/package.json +23 -6
  8. package/template/.dockerignore +17 -0
  9. package/template/.oxlintrc.json +9 -0
  10. package/template/AGENTS.md +6 -0
  11. package/template/BACKEND.md +108 -0
  12. package/template/CLAUDE.md +297 -0
  13. package/template/Dockerfile +19 -0
  14. package/template/_gitignore +26 -0
  15. package/template/components.json +27 -0
  16. package/template/design/COMPONENTS.md +145 -0
  17. package/template/design/DESIGN.md +212 -0
  18. package/template/design/PATTERNS.md +110 -0
  19. package/template/index.html +25 -0
  20. package/template/nginx.conf +32 -0
  21. package/template/package-lock.json +6484 -0
  22. package/template/package.json +51 -0
  23. package/template/public/favicon.svg +1 -0
  24. package/template/public/robots.txt +2 -0
  25. package/template/src/app.config.ts +50 -0
  26. package/template/src/app.roles.ts +24 -0
  27. package/template/src/components/ui/alert-dialog.tsx +181 -0
  28. package/template/src/components/ui/avatar.tsx +116 -0
  29. package/template/src/components/ui/badge.tsx +57 -0
  30. package/template/src/components/ui/breadcrumb.tsx +130 -0
  31. package/template/src/components/ui/button.tsx +58 -0
  32. package/template/src/components/ui/calendar.tsx +277 -0
  33. package/template/src/components/ui/card.tsx +99 -0
  34. package/template/src/components/ui/chart.tsx +370 -0
  35. package/template/src/components/ui/checkbox.tsx +28 -0
  36. package/template/src/components/ui/combobox.tsx +329 -0
  37. package/template/src/components/ui/command.tsx +191 -0
  38. package/template/src/components/ui/dialog.tsx +162 -0
  39. package/template/src/components/ui/dropdown-menu.tsx +271 -0
  40. package/template/src/components/ui/empty.tsx +103 -0
  41. package/template/src/components/ui/field.tsx +236 -0
  42. package/template/src/components/ui/input-group.tsx +263 -0
  43. package/template/src/components/ui/input.tsx +45 -0
  44. package/template/src/components/ui/kbd.tsx +31 -0
  45. package/template/src/components/ui/label.tsx +17 -0
  46. package/template/src/components/ui/pagination.tsx +134 -0
  47. package/template/src/components/ui/popover.tsx +87 -0
  48. package/template/src/components/ui/radio-group.tsx +35 -0
  49. package/template/src/components/ui/select.tsx +204 -0
  50. package/template/src/components/ui/separator.tsx +25 -0
  51. package/template/src/components/ui/sheet.tsx +139 -0
  52. package/template/src/components/ui/sidebar.tsx +741 -0
  53. package/template/src/components/ui/skeleton.tsx +13 -0
  54. package/template/src/components/ui/sonner.tsx +78 -0
  55. package/template/src/components/ui/spinner.tsx +42 -0
  56. package/template/src/components/ui/switch.tsx +29 -0
  57. package/template/src/components/ui/table.tsx +121 -0
  58. package/template/src/components/ui/tabs.tsx +191 -0
  59. package/template/src/components/ui/textarea.tsx +17 -0
  60. package/template/src/components/ui/toggle-group.tsx +94 -0
  61. package/template/src/components/ui/toggle.tsx +48 -0
  62. package/template/src/components/ui/tooltip.tsx +91 -0
  63. package/template/src/core/api/demo.ts +8 -0
  64. package/template/src/core/api/http.ts +76 -0
  65. package/template/src/core/api/mock.ts +88 -0
  66. package/template/src/core/api/rest.ts +67 -0
  67. package/template/src/core/api/types.ts +21 -0
  68. package/template/src/core/audit/audit.service.ts +94 -0
  69. package/template/src/core/audit/hooks.ts +19 -0
  70. package/template/src/core/auth/auth-store.ts +52 -0
  71. package/template/src/core/auth/auth.service.ts +86 -0
  72. package/template/src/core/auth/can.tsx +39 -0
  73. package/template/src/core/auth/dev-role-switcher.tsx +64 -0
  74. package/template/src/core/auth/local-credentials.ts +46 -0
  75. package/template/src/core/auth/permissions.ts +115 -0
  76. package/template/src/core/auth/require-auth.tsx +16 -0
  77. package/template/src/core/auth/require-permission.tsx +24 -0
  78. package/template/src/core/auth/roles.ts +9 -0
  79. package/template/src/core/auth/types.ts +20 -0
  80. package/template/src/core/auth/use-auth.ts +19 -0
  81. package/template/src/core/company/company.service.ts +94 -0
  82. package/template/src/core/company/hooks.ts +40 -0
  83. package/template/src/core/company/password-policy.ts +40 -0
  84. package/template/src/core/company/types.ts +46 -0
  85. package/template/src/core/company/use-app-name.ts +7 -0
  86. package/template/src/core/components/button-link.tsx +14 -0
  87. package/template/src/core/components/confirm-dialog.tsx +116 -0
  88. package/template/src/core/components/data-table/data-table.tsx +297 -0
  89. package/template/src/core/components/data-table/features.ts +24 -0
  90. package/template/src/core/components/data-table/table-header-cell.tsx +13 -0
  91. package/template/src/core/components/data-table/toolbar.tsx +100 -0
  92. package/template/src/core/components/data-table/use-list-url-state.ts +108 -0
  93. package/template/src/core/components/date-picker.tsx +71 -0
  94. package/template/src/core/components/file-attachments.tsx +84 -0
  95. package/template/src/core/components/form-error.tsx +9 -0
  96. package/template/src/core/components/form-page.tsx +93 -0
  97. package/template/src/core/components/image-upload.tsx +86 -0
  98. package/template/src/core/components/page.tsx +75 -0
  99. package/template/src/core/components/phone-input.tsx +58 -0
  100. package/template/src/core/components/search-select.tsx +57 -0
  101. package/template/src/core/components/segmented.tsx +41 -0
  102. package/template/src/core/components/states.tsx +97 -0
  103. package/template/src/core/components/status-badge.tsx +26 -0
  104. package/template/src/core/components/user-avatar.tsx +23 -0
  105. package/template/src/core/config/runtime.ts +16 -0
  106. package/template/src/core/config/types.ts +37 -0
  107. package/template/src/core/i18n/i18n.ts +69 -0
  108. package/template/src/core/i18n/i18next.d.ts +12 -0
  109. package/template/src/core/i18n/keys.ts +37 -0
  110. package/template/src/core/i18n/language-switcher.tsx +45 -0
  111. package/template/src/core/i18n/types.ts +17 -0
  112. package/template/src/core/i18n/use-format.ts +31 -0
  113. package/template/src/core/layout/app-shell.tsx +50 -0
  114. package/template/src/core/layout/app-sidebar.tsx +285 -0
  115. package/template/src/core/layout/command-menu.tsx +117 -0
  116. package/template/src/core/layout/top-bar.tsx +100 -0
  117. package/template/src/core/lib/csv.ts +27 -0
  118. package/template/src/core/lib/format.ts +87 -0
  119. package/template/src/core/lib/phone.ts +50 -0
  120. package/template/src/core/lib/storage.ts +27 -0
  121. package/template/src/core/modules/lazy-page.ts +9 -0
  122. package/template/src/core/modules/navigation.ts +39 -0
  123. package/template/src/core/modules/types.ts +55 -0
  124. package/template/src/core/notifications/hooks.ts +84 -0
  125. package/template/src/core/notifications/notification-center.tsx +251 -0
  126. package/template/src/core/notifications/notifications.service.ts +132 -0
  127. package/template/src/core/notifications/types.ts +70 -0
  128. package/template/src/core/preferences/preferences-store.ts +70 -0
  129. package/template/src/core/setup/setup-gate.tsx +20 -0
  130. package/template/src/core/setup/setup.service.ts +101 -0
  131. package/template/src/core/theme/brand-context.tsx +53 -0
  132. package/template/src/core/theme/brand.ts +29 -0
  133. package/template/src/core/theme/theme-context.tsx +62 -0
  134. package/template/src/core/theme/theme-options.ts +8 -0
  135. package/template/src/core/theme/theme-toggle.tsx +45 -0
  136. package/template/src/core/users/hooks.ts +51 -0
  137. package/template/src/core/users/types.ts +37 -0
  138. package/template/src/core/users/users.service.ts +167 -0
  139. package/template/src/hooks/use-mobile.ts +19 -0
  140. package/template/src/index.css +321 -0
  141. package/template/src/lib/utils.ts +1 -0
  142. package/template/src/locales/en/account.ts +180 -0
  143. package/template/src/locales/en/auth.ts +18 -0
  144. package/template/src/locales/en/common.ts +151 -0
  145. package/template/src/locales/en/company.ts +380 -0
  146. package/template/src/locales/en/dashboard.ts +46 -0
  147. package/template/src/locales/en/errors.ts +18 -0
  148. package/template/src/locales/en/notifications.ts +43 -0
  149. package/template/src/locales/en/settings.ts +25 -0
  150. package/template/src/locales/en/setup.ts +45 -0
  151. package/template/src/locales/index.ts +58 -0
  152. package/template/src/locales/tr/account.ts +184 -0
  153. package/template/src/locales/tr/auth.ts +22 -0
  154. package/template/src/locales/tr/common.ts +154 -0
  155. package/template/src/locales/tr/company.ts +384 -0
  156. package/template/src/locales/tr/dashboard.ts +50 -0
  157. package/template/src/locales/tr/errors.ts +22 -0
  158. package/template/src/locales/tr/notifications.ts +47 -0
  159. package/template/src/locales/tr/settings.ts +29 -0
  160. package/template/src/locales/tr/setup.ts +49 -0
  161. package/template/src/main.tsx +16 -0
  162. package/template/src/modules/locales.ts +10 -0
  163. package/template/src/modules/registry.ts +17 -0
  164. package/template/src/pages/dashboard/dashboard-page.tsx +55 -0
  165. package/template/src/pages/dashboard/quick-actions.tsx +37 -0
  166. package/template/src/pages/login-page.tsx +152 -0
  167. package/template/src/pages/no-access-page.tsx +11 -0
  168. package/template/src/pages/not-found-page.tsx +61 -0
  169. package/template/src/pages/settings/account/account.service.ts +128 -0
  170. package/template/src/pages/settings/account/components/mock-qr.tsx +50 -0
  171. package/template/src/pages/settings/account/components/password-strength.tsx +38 -0
  172. package/template/src/pages/settings/account/components/two-factor-dialog.tsx +140 -0
  173. package/template/src/pages/settings/account/hooks.ts +73 -0
  174. package/template/src/pages/settings/account/notifications-page.tsx +103 -0
  175. package/template/src/pages/settings/account/preferences-page.tsx +157 -0
  176. package/template/src/pages/settings/account/profile-page.tsx +242 -0
  177. package/template/src/pages/settings/account/security-page.tsx +364 -0
  178. package/template/src/pages/settings/account/types.ts +37 -0
  179. package/template/src/pages/settings/company/audit-log-page.tsx +126 -0
  180. package/template/src/pages/settings/company/branding-page.tsx +278 -0
  181. package/template/src/pages/settings/company/components/invite-dialog.tsx +106 -0
  182. package/template/src/pages/settings/company/components/read-only-notice.tsx +13 -0
  183. package/template/src/pages/settings/company/danger-zone-page.tsx +127 -0
  184. package/template/src/pages/settings/company/general-page.tsx +234 -0
  185. package/template/src/pages/settings/company/integrations/api-keys-panel.tsx +170 -0
  186. package/template/src/pages/settings/company/integrations/hooks.ts +37 -0
  187. package/template/src/pages/settings/company/integrations/integrations-page.tsx +57 -0
  188. package/template/src/pages/settings/company/integrations/integrations.service.ts +153 -0
  189. package/template/src/pages/settings/company/integrations/settings-forms.tsx +299 -0
  190. package/template/src/pages/settings/company/integrations/webhooks-panel.tsx +179 -0
  191. package/template/src/pages/settings/company/modules-page.tsx +65 -0
  192. package/template/src/pages/settings/company/roles-page.tsx +116 -0
  193. package/template/src/pages/settings/company/security-policies-page.tsx +164 -0
  194. package/template/src/pages/settings/company/users-page.tsx +262 -0
  195. package/template/src/pages/settings/settings-layout.tsx +57 -0
  196. package/template/src/pages/settings/settings-nav.ts +69 -0
  197. package/template/src/pages/settings/settings-routes.tsx +49 -0
  198. package/template/src/pages/setup-page.tsx +387 -0
  199. package/template/src/providers.tsx +68 -0
  200. package/template/src/router.tsx +59 -0
  201. package/template/tools/check-design.mjs +73 -0
  202. package/template/tools/new-module.mjs +90 -0
  203. package/template/tools/templates/module/api/__id__.mock.ts.tpl +17 -0
  204. package/template/tools/templates/module/api/__id__.service.ts.tpl +51 -0
  205. package/template/tools/templates/module/hooks.ts.tpl +57 -0
  206. package/template/tools/templates/module/index.ts.tpl +44 -0
  207. package/template/tools/templates/module/locales/en.ts.tpl +51 -0
  208. package/template/tools/templates/module/locales/tr.ts.tpl +55 -0
  209. package/template/tools/templates/module/pages/__id__-detail-page.tsx.tpl +103 -0
  210. package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -0
  211. package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -0
  212. package/template/tools/templates/module/types.ts.tpl +32 -0
  213. package/template/tsconfig.app.json +32 -0
  214. package/template/tsconfig.json +12 -0
  215. package/template/tsconfig.node.json +23 -0
  216. 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
+ }