create-freakpunch 0.1.0 → 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 (79) hide show
  1. package/README.md +24 -23
  2. package/bin/create-freakpunch.mjs +6 -6
  3. package/files/README.md +19 -15
  4. package/files/app.config.ts +50 -58
  5. package/files/locales.ts +10 -0
  6. package/files/registry.ts +17 -0
  7. package/package.json +23 -23
  8. package/template/BACKEND.md +108 -86
  9. package/template/CLAUDE.md +15 -10
  10. package/template/design/COMPONENTS.md +1 -1
  11. package/template/design/DESIGN.md +3 -3
  12. package/template/design/PATTERNS.md +5 -5
  13. package/template/index.html +1 -1
  14. package/template/src/app.config.ts +50 -61
  15. package/template/src/app.roles.ts +24 -24
  16. package/template/src/core/api/demo.ts +8 -0
  17. package/template/src/core/audit/audit.service.ts +3 -34
  18. package/template/src/core/auth/auth.service.ts +25 -14
  19. package/template/src/core/auth/local-credentials.ts +46 -0
  20. package/template/src/core/auth/permissions.ts +1 -1
  21. package/template/src/core/company/company.service.ts +14 -12
  22. package/template/src/core/company/use-app-name.ts +7 -0
  23. package/template/src/core/components/page.tsx +4 -3
  24. package/template/src/core/config/runtime.ts +16 -14
  25. package/template/src/core/config/types.ts +3 -2
  26. package/template/src/core/layout/app-sidebar.tsx +19 -2
  27. package/template/src/core/notifications/notifications.service.ts +10 -115
  28. package/template/src/core/notifications/types.ts +5 -0
  29. package/template/src/core/setup/setup-gate.tsx +20 -0
  30. package/template/src/core/setup/setup.service.ts +101 -0
  31. package/template/src/core/users/users.service.ts +2 -2
  32. package/template/src/locales/en/auth.ts +2 -1
  33. package/template/src/locales/en/common.ts +1 -0
  34. package/template/src/locales/en/company.ts +3 -1
  35. package/template/src/locales/en/dashboard.ts +5 -0
  36. package/template/src/locales/en/setup.ts +45 -0
  37. package/template/src/locales/index.ts +4 -4
  38. package/template/src/locales/tr/auth.ts +2 -1
  39. package/template/src/locales/tr/common.ts +1 -0
  40. package/template/src/locales/tr/company.ts +3 -1
  41. package/template/src/locales/tr/dashboard.ts +5 -0
  42. package/template/src/locales/tr/setup.ts +49 -0
  43. package/template/src/modules/locales.ts +0 -4
  44. package/template/src/modules/registry.ts +17 -19
  45. package/template/src/pages/dashboard/dashboard-page.tsx +28 -243
  46. package/template/src/pages/dashboard/quick-actions.tsx +37 -0
  47. package/template/src/pages/login-page.tsx +26 -14
  48. package/template/src/pages/not-found-page.tsx +1 -2
  49. package/template/src/pages/settings/account/account.service.ts +22 -41
  50. package/template/src/pages/settings/account/notifications-page.tsx +3 -3
  51. package/template/src/pages/settings/company/general-page.tsx +6 -4
  52. package/template/src/pages/settings/company/integrations/integrations.service.ts +8 -28
  53. package/template/src/pages/settings/company/modules-page.tsx +10 -2
  54. package/template/src/pages/settings/company/roles-page.tsx +2 -1
  55. package/template/src/pages/setup-page.tsx +387 -0
  56. package/template/src/router.tsx +9 -4
  57. package/template/tools/new-module.mjs +90 -90
  58. package/template/tools/templates/module/index.ts.tpl +44 -37
  59. package/template/tools/templates/module/locales/en.ts.tpl +51 -45
  60. package/template/tools/templates/module/locales/tr.ts.tpl +55 -49
  61. package/template/{src/modules/products/pages/product-detail-page.tsx → tools/templates/module/pages/__id__-detail-page.tsx.tpl} +103 -108
  62. package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -121
  63. package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -237
  64. package/template/vite.config.ts +6 -3
  65. package/template/src/core/users/users.mock.ts +0 -24
  66. package/template/src/locales/en/landing.ts +0 -15
  67. package/template/src/locales/tr/landing.ts +0 -19
  68. package/template/src/modules/products/api/products.mock.ts +0 -46
  69. package/template/src/modules/products/api/products.service.ts +0 -84
  70. package/template/src/modules/products/components/product-status.tsx +0 -21
  71. package/template/src/modules/products/hooks.ts +0 -73
  72. package/template/src/modules/products/index.ts +0 -50
  73. package/template/src/modules/products/locales/en.ts +0 -78
  74. package/template/src/modules/products/locales/tr.ts +0 -82
  75. package/template/src/modules/products/pages/product-form-page.tsx +0 -211
  76. package/template/src/modules/products/pages/products-list-page.tsx +0 -286
  77. package/template/src/modules/products/types.ts +0 -61
  78. package/template/src/pages/dashboard/dashboard.service.ts +0 -92
  79. package/template/src/pages/landing-page.tsx +0 -79
@@ -1,37 +1,44 @@
1
- import { Blocks, Plus } from 'lucide-react'
2
-
3
- import { lazyPage } from '@/core/modules/lazy-page'
4
- import type { AppModule } from '@/core/modules/types'
5
-
6
- export const __id__Module: AppModule = {
7
- id: '__id__',
8
- nav: [
9
- {
10
- label: '__id__:nav',
11
- path: '/app/__id__',
12
- icon: Blocks,
13
- group: '__group__',
14
- permission: '__id__.view',
15
- },
16
- ],
17
- quickActions: [
18
- {
19
- label: '__id__:quickActions.new',
20
- description: '__id__:quickActions.newDescription',
21
- path: '/app/__id__/new',
22
- icon: Plus,
23
- permission: '__id__.create',
24
- },
25
- ],
26
- routes: [
27
- {
28
- path: '__id__',
29
- handle: { crumb: '__id__:crumbs.list', permission: '__id__.view' },
30
- children: [
31
- { index: true, lazy: lazyPage(() => import('./pages/__id__-list-page')) },
32
- { path: 'new', handle: { crumb: '__id__:crumbs.new', permission: '__id__.create' }, lazy: lazyPage(() => import('./pages/__id__-form-page')) },
33
- { path: ':id/edit', handle: { crumb: '__id__:crumbs.edit', permission: '__id__.edit' }, lazy: lazyPage(() => import('./pages/__id__-form-page')) },
34
- ],
35
- },
36
- ],
37
- }
1
+ import { Blocks, Plus } from 'lucide-react'
2
+
3
+ import { lazyPage } from '@/core/modules/lazy-page'
4
+ import type { AppModule } from '@/core/modules/types'
5
+
6
+ export const __id__Module: AppModule = {
7
+ id: '__id__',
8
+ nav: [
9
+ {
10
+ label: '__id__:nav',
11
+ path: '/app/__id__',
12
+ icon: Blocks,
13
+ group: '__group__',
14
+ permission: '__id__.view',
15
+ },
16
+ ],
17
+ quickActions: [
18
+ {
19
+ label: '__id__:quickActions.new',
20
+ description: '__id__:quickActions.newDescription',
21
+ path: '/app/__id__/new',
22
+ icon: Plus,
23
+ permission: '__id__.create',
24
+ },
25
+ ],
26
+ routes: [
27
+ {
28
+ path: '__id__',
29
+ handle: { crumb: '__id__:crumbs.list', permission: '__id__.view' },
30
+ children: [
31
+ { index: true, lazy: lazyPage(() => import('./pages/__id__-list-page')) },
32
+ { path: 'new', handle: { crumb: '__id__:crumbs.new', permission: '__id__.create' }, lazy: lazyPage(() => import('./pages/__id__-form-page')) },
33
+ {
34
+ path: ':id',
35
+ handle: { crumb: '__id__:crumbs.details' },
36
+ children: [
37
+ { index: true, lazy: lazyPage(() => import('./pages/__id__-detail-page')) },
38
+ { path: 'edit', handle: { crumb: '__id__:crumbs.edit', permission: '__id__.edit' }, lazy: lazyPage(() => import('./pages/__id__-form-page')) },
39
+ ],
40
+ },
41
+ ],
42
+ },
43
+ ],
44
+ }
@@ -1,45 +1,51 @@
1
- /** __Name__ module strings (namespace `__id__`). */
2
- export default {
3
- nav: '__labelEn__',
4
- crumbs: { list: '__labelEn__', new: 'New', edit: 'Edit' },
5
- quickActions: { new: 'New record', newDescription: 'Add to __labelEn__' },
6
- noun: 'records',
7
- status: { active: 'Active', draft: 'Draft', archived: 'Archived' },
8
- list: {
9
- title: '__labelEn__',
10
- description: 'Manage your records.',
11
- new: 'New record',
12
- export: 'Export CSV',
13
- search: 'Search…',
14
- filterStatus: 'Status',
15
- allStatuses: 'All statuses',
16
- columns: { name: 'Name', status: 'Status', updated: 'Updated' },
17
- noMatchesTitle: 'No matching records',
18
- noMatchesDescription: 'Try a different search or clear the filters.',
19
- emptyTitle: 'No records yet',
20
- emptyDescription: 'Create your first record to get started.',
21
- },
22
- delete: {
23
- title: 'Delete “{{name}}”?',
24
- description: 'This cannot be undone.',
25
- deleting: 'Deleting…',
26
- deleted: '{{name}} deleted',
27
- bulkTitle_one: 'Delete {{count}} record?',
28
- bulkTitle_other: 'Delete {{count}} records?',
29
- bulkDeleted_one: '{{count}} record deleted',
30
- bulkDeleted_other: '{{count}} records deleted',
31
- },
32
- form: {
33
- newTitle: 'New record',
34
- editTitle: 'Edit {{name}}',
35
- newDescription: 'Add a record.',
36
- editDescription: 'Update this record.',
37
- create: 'Create record',
38
- created: 'Record created',
39
- saved: 'Changes saved',
40
- saveFailed: 'Could not save the record',
41
- back: 'Back to the list',
42
- sections: { details: 'Details', detailsDescription: 'The main information about this record.' },
43
- fields: { name: 'Name', status: 'Status', notes: 'Notes', notesHint: 'Up to 500 characters.' },
44
- },
45
- }
1
+ /** __Name__ module strings (namespace `__id__`). */
2
+ export default {
3
+ nav: '__labelEn__',
4
+ crumbs: { list: '__labelEn__', new: 'New', details: 'Details', edit: 'Edit' },
5
+ quickActions: { new: 'New record', newDescription: 'Add to __labelEn__' },
6
+ noun: 'records',
7
+ status: { active: 'Active', draft: 'Draft', archived: 'Archived' },
8
+ list: {
9
+ title: '__labelEn__',
10
+ description: 'Manage your records.',
11
+ new: 'New record',
12
+ export: 'Export CSV',
13
+ search: 'Search…',
14
+ filterStatus: 'Status',
15
+ allStatuses: 'All statuses',
16
+ columns: { name: 'Name', status: 'Status', updated: 'Updated' },
17
+ noMatchesTitle: 'No matching records',
18
+ noMatchesDescription: 'Try a different search or clear the filters.',
19
+ emptyTitle: 'No records yet',
20
+ emptyDescription: 'Create your first record to get started.',
21
+ },
22
+ delete: {
23
+ title: 'Delete “{{name}}”?',
24
+ description: 'This cannot be undone.',
25
+ deleting: 'Deleting…',
26
+ deleted: '{{name}} deleted',
27
+ bulkTitle_one: 'Delete {{count}} record?',
28
+ bulkTitle_other: 'Delete {{count}} records?',
29
+ bulkDeleted_one: '{{count}} record deleted',
30
+ bulkDeleted_other: '{{count}} records deleted',
31
+ },
32
+ detail: {
33
+ overview: 'Overview',
34
+ summary: 'Summary',
35
+ noNotes: 'No notes.',
36
+ fields: { status: 'Status', created: 'Created', updated: 'Updated' },
37
+ },
38
+ form: {
39
+ newTitle: 'New record',
40
+ editTitle: 'Edit {{name}}',
41
+ newDescription: 'Add a record.',
42
+ editDescription: 'Update this record.',
43
+ create: 'Create record',
44
+ created: 'Record created',
45
+ saved: 'Changes saved',
46
+ saveFailed: 'Could not save the record',
47
+ back: 'Back to the list',
48
+ sections: { details: 'Details', detailsDescription: 'The main information about this record.' },
49
+ fields: { name: 'Name', status: 'Status', notes: 'Notes', notesHint: 'Up to 500 characters.' },
50
+ },
51
+ }
@@ -1,49 +1,55 @@
1
- import type { Messages } from '@/core/i18n/types'
2
- import type en from './en'
3
-
4
- const tr: Messages<typeof en> = {
5
- nav: '__labelTr__',
6
- crumbs: { list: '__labelTr__', new: 'Yeni', edit: 'Düzenle' },
7
- quickActions: { new: 'Yeni kayıt', newDescription: 'Kayıt ekleyin' },
8
- noun: 'kayıt',
9
- status: { active: 'Aktif', draft: 'Taslak', archived: 'Arşivlendi' },
10
- list: {
11
- title: '__labelTr__',
12
- description: 'Kayıtlarınızı yönetin.',
13
- new: 'Yeni kayıt',
14
- export: 'CSV olarak dışa aktar',
15
- search: 'Ara…',
16
- filterStatus: 'Durum',
17
- allStatuses: 'Tüm durumlar',
18
- columns: { name: 'Ad', status: 'Durum', updated: 'Güncellenme' },
19
- noMatchesTitle: 'Eşleşen kayıt yok',
20
- noMatchesDescription: 'Farklı bir arama deneyin veya filtreleri temizleyin.',
21
- emptyTitle: 'Henüz kayıt yok',
22
- emptyDescription: 'Başlamak için ilk kaydınızı oluşturun.',
23
- },
24
- delete: {
25
- title: '“{{name}}” silinsin mi?',
26
- description: 'Bu işlem geri alınamaz.',
27
- deleting: 'Siliniyor…',
28
- deleted: '{{name}} silindi',
29
- bulkTitle_one: '{{count}} kayıt silinsin mi?',
30
- bulkTitle_other: '{{count}} kayıt silinsin mi?',
31
- bulkDeleted_one: '{{count}} kayıt silindi',
32
- bulkDeleted_other: '{{count}} kayıt silindi',
33
- },
34
- form: {
35
- newTitle: 'Yeni kayıt',
36
- editTitle: '{{name}} kaydını düzenle',
37
- newDescription: 'Kayıt ekleyin.',
38
- editDescription: 'Bu kaydı güncelleyin.',
39
- create: 'Kayıt oluştur',
40
- created: 'Kayıt oluşturuldu',
41
- saved: 'Değişiklikler kaydedildi',
42
- saveFailed: 'Kayıt kaydedilemedi',
43
- back: 'Listeye dön',
44
- sections: { details: 'Ayrıntılar', detailsDescription: 'Bu kaydın temel bilgileri.' },
45
- fields: { name: 'Ad', status: 'Durum', notes: 'Notlar', notesHint: 'En fazla 500 karakter.' },
46
- },
47
- }
48
-
49
- export default tr
1
+ import type { Messages } from '@/core/i18n/types'
2
+ import type en from './en'
3
+
4
+ const tr: Messages<typeof en> = {
5
+ nav: '__labelTr__',
6
+ crumbs: { list: '__labelTr__', new: 'Yeni', details: 'Ayrıntılar', edit: 'Düzenle' },
7
+ quickActions: { new: 'Yeni kayıt', newDescription: 'Kayıt ekleyin' },
8
+ noun: 'kayıt',
9
+ status: { active: 'Aktif', draft: 'Taslak', archived: 'Arşivlendi' },
10
+ list: {
11
+ title: '__labelTr__',
12
+ description: 'Kayıtlarınızı yönetin.',
13
+ new: 'Yeni kayıt',
14
+ export: 'CSV olarak dışa aktar',
15
+ search: 'Ara…',
16
+ filterStatus: 'Durum',
17
+ allStatuses: 'Tüm durumlar',
18
+ columns: { name: 'Ad', status: 'Durum', updated: 'Güncellenme' },
19
+ noMatchesTitle: 'Eşleşen kayıt yok',
20
+ noMatchesDescription: 'Farklı bir arama deneyin veya filtreleri temizleyin.',
21
+ emptyTitle: 'Henüz kayıt yok',
22
+ emptyDescription: 'Başlamak için ilk kaydınızı oluşturun.',
23
+ },
24
+ delete: {
25
+ title: '“{{name}}” silinsin mi?',
26
+ description: 'Bu işlem geri alınamaz.',
27
+ deleting: 'Siliniyor…',
28
+ deleted: '{{name}} silindi',
29
+ bulkTitle_one: '{{count}} kayıt silinsin mi?',
30
+ bulkTitle_other: '{{count}} kayıt silinsin mi?',
31
+ bulkDeleted_one: '{{count}} kayıt silindi',
32
+ bulkDeleted_other: '{{count}} kayıt silindi',
33
+ },
34
+ detail: {
35
+ overview: 'Genel bakış',
36
+ summary: 'Özet',
37
+ noNotes: 'Not yok.',
38
+ fields: { status: 'Durum', created: 'Oluşturulma', updated: 'Güncellenme' },
39
+ },
40
+ form: {
41
+ newTitle: 'Yeni kayıt',
42
+ editTitle: '{{name}} kaydını düzenle',
43
+ newDescription: 'Kayıt ekleyin.',
44
+ editDescription: 'Bu kaydı güncelleyin.',
45
+ create: 'Kayıt oluştur',
46
+ created: 'Kayıt oluşturuldu',
47
+ saved: 'Değişiklikler kaydedildi',
48
+ saveFailed: 'Kayıt kaydedilemedi',
49
+ back: 'Listeye dön',
50
+ sections: { details: 'Ayrıntılar', detailsDescription: 'Bu kaydın temel bilgileri.' },
51
+ fields: { name: 'Ad', status: 'Durum', notes: 'Notlar', notesHint: 'En fazla 500 karakter.' },
52
+ },
53
+ }
54
+
55
+ export default tr
@@ -1,108 +1,103 @@
1
- import type { ReactNode } from 'react'
2
- import { useTranslation } from 'react-i18next'
3
- import { useNavigate, useParams } from 'react-router'
4
- import { Package, Pencil, Trash2 } from 'lucide-react'
5
- import { toast } from 'sonner'
6
-
7
- import { Button } from '@/components/ui/button'
8
- import { Can } from '@/core/auth/can'
9
- import { ButtonLink } from '@/core/components/button-link'
10
- import { useConfirm } from '@/core/components/confirm-dialog'
11
- import { Page, Panel } from '@/core/components/page'
12
- import { ErrorState, LoadingState } from '@/core/components/states'
13
- import { useFormat } from '@/core/i18n/use-format'
14
- import { ProductStatusBadge, StockLevel } from '../components/product-status'
15
- import { useDeleteProduct, useProduct } from '../hooks'
16
-
17
- function BackToList() {
18
- const { t } = useTranslation(['products', 'common'])
19
- return (
20
- <ButtonLink to="/app/products" variant="outline">
21
- {t('detail.back')}
22
- </ButtonLink>
23
- )
24
- }
25
-
26
- export default function ProductDetailPage() {
27
- const { id } = useParams()
28
- const query = useProduct(id)
29
- const remove = useDeleteProduct()
30
- const confirm = useConfirm()
31
- const navigate = useNavigate()
32
- const { t } = useTranslation(['products', 'common'])
33
- const fmt = useFormat()
34
-
35
- if (query.isPending) return <LoadingState />
36
- if (query.error) return <ErrorState error={query.error} onRetry={() => query.refetch()} notFoundAction={<BackToList />} />
37
- const product = query.data
38
-
39
- async function onDelete() {
40
- const ok = await confirm({
41
- title: t('delete.title', { name: product.name }),
42
- description: t('delete.description'),
43
- confirmLabel: t('common:actions.delete'),
44
- destructive: true,
45
- })
46
- if (!ok) return
47
- await remove.mutateAsync(product.id)
48
- toast.success(t('delete.deleted', { name: product.name }))
49
- navigate('/app/products', { replace: true })
50
- }
51
-
52
- return (
53
- <Page
54
- title={product.name}
55
- description={<span className="tabular-nums text-xs">{product.sku}</span>}
56
- leading={
57
- <div className="flex size-12 shrink-0 items-center justify-center rounded-xl border bg-fill-1 text-muted-foreground">
58
- <Package className="size-5" />
59
- </div>
60
- }
61
- actions={
62
- <>
63
- <Can permission="products.delete">
64
- <Button variant="destructive" onClick={onDelete} disabled={remove.isPending}>
65
- <Trash2 data-icon="inline-start" />
66
- {t('common:actions.delete')}
67
- </Button>
68
- </Can>
69
- <Can permission="products.edit">
70
- <ButtonLink to="edit">
71
- <Pencil data-icon="inline-start" />
72
- {t('common:actions.edit')}
73
- </ButtonLink>
74
- </Can>
75
- </>
76
- }
77
- >
78
- <div className="grid gap-4 lg:grid-cols-3">
79
- <Panel label={t('detail.overview')} className="lg:col-span-2" bodyClassName="p-5">
80
- <p className="text-sm leading-relaxed text-pretty">{product.description || t('detail.noDescription')}</p>
81
- </Panel>
82
- <Panel label={t('detail.summary')}>
83
- <dl className="divide-y text-sm">
84
- <Row label={t('detail.fields.status')}>
85
- <ProductStatusBadge status={product.status} />
86
- </Row>
87
- <Row label={t('detail.fields.price')}>{fmt.currency(product.price)}</Row>
88
- <Row label={t('detail.fields.stock')}>
89
- <StockLevel stock={product.stock} />
90
- </Row>
91
- <Row label={t('detail.fields.category')}>{t(`categories.${product.category}`)}</Row>
92
- <Row label={t('detail.fields.created')}>{fmt.date(product.createdAt)}</Row>
93
- <Row label={t('detail.fields.updated')}>{fmt.relative(product.updatedAt)}</Row>
94
- </dl>
95
- </Panel>
96
- </div>
97
- </Page>
98
- )
99
- }
100
-
101
- function Row({ label, children }: { label: string; children: ReactNode }) {
102
- return (
103
- <div className="flex items-center justify-between gap-4 px-4 py-2.5">
104
- <dt className="text-muted-foreground">{label}</dt>
105
- <dd className="text-right font-medium tabular-nums">{children}</dd>
106
- </div>
107
- )
108
- }
1
+ import type { ReactNode } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { useNavigate, useParams } from 'react-router'
4
+ import { Pencil, Trash2 } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+
7
+ import { Button } from '@/components/ui/button'
8
+ import { Can } from '@/core/auth/can'
9
+ import { ButtonLink } from '@/core/components/button-link'
10
+ import { useConfirm } from '@/core/components/confirm-dialog'
11
+ import { Page, Panel } from '@/core/components/page'
12
+ import { ErrorState, LoadingState } from '@/core/components/states'
13
+ import { StatusBadge, type StatusTone } from '@/core/components/status-badge'
14
+ import { useFormat } from '@/core/i18n/use-format'
15
+ import { useDelete__Name__, use__Name__Record } from '../hooks'
16
+ import type { __Name__Status } from '../types'
17
+
18
+ const tones: Record<__Name__Status, StatusTone> = { active: 'success', draft: 'neutral', archived: 'warning' }
19
+
20
+ export default function __Name__DetailPage() {
21
+ const { id } = useParams()
22
+ const query = use__Name__Record(id)
23
+ const remove = useDelete__Name__()
24
+ const confirm = useConfirm()
25
+ const navigate = useNavigate()
26
+ const { t } = useTranslation(['__id__', 'common'])
27
+ const fmt = useFormat()
28
+
29
+ if (query.isPending) return <LoadingState />
30
+ if (query.error) {
31
+ return (
32
+ <ErrorState
33
+ error={query.error}
34
+ onRetry={() => query.refetch()}
35
+ notFoundAction={
36
+ <ButtonLink to="/app/__id__" variant="outline">
37
+ {t('form.back')}
38
+ </ButtonLink>
39
+ }
40
+ />
41
+ )
42
+ }
43
+ const record = query.data
44
+
45
+ async function onDelete() {
46
+ const ok = await confirm({
47
+ title: t('delete.title', { name: record.name }),
48
+ description: t('delete.description'),
49
+ confirmLabel: t('common:actions.delete'),
50
+ destructive: true,
51
+ })
52
+ if (!ok) return
53
+ await remove.mutateAsync(record.id)
54
+ toast.success(t('delete.deleted', { name: record.name }))
55
+ navigate('/app/__id__', { replace: true })
56
+ }
57
+
58
+ return (
59
+ <Page
60
+ title={record.name}
61
+ actions={
62
+ <>
63
+ <Can permission="__id__.delete">
64
+ <Button variant="destructive" onClick={onDelete} disabled={remove.isPending}>
65
+ <Trash2 data-icon="inline-start" />
66
+ {t('common:actions.delete')}
67
+ </Button>
68
+ </Can>
69
+ <Can permission="__id__.edit">
70
+ <ButtonLink to="edit">
71
+ <Pencil data-icon="inline-start" />
72
+ {t('common:actions.edit')}
73
+ </ButtonLink>
74
+ </Can>
75
+ </>
76
+ }
77
+ >
78
+ <div className="grid gap-4 lg:grid-cols-3">
79
+ <Panel label={t('detail.overview')} className="lg:col-span-2" bodyClassName="p-5">
80
+ <p className="text-sm leading-relaxed text-pretty">{record.notes || t('detail.noNotes')}</p>
81
+ </Panel>
82
+ <Panel label={t('detail.summary')}>
83
+ <dl className="divide-y text-sm">
84
+ <Row label={t('detail.fields.status')}>
85
+ <StatusBadge tone={tones[record.status]}>{t(`status.${record.status}`)}</StatusBadge>
86
+ </Row>
87
+ <Row label={t('detail.fields.created')}>{fmt.date(record.createdAt)}</Row>
88
+ <Row label={t('detail.fields.updated')}>{fmt.relative(record.updatedAt)}</Row>
89
+ </dl>
90
+ </Panel>
91
+ </div>
92
+ </Page>
93
+ )
94
+ }
95
+
96
+ function Row({ label, children }: { label: string; children: ReactNode }) {
97
+ return (
98
+ <div className="flex items-center justify-between gap-4 px-4 py-2.5">
99
+ <dt className="text-muted-foreground">{label}</dt>
100
+ <dd className="text-right font-medium tabular-nums">{children}</dd>
101
+ </div>
102
+ )
103
+ }