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,71 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { enUS, tr } from 'date-fns/locale'
|
|
4
|
+
import { CalendarDays } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { Calendar } from '@/components/ui/calendar'
|
|
8
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
9
|
+
import { useFormat } from '@/core/i18n/use-format'
|
|
10
|
+
import { cn } from '@/lib/utils'
|
|
11
|
+
|
|
12
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
13
|
+
const toValue = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
14
|
+
/** Local noon, so a time zone offset can't move the calendar day. */
|
|
15
|
+
const fromValue = (value: string) => new Date(`${value}T12:00:00`)
|
|
16
|
+
|
|
17
|
+
/** Date-only field. The value is a `YYYY-MM-DD` string, the shape to send to a backend. */
|
|
18
|
+
export function DatePicker({
|
|
19
|
+
id,
|
|
20
|
+
value,
|
|
21
|
+
onChange,
|
|
22
|
+
placeholder,
|
|
23
|
+
disabled,
|
|
24
|
+
invalid,
|
|
25
|
+
className,
|
|
26
|
+
}: {
|
|
27
|
+
id?: string
|
|
28
|
+
value: string | undefined
|
|
29
|
+
onChange: (value: string | undefined) => void
|
|
30
|
+
placeholder?: string
|
|
31
|
+
disabled?: boolean
|
|
32
|
+
invalid?: boolean
|
|
33
|
+
className?: string
|
|
34
|
+
}) {
|
|
35
|
+
const [open, setOpen] = useState(false)
|
|
36
|
+
const { t, i18n } = useTranslation()
|
|
37
|
+
const fmt = useFormat()
|
|
38
|
+
const selected = value ? fromValue(value) : undefined
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
42
|
+
<PopoverTrigger
|
|
43
|
+
render={
|
|
44
|
+
<Button
|
|
45
|
+
id={id}
|
|
46
|
+
type="button"
|
|
47
|
+
variant="outline"
|
|
48
|
+
disabled={disabled}
|
|
49
|
+
aria-invalid={invalid}
|
|
50
|
+
className={cn('w-full justify-start font-normal', !value && 'text-muted-foreground', className)}
|
|
51
|
+
>
|
|
52
|
+
<CalendarDays data-icon="inline-start" />
|
|
53
|
+
{selected ? fmt.date(selected) : (placeholder ?? t('fields.pickDate'))}
|
|
54
|
+
</Button>
|
|
55
|
+
}
|
|
56
|
+
/>
|
|
57
|
+
<PopoverContent align="start" className="w-auto p-0">
|
|
58
|
+
<Calendar
|
|
59
|
+
mode="single"
|
|
60
|
+
locale={i18n.language === 'tr' ? tr : enUS}
|
|
61
|
+
selected={selected}
|
|
62
|
+
defaultMonth={selected}
|
|
63
|
+
onSelect={(day) => {
|
|
64
|
+
onChange(day ? toValue(day) : undefined)
|
|
65
|
+
setOpen(false)
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
</PopoverContent>
|
|
69
|
+
</Popover>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { FileText, Paperclip, X } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
import { useFormat } from '@/core/i18n/use-format'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Picks files and lists them. It only holds `File` objects: the service uploads
|
|
12
|
+
* them, usually `http.post(path, formData)`. `onTooLarge` receives rejected files.
|
|
13
|
+
*/
|
|
14
|
+
export function FileAttachments({
|
|
15
|
+
id,
|
|
16
|
+
files,
|
|
17
|
+
onChange,
|
|
18
|
+
onTooLarge,
|
|
19
|
+
accept,
|
|
20
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
21
|
+
disabled,
|
|
22
|
+
}: {
|
|
23
|
+
id?: string
|
|
24
|
+
files: File[]
|
|
25
|
+
onChange: (files: File[]) => void
|
|
26
|
+
onTooLarge?: (files: File[]) => void
|
|
27
|
+
accept?: string
|
|
28
|
+
maxBytes?: number
|
|
29
|
+
disabled?: boolean
|
|
30
|
+
}) {
|
|
31
|
+
const input = useRef<HTMLInputElement>(null)
|
|
32
|
+
const { t } = useTranslation()
|
|
33
|
+
const fmt = useFormat()
|
|
34
|
+
|
|
35
|
+
function add(picked: File[]) {
|
|
36
|
+
const tooLarge = picked.filter((f) => f.size > maxBytes)
|
|
37
|
+
if (tooLarge.length > 0) onTooLarge?.(tooLarge)
|
|
38
|
+
onChange([...files, ...picked.filter((f) => f.size <= maxBytes)])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex flex-col gap-2">
|
|
43
|
+
<input
|
|
44
|
+
ref={input}
|
|
45
|
+
id={id}
|
|
46
|
+
type="file"
|
|
47
|
+
multiple
|
|
48
|
+
accept={accept}
|
|
49
|
+
className="sr-only"
|
|
50
|
+
tabIndex={-1}
|
|
51
|
+
disabled={disabled}
|
|
52
|
+
onChange={(e) => {
|
|
53
|
+
add(Array.from(e.target.files ?? []))
|
|
54
|
+
e.target.value = ''
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
<Button type="button" variant="outline" size="sm" disabled={disabled} className="self-start" onClick={() => input.current?.click()}>
|
|
58
|
+
<Paperclip data-icon="inline-start" />
|
|
59
|
+
{t('fields.addFiles')}
|
|
60
|
+
</Button>
|
|
61
|
+
{files.length > 0 && (
|
|
62
|
+
<ul className="divide-y rounded-xl border">
|
|
63
|
+
{files.map((file, i) => (
|
|
64
|
+
<li key={`${file.name}-${i}`} className="flex items-center gap-3 px-3 py-2 text-sm">
|
|
65
|
+
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
|
66
|
+
<span className="min-w-0 flex-1 truncate">{file.name}</span>
|
|
67
|
+
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{fmt.number(Math.ceil(file.size / 1024))} KB</span>
|
|
68
|
+
<Button
|
|
69
|
+
type="button"
|
|
70
|
+
variant="ghost"
|
|
71
|
+
size="icon-sm"
|
|
72
|
+
disabled={disabled}
|
|
73
|
+
aria-label={t('fields.removeFile', { name: file.name })}
|
|
74
|
+
onClick={() => onChange(files.filter((_, index) => index !== i))}
|
|
75
|
+
>
|
|
76
|
+
<X />
|
|
77
|
+
</Button>
|
|
78
|
+
</li>
|
|
79
|
+
))}
|
|
80
|
+
</ul>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { FieldError } from '@/components/ui/field'
|
|
2
|
+
import { translateMessage, useAnyT } from '@/core/i18n/keys'
|
|
3
|
+
|
|
4
|
+
/** SortUI FieldError that translates `vmsg()` keys from Zod schemas. */
|
|
5
|
+
export function FormFieldError({ error }: { error?: { message?: string } }) {
|
|
6
|
+
const t = useAnyT()
|
|
7
|
+
const message = error?.message ? translateMessage(t, error.message) : undefined
|
|
8
|
+
return <FieldError errors={message ? [{ message }] : []} />
|
|
9
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { FormEventHandler, ReactNode } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { Spinner } from '@/components/ui/spinner'
|
|
6
|
+
import { cn } from '@/lib/utils'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Two-column settings-style form: section titles on the left, fields on the
|
|
10
|
+
* right, sticky action bar at the bottom. Wrap with <Page> for the title.
|
|
11
|
+
*
|
|
12
|
+
* saveBar="always" (create/edit pages) keeps the bar visible; "dirty"
|
|
13
|
+
* (settings pages) shows it only when there are unsaved changes.
|
|
14
|
+
*/
|
|
15
|
+
export function FormLayout({
|
|
16
|
+
onSubmit,
|
|
17
|
+
children,
|
|
18
|
+
submitLabel,
|
|
19
|
+
isSubmitting,
|
|
20
|
+
isDirty = true,
|
|
21
|
+
onCancel,
|
|
22
|
+
extraActions,
|
|
23
|
+
saveBar = 'always',
|
|
24
|
+
readOnly,
|
|
25
|
+
}: {
|
|
26
|
+
onSubmit: FormEventHandler<HTMLFormElement>
|
|
27
|
+
children: ReactNode
|
|
28
|
+
submitLabel?: string
|
|
29
|
+
isSubmitting?: boolean
|
|
30
|
+
isDirty?: boolean
|
|
31
|
+
onCancel?: () => void
|
|
32
|
+
/** Left side of the action bar, e.g. a destructive "Delete" button. */
|
|
33
|
+
extraActions?: ReactNode
|
|
34
|
+
saveBar?: 'always' | 'dirty'
|
|
35
|
+
/** Disables every field and hides the action bar (e.g. missing permission). */
|
|
36
|
+
readOnly?: boolean
|
|
37
|
+
}) {
|
|
38
|
+
const { t } = useTranslation()
|
|
39
|
+
const showBar = !readOnly && (saveBar === 'always' || isDirty || isSubmitting)
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<form onSubmit={onSubmit} noValidate className="flex flex-col">
|
|
43
|
+
<fieldset disabled={readOnly} className="contents">
|
|
44
|
+
<div className="flex flex-col divide-y rounded-xl border bg-card shadow-(--shadow-card)">{children}</div>
|
|
45
|
+
</fieldset>
|
|
46
|
+
{showBar && (
|
|
47
|
+
<div
|
|
48
|
+
className={cn(
|
|
49
|
+
'sticky bottom-0 z-10 -mx-1 mt-4 flex items-center justify-between gap-2 bg-background px-1 py-3',
|
|
50
|
+
saveBar === 'dirty' && 'animate-in fade-in slide-in-from-bottom-1',
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
<div className="min-w-0 text-xs text-muted-foreground">
|
|
54
|
+
{extraActions ?? (saveBar === 'dirty' && <span className="truncate">{t('form.unsaved')}</span>)}
|
|
55
|
+
</div>
|
|
56
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
57
|
+
{onCancel && (
|
|
58
|
+
<Button type="button" variant="ghost" onClick={onCancel}>
|
|
59
|
+
{saveBar === 'dirty' ? t('actions.discard') : t('actions.cancel')}
|
|
60
|
+
</Button>
|
|
61
|
+
)}
|
|
62
|
+
<Button type="submit" disabled={isSubmitting || !isDirty}>
|
|
63
|
+
{isSubmitting && <Spinner data-icon="inline-start" />}
|
|
64
|
+
{submitLabel ?? t('actions.saveChanges')}
|
|
65
|
+
</Button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
</form>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function FormSection({
|
|
74
|
+
title,
|
|
75
|
+
description,
|
|
76
|
+
children,
|
|
77
|
+
className,
|
|
78
|
+
}: {
|
|
79
|
+
title: string
|
|
80
|
+
description?: ReactNode
|
|
81
|
+
children: ReactNode
|
|
82
|
+
className?: string
|
|
83
|
+
}) {
|
|
84
|
+
return (
|
|
85
|
+
<section className={cn('grid gap-x-10 gap-y-4 p-5 md:grid-cols-[minmax(0,16rem)_minmax(0,1fr)] md:p-6', className)}>
|
|
86
|
+
<div>
|
|
87
|
+
<h2 className="text-sm font-medium">{title}</h2>
|
|
88
|
+
{description && <p className="mt-1 text-xs text-muted-foreground">{description}</p>}
|
|
89
|
+
</div>
|
|
90
|
+
<div className="flex min-w-0 flex-col gap-4">{children}</div>
|
|
91
|
+
</section>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useId, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Upload } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
import { cn } from '@/lib/utils'
|
|
7
|
+
|
|
8
|
+
const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml', 'image/x-icon', 'image/vnd.microsoft.icon']
|
|
9
|
+
const MAX_BYTES = 2 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
export type ImageUploadError = 'tooLarge' | 'invalidType'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* File picker with a preview slot. Reads the image as a data URL (mock
|
|
15
|
+
* storage); a real backend would upload the file and return its URL.
|
|
16
|
+
*/
|
|
17
|
+
export function ImageUpload({
|
|
18
|
+
value,
|
|
19
|
+
onChange,
|
|
20
|
+
preview,
|
|
21
|
+
label,
|
|
22
|
+
onError,
|
|
23
|
+
accept = ACCEPT,
|
|
24
|
+
className,
|
|
25
|
+
disabled,
|
|
26
|
+
}: {
|
|
27
|
+
value: string | undefined
|
|
28
|
+
onChange: (dataUrl: string | undefined) => void
|
|
29
|
+
/** Rendered as the preview tile (receives the current value). */
|
|
30
|
+
preview: (value: string | undefined) => ReactNode
|
|
31
|
+
/** Accessible label of the upload button, e.g. "Change photo". */
|
|
32
|
+
label: string
|
|
33
|
+
onError?: (error: ImageUploadError) => void
|
|
34
|
+
accept?: string[]
|
|
35
|
+
className?: string
|
|
36
|
+
disabled?: boolean
|
|
37
|
+
}) {
|
|
38
|
+
const { t } = useTranslation()
|
|
39
|
+
const input = useRef<HTMLInputElement>(null)
|
|
40
|
+
const id = useId()
|
|
41
|
+
const [busy, setBusy] = useState(false)
|
|
42
|
+
|
|
43
|
+
function onFile(file: File | undefined) {
|
|
44
|
+
if (!file) return
|
|
45
|
+
if (!accept.includes(file.type)) return onError?.('invalidType')
|
|
46
|
+
if (file.size > MAX_BYTES) return onError?.('tooLarge')
|
|
47
|
+
setBusy(true)
|
|
48
|
+
const reader = new FileReader()
|
|
49
|
+
reader.onload = () => {
|
|
50
|
+
onChange(String(reader.result))
|
|
51
|
+
setBusy(false)
|
|
52
|
+
}
|
|
53
|
+
reader.onerror = () => setBusy(false)
|
|
54
|
+
reader.readAsDataURL(file)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className={cn('flex items-center gap-4', className)}>
|
|
59
|
+
{preview(value)}
|
|
60
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
61
|
+
<input
|
|
62
|
+
ref={input}
|
|
63
|
+
id={id}
|
|
64
|
+
type="file"
|
|
65
|
+
accept={accept.join(',')}
|
|
66
|
+
className="sr-only"
|
|
67
|
+
tabIndex={-1}
|
|
68
|
+
disabled={disabled}
|
|
69
|
+
onChange={(e) => {
|
|
70
|
+
onFile(e.target.files?.[0])
|
|
71
|
+
e.target.value = ''
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
<Button type="button" variant="outline" size="sm" disabled={disabled || busy} aria-label={label} onClick={() => input.current?.click()}>
|
|
75
|
+
<Upload data-icon="inline-start" />
|
|
76
|
+
{t('actions.upload')}
|
|
77
|
+
</Button>
|
|
78
|
+
{value && (
|
|
79
|
+
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => onChange(undefined)}>
|
|
80
|
+
{t('actions.remove')}
|
|
81
|
+
</Button>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useAppName } from '@/core/company/use-app-name'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
|
|
6
|
+
type PageProps = {
|
|
7
|
+
title: ReactNode
|
|
8
|
+
/** Plain-text document title; defaults to `title` when it is a string. */
|
|
9
|
+
documentTitle?: string
|
|
10
|
+
description?: ReactNode
|
|
11
|
+
/** Buttons on the right of the title (primary action last). */
|
|
12
|
+
actions?: ReactNode
|
|
13
|
+
/** Optional element left of the title, e.g. an avatar or icon tile. */
|
|
14
|
+
leading?: ReactNode
|
|
15
|
+
children?: ReactNode
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Standard page frame inside the content panel: title row + body. */
|
|
20
|
+
export function Page({ title, documentTitle, description, actions, leading, children, className }: PageProps) {
|
|
21
|
+
const docTitle = documentTitle ?? (typeof title === 'string' ? title : undefined)
|
|
22
|
+
const appName = useAppName()
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
document.title = docTitle ? `${docTitle} · ${appName}` : appName
|
|
26
|
+
}, [docTitle, appName])
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className={cn('mx-auto flex w-full max-w-(--page-max-width) flex-col gap-6', className)}>
|
|
30
|
+
<header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
31
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
32
|
+
{leading}
|
|
33
|
+
<div className="min-w-0">
|
|
34
|
+
<h1 className="truncate text-2xl font-semibold tracking-tight">{title}</h1>
|
|
35
|
+
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
|
39
|
+
</header>
|
|
40
|
+
{children}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Bordered section card with a small uppercase label header, as used across pages. */
|
|
46
|
+
export function Panel({
|
|
47
|
+
label,
|
|
48
|
+
actions,
|
|
49
|
+
children,
|
|
50
|
+
className,
|
|
51
|
+
bodyClassName,
|
|
52
|
+
}: {
|
|
53
|
+
label?: ReactNode
|
|
54
|
+
actions?: ReactNode
|
|
55
|
+
children: ReactNode
|
|
56
|
+
className?: string
|
|
57
|
+
bodyClassName?: string
|
|
58
|
+
}) {
|
|
59
|
+
return (
|
|
60
|
+
<section
|
|
61
|
+
className={cn(
|
|
62
|
+
'flex min-w-0 flex-col overflow-hidden rounded-xl border bg-card text-card-foreground shadow-(--shadow-card)',
|
|
63
|
+
className,
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{(label || actions) && (
|
|
67
|
+
<div className="flex min-h-11 items-center justify-between gap-3 border-b px-4 py-2">
|
|
68
|
+
<h2 className="label-caps">{label}</h2>
|
|
69
|
+
{actions && <div className="flex items-center gap-1">{actions}</div>}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
<div className={cn('flex-1', bodyClassName)}>{children}</div>
|
|
73
|
+
</section>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useMemo, type ComponentProps } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
|
|
4
|
+
import { Input } from '@/components/ui/input'
|
|
5
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
6
|
+
import { dialCode, phoneCountries } from '@/core/lib/phone'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Country calling code (localized country names) + national number.
|
|
10
|
+
* Validate with isValidPhone and store with toE164 (core/lib/phone).
|
|
11
|
+
* <PhoneInput id="phone" country={c} onCountryChange={…} invalid={…} inputProps={register('phoneNumber')} />
|
|
12
|
+
*/
|
|
13
|
+
export function PhoneInput({
|
|
14
|
+
id,
|
|
15
|
+
country,
|
|
16
|
+
onCountryChange,
|
|
17
|
+
invalid,
|
|
18
|
+
inputProps,
|
|
19
|
+
countryLabel,
|
|
20
|
+
}: {
|
|
21
|
+
id: string
|
|
22
|
+
country: string
|
|
23
|
+
onCountryChange: (code: string) => void
|
|
24
|
+
invalid?: boolean
|
|
25
|
+
inputProps?: ComponentProps<'input'>
|
|
26
|
+
/** Accessible name of the country select. */
|
|
27
|
+
countryLabel: string
|
|
28
|
+
}) {
|
|
29
|
+
const { i18n } = useTranslation()
|
|
30
|
+
const regionNames = useMemo(() => new Intl.DisplayNames([i18n.language], { type: 'region' }), [i18n.language])
|
|
31
|
+
const items = phoneCountries.map((c) => ({ value: c.code, label: `${c.dial} ${regionNames.of(c.code) ?? c.code}` }))
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex gap-2">
|
|
35
|
+
<Select items={items} value={country} onValueChange={(v) => v && onCountryChange(v)}>
|
|
36
|
+
<SelectTrigger aria-label={countryLabel} className="w-28 shrink-0 tabular-nums">
|
|
37
|
+
<SelectValue>{(v: string) => dialCode(v)}</SelectValue>
|
|
38
|
+
</SelectTrigger>
|
|
39
|
+
<SelectContent>
|
|
40
|
+
{items.map((c) => (
|
|
41
|
+
<SelectItem key={c.value} value={c.value}>
|
|
42
|
+
{c.label}
|
|
43
|
+
</SelectItem>
|
|
44
|
+
))}
|
|
45
|
+
</SelectContent>
|
|
46
|
+
</Select>
|
|
47
|
+
<Input
|
|
48
|
+
id={id}
|
|
49
|
+
type="tel"
|
|
50
|
+
inputMode="tel"
|
|
51
|
+
autoComplete="tel-national"
|
|
52
|
+
aria-invalid={invalid}
|
|
53
|
+
className="min-w-0 flex-1"
|
|
54
|
+
{...inputProps}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Combobox,
|
|
5
|
+
ComboboxContent,
|
|
6
|
+
ComboboxEmpty,
|
|
7
|
+
ComboboxInput,
|
|
8
|
+
ComboboxItem,
|
|
9
|
+
ComboboxList,
|
|
10
|
+
} from '@/components/ui/combobox'
|
|
11
|
+
|
|
12
|
+
export type SearchSelectOption = { value: string; label: string }
|
|
13
|
+
|
|
14
|
+
/** Single choice from a long list (employees, assets…) with type-to-search. For short lists use `Select`. */
|
|
15
|
+
export function SearchSelect({
|
|
16
|
+
id,
|
|
17
|
+
value,
|
|
18
|
+
onChange,
|
|
19
|
+
options,
|
|
20
|
+
placeholder,
|
|
21
|
+
disabled,
|
|
22
|
+
invalid,
|
|
23
|
+
}: {
|
|
24
|
+
id?: string
|
|
25
|
+
value: string | undefined
|
|
26
|
+
onChange: (value: string | undefined) => void
|
|
27
|
+
options: SearchSelectOption[]
|
|
28
|
+
placeholder?: string
|
|
29
|
+
disabled?: boolean
|
|
30
|
+
invalid?: boolean
|
|
31
|
+
}) {
|
|
32
|
+
const { t } = useTranslation()
|
|
33
|
+
const selected = options.find((o) => o.value === value) ?? null
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Combobox
|
|
37
|
+
items={options}
|
|
38
|
+
value={selected}
|
|
39
|
+
disabled={disabled}
|
|
40
|
+
onValueChange={(option: SearchSelectOption | null) => onChange(option?.value)}
|
|
41
|
+
itemToStringLabel={(option: SearchSelectOption) => option.label}
|
|
42
|
+
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
|
|
43
|
+
>
|
|
44
|
+
<ComboboxInput id={id} placeholder={placeholder ?? t('fields.search')} aria-invalid={invalid} disabled={disabled} className="w-full" />
|
|
45
|
+
<ComboboxContent>
|
|
46
|
+
<ComboboxEmpty>{t('table.noResults')}</ComboboxEmpty>
|
|
47
|
+
<ComboboxList>
|
|
48
|
+
{(option: SearchSelectOption) => (
|
|
49
|
+
<ComboboxItem key={option.value} value={option}>
|
|
50
|
+
{option.label}
|
|
51
|
+
</ComboboxItem>
|
|
52
|
+
)}
|
|
53
|
+
</ComboboxList>
|
|
54
|
+
</ComboboxContent>
|
|
55
|
+
</Combobox>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
|
|
4
|
+
/** iOS-style segmented control built on SortUI ToggleGroup (single value, never empty). */
|
|
5
|
+
export function Segmented<T extends string>({
|
|
6
|
+
value,
|
|
7
|
+
onChange,
|
|
8
|
+
options,
|
|
9
|
+
label,
|
|
10
|
+
className,
|
|
11
|
+
}: {
|
|
12
|
+
value: T
|
|
13
|
+
onChange: (value: T) => void
|
|
14
|
+
options: readonly { value: T; label: string }[]
|
|
15
|
+
label: string
|
|
16
|
+
className?: string
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<ToggleGroup
|
|
20
|
+
aria-label={label}
|
|
21
|
+
value={[value]}
|
|
22
|
+
onValueChange={(next) => {
|
|
23
|
+
const v = next[0] as T | undefined
|
|
24
|
+
if (v) onChange(v)
|
|
25
|
+
}}
|
|
26
|
+
spacing={0}
|
|
27
|
+
className={cn('rounded-lg bg-fill-2 p-0.5', className)}
|
|
28
|
+
>
|
|
29
|
+
{options.map((o) => (
|
|
30
|
+
<ToggleGroupItem
|
|
31
|
+
key={o.value}
|
|
32
|
+
value={o.value}
|
|
33
|
+
size="sm"
|
|
34
|
+
className="h-6 rounded-md px-2.5 text-xs text-muted-foreground aria-pressed:bg-raised aria-pressed:text-foreground aria-pressed:shadow-(--shadow-raised) hover:bg-transparent"
|
|
35
|
+
>
|
|
36
|
+
{o.label}
|
|
37
|
+
</ToggleGroupItem>
|
|
38
|
+
))}
|
|
39
|
+
</ToggleGroup>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { AlertTriangle, Inbox, RotateCw, SearchX } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
|
7
|
+
import { Spinner } from '@/components/ui/spinner'
|
|
8
|
+
import { ApiError } from '@/core/api/http'
|
|
9
|
+
import { cn } from '@/lib/utils'
|
|
10
|
+
|
|
11
|
+
export function LoadingState({ label, className }: { label?: string; className?: string }) {
|
|
12
|
+
const { t } = useTranslation()
|
|
13
|
+
return (
|
|
14
|
+
<div role="status" className={cn('flex flex-col items-center justify-center gap-3 py-16 text-sm text-muted-foreground', className)}>
|
|
15
|
+
<Spinner className="size-5" />
|
|
16
|
+
<span>{label ?? t('states.loading')}</span>
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function EmptyState({
|
|
22
|
+
title,
|
|
23
|
+
description,
|
|
24
|
+
icon,
|
|
25
|
+
action,
|
|
26
|
+
className,
|
|
27
|
+
}: {
|
|
28
|
+
title?: string
|
|
29
|
+
description?: ReactNode
|
|
30
|
+
icon?: ReactNode
|
|
31
|
+
action?: ReactNode
|
|
32
|
+
className?: string
|
|
33
|
+
}) {
|
|
34
|
+
const { t } = useTranslation()
|
|
35
|
+
return (
|
|
36
|
+
<Empty className={cn('py-16', className)}>
|
|
37
|
+
<EmptyHeader>
|
|
38
|
+
<EmptyMedia variant="icon">{icon ?? <Inbox />}</EmptyMedia>
|
|
39
|
+
<EmptyTitle>{title ?? t('states.emptyTitle')}</EmptyTitle>
|
|
40
|
+
{description && <EmptyDescription>{description}</EmptyDescription>}
|
|
41
|
+
</EmptyHeader>
|
|
42
|
+
{action && <EmptyContent>{action}</EmptyContent>}
|
|
43
|
+
</Empty>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Query/route error. 404s from the API render as "not found" without a retry. */
|
|
48
|
+
export function ErrorState({
|
|
49
|
+
title,
|
|
50
|
+
error,
|
|
51
|
+
onRetry,
|
|
52
|
+
notFoundAction,
|
|
53
|
+
className,
|
|
54
|
+
}: {
|
|
55
|
+
title?: string
|
|
56
|
+
error?: unknown
|
|
57
|
+
onRetry?: () => void
|
|
58
|
+
/** Shown instead of "Try again" when the error is a 404, e.g. a link back to the list. */
|
|
59
|
+
notFoundAction?: ReactNode
|
|
60
|
+
className?: string
|
|
61
|
+
}) {
|
|
62
|
+
const { t } = useTranslation()
|
|
63
|
+
|
|
64
|
+
if (error instanceof ApiError && error.status === 404) {
|
|
65
|
+
return (
|
|
66
|
+
<EmptyState
|
|
67
|
+
title={t('states.notFoundTitle')}
|
|
68
|
+
description={t('states.notFoundDescription')}
|
|
69
|
+
icon={<SearchX />}
|
|
70
|
+
action={notFoundAction}
|
|
71
|
+
className={className}
|
|
72
|
+
/>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// API messages come from the server (already localized by a real backend).
|
|
77
|
+
const message = error instanceof Error ? error.message : t('states.errorDescription')
|
|
78
|
+
return (
|
|
79
|
+
<Empty className={cn('py-16', className)}>
|
|
80
|
+
<EmptyHeader>
|
|
81
|
+
<EmptyMedia variant="icon" className="text-danger">
|
|
82
|
+
<AlertTriangle />
|
|
83
|
+
</EmptyMedia>
|
|
84
|
+
<EmptyTitle>{title ?? t('states.errorTitle')}</EmptyTitle>
|
|
85
|
+
<EmptyDescription>{message}</EmptyDescription>
|
|
86
|
+
</EmptyHeader>
|
|
87
|
+
{onRetry && (
|
|
88
|
+
<EmptyContent>
|
|
89
|
+
<Button variant="outline" onClick={onRetry}>
|
|
90
|
+
<RotateCw data-icon="inline-start" />
|
|
91
|
+
{t('actions.retry')}
|
|
92
|
+
</Button>
|
|
93
|
+
</EmptyContent>
|
|
94
|
+
)}
|
|
95
|
+
</Empty>
|
|
96
|
+
)
|
|
97
|
+
}
|