@adula/ui 0.2.0-alpha.4 → 1.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.
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "record-workflows",
3
+ "type": "registry:ui",
4
+ "registryDependencies": [
5
+ "badge",
6
+ "button",
7
+ "label",
8
+ "textarea",
9
+ "dialog",
10
+ "ui-preferences",
11
+ "resource-value"
12
+ ],
13
+ "dependencies": [
14
+ "lucide-react@1.47.0",
15
+ "axios@1.19.0"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "registry/ui/record-workflows.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import { useCallback, useEffect, useState } from 'react'\nimport axios from 'axios'\nimport { CheckCircle2, GitBranch, XCircle } from 'lucide-react'\nimport type { WorkflowRun } from '@adula/kit'\nimport { Badge } from '~/components/ui/badge'\nimport { Button } from '~/components/ui/button'\nimport { Label } from '~/components/ui/label'\nimport { Textarea } from '~/components/ui/textarea'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '~/components/ui/dialog'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\nimport { formatDatetime } from '~/components/ui/resource-value'\n\nconst json = { headers: { Accept: 'application/json' }, withXSRFToken: true }\nexport const runStatusLabel: Record<string, string> = {\n pending_definition: 'بانتظار التعريف',\n running: 'قيد التنفيذ',\n waiting: 'بانتظار موافقة',\n completed: 'مكتمل',\n failed: 'فشل',\n cancelled: 'أُلغي',\n}\nconst outcomeLabel: Record<string, string> = {\n approved: 'معتمد',\n rejected: 'مرفوض',\n completed: 'مكتمل',\n no_workflow: 'بلا تدفق',\n}\nconst eventLabel: Record<string, string> = {\n started: 'بدأ التدفق',\n evaluate: 'تقييم شرط',\n approval_requested: 'طُلبت الموافقة',\n approved: 'موافقة',\n rejected: 'رفض',\n decided: 'قرار',\n done: 'نُفذت الخطوة',\n delay_started: 'بدأ الانتظار',\n retry_scheduled: 'جدولة إعادة المحاولة',\n failed: 'فشل',\n retried: 'إعادة تشغيل',\n migrated: 'ترحيل إلى إصدار أحدث',\n completed: 'اكتمل',\n cancelled: 'أُلغي',\n}\n\ntype DecisionChoice = NonNullable<\n NonNullable<WorkflowRun['myApproval']>['decision']\n>['outcomes'][number]\n\n// A plain approval step offers exactly these two decisions.\nconst approvalChoices: DecisionChoice[] = [\n { key: 'approve', label: 'موافقة', comment: 'optional' },\n { key: 'reject', label: 'رفض', comment: 'optional' },\n]\nconst dialogTitle: Record<string, string> = { approve: 'تأكيد الموافقة', reject: 'تأكيد الرفض' }\n\n/**\n * The approver's decision in a confirmation dialog: approve or reject, or at a decision\n * step one of its named outcomes (#42). A decision never edits the submitted document.\n */\nexport function WorkflowDecision({\n run,\n onDecided,\n}: {\n run: WorkflowRun\n onDecided: (run: WorkflowRun) => void\n}) {\n const [choice, setChoice] = useState<DecisionChoice | null>(null)\n const [comment, setComment] = useState('')\n const [error, setError] = useState('')\n const [busy, setBusy] = useState(false)\n if (!run.myApproval) return null\n const form = run.myApproval.decision\n const choices = form?.outcomes ?? approvalChoices\n const open = (next: DecisionChoice) => {\n setChoice(next)\n setComment('')\n setError('')\n }\n const submit = async () => {\n if (!choice) return\n if (choice.comment === 'required' && !comment.trim()) {\n setError('اكتب ملاحظة القرار.')\n return\n }\n setBusy(true)\n setError('')\n try {\n const response = await axios.post<{ data: WorkflowRun }>(\n `/workflows/${run.id}/decide`,\n { decision: choice.key, comment },\n json\n )\n setChoice(null)\n setComment('')\n onDecided(response.data.data)\n } catch (caught) {\n setError(\n axios.isAxiosError(caught) && caught.response?.data?.error?.message\n ? String(caught.response.data.error.message)\n : 'تعذر تسجيل القرار.'\n )\n } finally {\n setBusy(false)\n }\n }\n const rejecting = choice?.key === 'reject'\n return (\n <>\n <div className=\"flex flex-wrap gap-2\">\n {choices.map((item, index) => (\n <Button\n key={item.key}\n size=\"sm\"\n variant={index === 0 ? 'default' : 'outline'}\n className={item.key === 'reject' ? 'text-destructive' : undefined}\n onClick={() => open(item)}\n >\n {item.key === 'approve' && <CheckCircle2 size={15} />}\n {item.key === 'reject' && <XCircle size={15} />}\n {item.label}\n </Button>\n ))}\n </div>\n <Dialog open={choice !== null} onOpenChange={(next) => !next && !busy && setChoice(null)}>\n <DialogContent dir=\"rtl\">\n <DialogHeader>\n <DialogTitle>\n {choice && (form ? `تأكيد القرار: ${choice.label}` : dialogTitle[choice.key])}\n </DialogTitle>\n <DialogDescription>\n {run.myApproval.title} — {run.resourceLabel}:{' '}\n {run.recordTitle ?? `#${run.recordId}`}\n </DialogDescription>\n </DialogHeader>\n <div className=\"space-y-2\">\n <Label htmlFor={`decision-comment-${run.id}`} className=\"flex items-center gap-1\">\n {choice?.comment === 'required' ? (\n <>\n ملاحظة\n <span className=\"text-destructive\" aria-hidden=\"true\">\n *\n </span>\n </>\n ) : (\n 'ملاحظة (اختيارية)'\n )}\n </Label>\n <Textarea\n id={`decision-comment-${run.id}`}\n value={comment}\n maxLength={1000}\n required={choice?.comment === 'required'}\n onChange={(event) => setComment(event.target.value)}\n />\n </div>\n {error && (\n <p role=\"alert\" className=\"rounded-lg bg-red-50 px-3 py-2 text-xs text-red-800\">\n {error}\n </p>\n )}\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" disabled={busy} onClick={() => setChoice(null)}>\n تراجع\n </Button>\n <Button\n type=\"button\"\n variant={rejecting ? 'destructive' : 'default'}\n disabled={busy}\n onClick={() => void submit()}\n >\n {choice?.label}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n\n/** Workflow runs of one submittable record with their step history. */\nexport function RecordWorkflows({ resource, id }: { resource: string; id: number | string }) {\n const { calendar } = useUiPreferences()\n const [runs, setRuns] = useState<WorkflowRun[] | null>(null)\n const load = useCallback(async () => {\n try {\n const response = await axios.get<{ data: WorkflowRun[] }>(\n `/resources/${resource}/${id}/workflows`,\n json\n )\n setRuns(response.data.data)\n } catch {\n setRuns([])\n }\n }, [resource, id])\n useEffect(() => {\n void load()\n }, [load])\n if (runs !== null && runs.length === 0) return null\n return (\n <section aria-label=\"سير العمل\" className=\"rounded-xl border bg-white\">\n <div className=\"flex items-center gap-2 border-b px-7 py-4\">\n <GitBranch size={16} className=\"text-muted-foreground\" />\n <h2 className=\"text-sm font-semibold\">سير العمل</h2>\n </div>\n {runs === null ? (\n <p role=\"status\" className=\"px-7 py-5 text-xs text-muted-foreground\">\n جارٍ تحميل سير العمل…\n </p>\n ) : (\n <ul className=\"divide-y\">\n {runs.map((run) => (\n <li key={run.id} className=\"space-y-3 px-7 py-4\">\n <div className=\"flex flex-wrap items-center gap-2 text-sm\">\n <span className=\"font-medium\">{run.label}</span>\n <Badge variant={run.status === 'failed' ? 'destructive' : 'secondary'}>\n {runStatusLabel[run.status] ?? run.status}\n </Badge>\n {run.outcome && <Badge>{outcomeLabel[run.outcome] ?? run.outcome}</Badge>}\n {run.status === 'waiting' && run.stepLabel && (\n <span className=\"text-xs text-muted-foreground\">الخطوة: {run.stepLabel}</span>\n )}\n <span className=\"ms-auto\">\n <WorkflowDecision\n run={run}\n onDecided={() => {\n void load()\n }}\n />\n </span>\n </div>\n {run.lastError && (\n <p className=\"rounded-lg bg-red-50 px-3 py-2 text-xs text-red-800\">\n {run.lastError}\n </p>\n )}\n <ol className=\"space-y-1 border-s ps-4 text-xs\">\n {run.history.map((entry, index) => (\n <li key={index} className=\"flex flex-wrap gap-x-3\">\n <span className=\"font-medium\">{eventLabel[entry.event] ?? entry.event}</span>\n {entry.actorName && <span>{entry.actorName}</span>}\n {typeof entry.detail.comment === 'string' && (\n <span className=\"text-muted-foreground\">«{entry.detail.comment}»</span>\n )}\n <time dateTime={entry.at} className=\"text-muted-foreground tabular-nums\">\n {formatDatetime(entry.at, calendar)}\n </time>\n </li>\n ))}\n </ol>\n </li>\n ))}\n </ul>\n )}\n </section>\n )\n}\n",
22
+ "target": "inertia/components/ui/record-workflows.tsx"
23
+ }
24
+ ],
25
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json"
26
+ }
@@ -14,7 +14,7 @@
14
14
  {
15
15
  "path": "registry/ui/resource-actions.tsx",
16
16
  "type": "registry:ui",
17
- "content": "import { useState } from 'react'\nimport { router } from '@inertiajs/react'\nimport axios from 'axios'\nimport { toast } from 'sonner'\nimport type { RecordPermissions } from '@adula/kit'\nimport { Button } from '~/components/ui/button'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '~/components/ui/dialog'\nimport { Can } from '~/components/ui/can'\n\nexport type TransitionAction = 'delete' | 'submit' | 'cancel'\nexport const transitionLabels: Record<TransitionAction, string> = {\n delete: 'حذف السجل',\n submit: 'اعتماد المستند',\n cancel: 'إلغاء الاعتماد',\n}\nconst descriptions: Record<TransitionAction, string> = {\n delete: 'سيُخفى السجل من القوائم ولن يمكن تعديله بعد الحذف.',\n submit: 'بعد الاعتماد لا يمكن تعديل المستند أو بنوده.',\n cancel: 'سيُعاد المستند إلى حالة الإلغاء ولن يُحتسب.',\n}\n\nexport function failureMessage(error: unknown, fallback: string) {\n if (!axios.isAxiosError(error)) return 'تعذر الاتصال بالخادم. حاول مجدداً.'\n if (error.response?.status === 409 && error.response.data?.error?.code === 'E_VERSION_CONFLICT')\n return 'تغير السجل منذ فتحه. أعد تحميل الصفحة قبل المتابعة.'\n return error.response?.data?.error?.message ?? fallback\n}\n\nexport function isConflict(error: unknown) {\n return axios.isAxiosError(error) && error.response?.status === 409\n}\n\n/** Version travels with every transition so a stale page cannot delete or approve a changed record. */\nexport function ResourceActions({\n resource,\n id,\n version,\n permissions,\n label,\n size = 'default',\n variant = 'outline',\n onDone,\n}: {\n resource: string\n id: number | string\n version?: unknown\n permissions: RecordPermissions\n label?: string\n size?: 'default' | 'sm'\n variant?: 'outline' | 'ghost'\n onDone?: (action: TransitionAction) => void\n}) {\n const [pending, setPending] = useState<TransitionAction | null>(null)\n const [busy, setBusy] = useState(false)\n const [failure, setFailure] = useState('')\n const [conflict, setConflict] = useState(false)\n const base = `/resources/${resource}/${id}`\n const execute = async () => {\n if (!pending) return\n setBusy(true)\n setFailure('')\n try {\n await axios.request({\n method: pending === 'delete' ? 'delete' : 'post',\n url: pending === 'delete' ? base : `${base}/${pending}`,\n data: version === undefined ? {} : { version },\n headers: { Accept: 'application/json' },\n withXSRFToken: true,\n })\n toast.success(`تم ${transitionLabels[pending]}`)\n const done = pending\n setPending(null)\n if (onDone) onDone(done)\n else if (done === 'delete') router.visit(`/resources/${resource}`)\n else router.reload()\n } catch (error) {\n setConflict(isConflict(error))\n setFailure(failureMessage(error, 'تعذر تنفيذ الإجراء. تحقق من صلاحياتك وحاول مجدداً.'))\n } finally {\n setBusy(false)\n }\n }\n return (\n <>\n {(['submit', 'cancel', 'delete'] as const).map((action) => (\n <Can key={action} permissions={permissions} action={action}>\n <Button\n type=\"button\"\n variant={variant}\n size={size}\n className={action === 'delete' ? 'text-destructive' : undefined}\n aria-label={label ? `${transitionLabels[action]} ${label}` : undefined}\n onClick={() => {\n setPending(action)\n setFailure('')\n setConflict(false)\n }}\n >\n {transitionLabels[action]}\n </Button>\n </Can>\n ))}\n <Dialog open={pending !== null} onOpenChange={(open) => !open && !busy && setPending(null)}>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{pending ? transitionLabels[pending] : ''}</DialogTitle>\n <DialogDescription>\n {pending ? descriptions[pending] : ''}\n {label ? ` (${label})` : ''}\n </DialogDescription>\n </DialogHeader>\n {failure && (\n <div role=\"alert\" className=\"flex flex-wrap items-center gap-3 text-sm text-destructive\">\n {failure}\n {conflict && (\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => router.reload()}>\n إعادة التحميل\n </Button>\n )}\n </div>\n )}\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" disabled={busy} onClick={() => setPending(null)}>\n تراجع\n </Button>\n <Button\n type=\"button\"\n variant={pending === 'delete' ? 'destructive' : 'default'}\n disabled={busy || conflict}\n onClick={() => void execute()}\n >\n {busy ? 'جارٍ التنفيذ...' : 'تأكيد'}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n",
17
+ "content": "import { useState } from 'react'\nimport { router } from '@inertiajs/react'\nimport axios from 'axios'\nimport { toast } from 'sonner'\nimport type { RecordPermissions } from '@adula/kit'\nimport { Button } from '~/components/ui/button'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '~/components/ui/dialog'\nimport { Can } from '~/components/ui/can'\n\nexport type TransitionAction = 'delete' | 'submit' | 'cancel' | 'amend'\nexport const transitionLabels: Record<TransitionAction, string> = {\n delete: 'حذف السجل',\n submit: 'اعتماد المستند',\n cancel: 'إلغاء الاعتماد',\n amend: 'تعديل بالنسخ',\n}\nconst descriptions: Record<TransitionAction, string> = {\n delete: 'سيُخفى السجل من القوائم ولن يمكن تعديله بعد الحذف.',\n submit: 'بعد الاعتماد لا يمكن تعديل المستند أو بنوده.',\n cancel: 'سيُعاد المستند إلى حالة الإلغاء ولن يُحتسب.',\n amend: 'تُنشأ مسودة جديدة منسوخة من هذا المستند الملغي مع بنوده، ويبقى الأصل كما هو.',\n}\n\nexport function failureMessage(error: unknown, fallback: string) {\n if (!axios.isAxiosError(error)) return 'تعذر الاتصال بالخادم. حاول مجدداً.'\n if (error.response?.status === 409 && error.response.data?.error?.code === 'E_VERSION_CONFLICT')\n return 'تغير السجل منذ فتحه. أعد تحميل الصفحة قبل المتابعة.'\n return error.response?.data?.error?.message ?? fallback\n}\n\nexport function isConflict(error: unknown) {\n return axios.isAxiosError(error) && error.response?.status === 409\n}\n\n/** Version travels with every transition so a stale page cannot delete or approve a changed record. */\nexport function ResourceActions({\n resource,\n id,\n version,\n permissions,\n label,\n size = 'default',\n variant = 'outline',\n onDone,\n}: {\n resource: string\n id: number | string\n version?: unknown\n permissions: RecordPermissions\n label?: string\n size?: 'default' | 'sm'\n variant?: 'outline' | 'ghost'\n onDone?: (action: TransitionAction) => void\n}) {\n const [pending, setPending] = useState<TransitionAction | null>(null)\n const [busy, setBusy] = useState(false)\n const [failure, setFailure] = useState('')\n const [conflict, setConflict] = useState(false)\n const base = `/resources/${resource}/${id}`\n const execute = async () => {\n if (!pending) return\n setBusy(true)\n setFailure('')\n try {\n const response = await axios.request({\n method: pending === 'delete' ? 'delete' : 'post',\n url: pending === 'delete' ? base : `${base}/${pending}`,\n data: version === undefined ? {} : { version },\n headers: { Accept: 'application/json' },\n withXSRFToken: true,\n })\n toast.success(`تم ${transitionLabels[pending]}`)\n const done = pending\n setPending(null)\n // The amended copy is a new draft; continue editing it.\n if (done === 'amend') router.visit(`/resources/${resource}/${response.data.data.id}/edit`)\n else if (onDone) onDone(done)\n else if (done === 'delete') router.visit(`/resources/${resource}`)\n else router.reload()\n } catch (error) {\n setConflict(isConflict(error))\n setFailure(failureMessage(error, 'تعذر تنفيذ الإجراء. تحقق من صلاحياتك وحاول مجدداً.'))\n } finally {\n setBusy(false)\n }\n }\n return (\n <>\n {(['submit', 'cancel', 'amend', 'delete'] as const).map((action) => (\n <Can key={action} permissions={permissions} action={action}>\n <Button\n type=\"button\"\n variant={variant}\n size={size}\n className={action === 'delete' ? 'text-destructive' : undefined}\n aria-label={label ? `${transitionLabels[action]} ${label}` : undefined}\n onClick={() => {\n setPending(action)\n setFailure('')\n setConflict(false)\n }}\n >\n {transitionLabels[action]}\n </Button>\n </Can>\n ))}\n <Dialog open={pending !== null} onOpenChange={(open) => !open && !busy && setPending(null)}>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{pending ? transitionLabels[pending] : ''}</DialogTitle>\n <DialogDescription>\n {pending ? descriptions[pending] : ''}\n {label ? ` (${label})` : ''}\n </DialogDescription>\n </DialogHeader>\n {failure && (\n <div role=\"alert\" className=\"flex flex-wrap items-center gap-3 text-sm text-destructive\">\n {failure}\n {conflict && (\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => router.reload()}>\n إعادة التحميل\n </Button>\n )}\n </div>\n )}\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" disabled={busy} onClick={() => setPending(null)}>\n تراجع\n </Button>\n <Button\n type=\"button\"\n variant={pending === 'delete' ? 'destructive' : 'default'}\n disabled={busy || conflict}\n onClick={() => void execute()}\n >\n {busy ? 'جارٍ التنفيذ...' : 'تأكيد'}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n",
18
18
  "target": "inertia/components/ui/resource-actions.tsx"
19
19
  }
20
20
  ],
@@ -25,7 +25,7 @@
25
25
  {
26
26
  "path": "registry/ui/resource-field.tsx",
27
27
  "type": "registry:ui",
28
- "content": "import { useEffect, useId, useRef, useState, type ComponentProps } from 'react'\nimport { useHttp } from '@inertiajs/react'\nimport axios from 'axios'\nimport { format } from 'date-fns'\nimport {\n CalendarDays,\n Check,\n ChevronsUpDown,\n Paperclip,\n Plus,\n Trash2,\n Undo2,\n Upload,\n X,\n} from 'lucide-react'\nimport type { CalendarSystem, ResourceEditor, ResourceField as Field } from '@adula/kit'\nimport { Button } from '~/components/ui/button'\nimport { Calendar } from '~/components/ui/calendar'\nimport { HijriCalendar } from '~/components/ui/hijri-calendar'\nimport { calendarInput, calendarIso } from '~/components/ui/calendar_date'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from '~/components/ui/command'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Popover, PopoverContent, PopoverTrigger } from '~/components/ui/popover'\nimport { Switch } from '~/components/ui/switch'\nimport { Textarea } from '~/components/ui/textarea'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/components/ui/select'\nimport {\n CURRENCY_LABEL,\n fileSize,\n moneyDisplay,\n moneyInput,\n parseMoney,\n toLatinDigits,\n type AttachmentValue,\n type Option,\n} from '~/components/ui/resource-value'\n\nexport type RelationSource = { url: string; searchable: boolean }\nexport type InlineDefinition = ResourceEditor['inline'][string]\nexport type InlineRow = {\n key: string\n id?: number\n version?: number\n values: Record<string, string>\n deleted?: boolean\n dirty?: boolean\n}\nexport type FieldProps = {\n field: Field\n id: string\n value: string\n onChange: (value: string) => void\n disabled?: boolean\n error?: string\n options?: Option[]\n relation?: RelationSource\n attachment?: AttachmentValue | null\n /** Resource name; attachment uploads are claimed against it. */\n resource?: string\n /** Accessible name when the visible label lives elsewhere, e.g. inline rows. */\n label?: string\n}\nexport function ResourceSelect({\n value,\n onChange,\n options,\n placeholder = 'اختر قيمة',\n required,\n disabled,\n ...trigger\n}: Pick<\n ComponentProps<typeof SelectTrigger>,\n 'id' | 'aria-label' | 'aria-invalid' | 'aria-describedby'\n> & {\n value: string\n onChange: (value: string) => void\n options: Option[]\n placeholder?: string\n required?: boolean\n disabled?: boolean\n}) {\n // Encode values so an empty selection cannot collide with a business lookup key.\n return (\n <Select\n value={`value:${value}`}\n onValueChange={(next) => onChange(next.slice(6))}\n required={required}\n disabled={disabled}\n >\n <SelectTrigger {...trigger} className=\"h-10 w-full\" aria-required={required}>\n <SelectValue placeholder={placeholder} />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"value:\">{placeholder}</SelectItem>\n {options\n .filter((option) => String(option.value) !== '')\n .map((option) => (\n <SelectItem key={option.value} value={`value:${option.value}`}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )\n}\n\nexport function ResourceField(props: FieldProps) {\n const { field, id, error } = props\n return (\n <div\n className={`min-w-0 space-y-2 ${['text', 'json'].includes(field.type) ? 'md:col-span-2' : ''}`}\n >\n {/* The marker stays outside the label so the field's accessible name is the label alone. */}\n <div className=\"flex items-center gap-1\">\n <Label htmlFor={id}>{field.label.ar}</Label>\n {field.required && (\n <span className=\"text-destructive\" aria-hidden=\"true\">\n *\n </span>\n )}\n </div>\n <FieldControl {...props} />\n {error && (\n <p role=\"alert\" id={`${id}-error`} className=\"text-xs text-destructive\">\n {error}\n </p>\n )}\n </div>\n )\n}\n\nexport function FieldControl(props: FieldProps) {\n const { field, id, value, onChange, disabled, error, label } = props\n const shared = {\n id,\n disabled,\n 'required': field.required,\n 'aria-label': label,\n 'aria-invalid': error ? true : undefined,\n 'aria-describedby': error ? `${id}-error` : undefined,\n }\n switch (field.type) {\n case 'belongsTo':\n return <RelationCombobox {...props} />\n case 'lookup':\n return (\n <ResourceSelect\n {...shared}\n value={value}\n onChange={onChange}\n options={props.options ?? []}\n />\n )\n case 'boolean':\n return (\n <div className=\"flex h-10 items-center gap-3\">\n <Switch\n {...shared}\n required={undefined}\n checked={value === 'true'}\n onCheckedChange={(checked) => onChange(checked ? 'true' : 'false')}\n />\n <span className=\"text-sm text-muted-foreground\">{value === 'true' ? 'نعم' : 'لا'}</span>\n </div>\n )\n case 'attachment':\n return <AttachmentControl {...props} />\n case 'text':\n return (\n <Textarea\n {...shared}\n rows={4}\n value={value}\n onChange={(event) => onChange(event.target.value)}\n />\n )\n case 'json':\n return <JsonControl {...props} />\n case 'money':\n return <MoneyControl {...props} />\n case 'date':\n return <DateControl {...props} />\n case 'datetime':\n return <DatetimeControl {...props} />\n case 'integer':\n return (\n <Input\n {...shared}\n type=\"number\"\n step={1}\n inputMode=\"numeric\"\n dir=\"ltr\"\n className=\"h-10\"\n value={value}\n onChange={(event) => onChange(event.target.value)}\n />\n )\n default:\n return (\n <Input\n {...shared}\n type=\"text\"\n className=\"h-10\"\n value={value}\n onChange={(event) => onChange(event.target.value)}\n />\n )\n }\n}\n\nfunction MoneyControl({ id, value, onChange, disabled, error, field, label }: FieldProps) {\n let preview = ''\n try {\n if (value) preview = moneyDisplay(parseMoney(value))\n } catch {\n preview = ''\n }\n return (\n <div className=\"space-y-1\">\n <div className=\"relative\">\n <Input\n id={id}\n type=\"text\"\n inputMode=\"decimal\"\n dir=\"ltr\"\n placeholder=\"0.00\"\n className=\"h-10 pe-16 text-end tabular-nums\"\n value={value}\n disabled={disabled}\n required={field.required}\n aria-label={label}\n aria-invalid={error ? true : undefined}\n aria-describedby={error ? `${id}-error` : undefined}\n onChange={(event) => onChange(event.target.value)}\n onBlur={() => {\n try {\n if (value) onChange(moneyInput(parseMoney(value)))\n } catch {\n // Keep the typed text so the submit error can point at it.\n }\n }}\n />\n <span className=\"pointer-events-none absolute inset-y-0 end-3 flex items-center text-xs text-muted-foreground\">\n {CURRENCY_LABEL}\n </span>\n </div>\n {preview && (\n <p className=\"text-xs text-muted-foreground\" dir=\"ltr\">\n {preview} {CURRENCY_LABEL}\n </p>\n )}\n </div>\n )\n}\n\nfunction DateControl({ id, value, onChange, disabled, error, field, label }: FieldProps) {\n const { calendar: preference } = useUiPreferences()\n const [entryCalendar, setEntryCalendar] = useState<CalendarSystem>('gregory')\n const calendar = preference === 'both' ? entryCalendar : preference\n const [open, setOpen] = useState(false)\n let normalized = toLatinDigits(value)\n try {\n if (value.startsWith('hijri:')) normalized = calendarIso(value.slice(6), 'islamic-umalqura')\n } catch {\n normalized = ''\n }\n const displayed =\n calendar === 'islamic-umalqura'\n ? value.startsWith('hijri:')\n ? value.slice(6)\n : calendarInput(value, calendar)\n : normalized\n const selected = /^\\d{4}-\\d{2}-\\d{2}$/.test(normalized)\n ? new Date(`${normalized}T00:00:00`)\n : undefined\n const valid = selected !== undefined && !Number.isNaN(selected.getTime())\n return (\n <div className=\"space-y-1\">\n {preference === 'both' && (\n <div\n className=\"flex gap-1\"\n role=\"group\"\n aria-label={`تقويم الإدخال: ${label ?? field.label.ar}`}\n >\n <Button\n type=\"button\"\n size=\"sm\"\n variant={calendar === 'gregory' ? 'secondary' : 'ghost'}\n aria-pressed={calendar === 'gregory'}\n onClick={() => setEntryCalendar('gregory')}\n >\n إدخال ميلادي\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n variant={calendar === 'islamic-umalqura' ? 'secondary' : 'ghost'}\n aria-pressed={calendar === 'islamic-umalqura'}\n onClick={() => setEntryCalendar('islamic-umalqura')}\n >\n إدخال هجري\n </Button>\n </div>\n )}\n <div className=\"flex gap-2\">\n <Input\n id={id}\n type=\"text\"\n inputMode=\"numeric\"\n dir=\"ltr\"\n placeholder={calendar === 'islamic-umalqura' ? '1447-09-01' : '2026-01-31'}\n className=\"h-10 tabular-nums\"\n value={displayed}\n disabled={disabled}\n required={field.required}\n aria-label={label}\n aria-invalid={error ? true : undefined}\n aria-describedby={error ? `${id}-error` : undefined}\n onChange={(event) =>\n onChange(\n calendar === 'islamic-umalqura' && event.target.value\n ? `hijri:${event.target.value}`\n : event.target.value\n )\n }\n />\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-lg\"\n disabled={disabled}\n aria-label={`التقويم: ${label ?? field.label.ar}`}\n >\n <CalendarDays />\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-auto p-0\" align=\"start\">\n {calendar === 'islamic-umalqura' ? (\n <HijriCalendar\n value={valid ? normalized : ''}\n onSelect={(iso) => {\n onChange(iso)\n setOpen(false)\n }}\n />\n ) : (\n <Calendar\n mode=\"single\"\n selected={valid ? selected : undefined}\n defaultMonth={valid ? selected : undefined}\n onSelect={(date) => {\n onChange(date ? format(date, 'yyyy-MM-dd') : '')\n setOpen(false)\n }}\n />\n )}\n </PopoverContent>\n </Popover>\n </div>\n {calendar === 'islamic-umalqura' && (\n <p className=\"text-xs text-muted-foreground\">\n هجري · أم القرى {valid && <span dir=\"ltr\">({normalized} ميلادي)</span>}\n </p>\n )}\n {preference === 'both' && calendar === 'gregory' && valid && (\n <p className=\"text-xs text-muted-foreground\" aria-live=\"polite\">\n الموافق هجريًا: <span dir=\"ltr\">{calendarInput(normalized, 'islamic-umalqura')}</span> هـ\n </p>\n )}\n </div>\n )\n}\n\nfunction DatetimeControl(props: FieldProps) {\n const { calendar } = useUiPreferences()\n if (calendar === 'gregory')\n return (\n <Input\n id={props.id}\n type=\"datetime-local\"\n dir=\"ltr\"\n className=\"h-10\"\n value={props.value}\n disabled={props.disabled}\n required={props.field.required}\n aria-label={props.label}\n aria-invalid={Boolean(props.error)}\n onChange={(event) => props.onChange(event.target.value)}\n />\n )\n const [date, time = '00:00'] = props.value.split('T')\n return (\n <div className=\"space-y-2\">\n <DateControl\n {...props}\n value={date}\n onChange={(next) => props.onChange(next ? `${next}T${time}` : '')}\n />\n <Input\n type=\"time\"\n dir=\"ltr\"\n aria-label={`الوقت: ${props.label ?? props.field.label.ar}`}\n value={time}\n disabled={props.disabled}\n onChange={(event) => props.onChange(`${date}T${event.target.value}`)}\n />\n </div>\n )\n}\n\nfunction JsonControl({ id, value, onChange, disabled, error, field, label }: FieldProps) {\n const [invalid, setInvalid] = useState('')\n const check = () => {\n if (!value.trim()) return setInvalid('')\n try {\n JSON.parse(value)\n setInvalid('')\n } catch {\n setInvalid('أدخل بيانات JSON صحيحة')\n }\n }\n return (\n <div className=\"space-y-2\">\n <Textarea\n id={id}\n dir=\"ltr\"\n rows={6}\n spellCheck={false}\n className=\"font-mono text-xs\"\n value={value}\n disabled={disabled}\n required={field.required}\n aria-label={label}\n aria-invalid={error || invalid ? true : undefined}\n aria-describedby={error ? `${id}-error` : undefined}\n onChange={(event) => onChange(event.target.value)}\n onBlur={check}\n />\n <div className=\"flex items-center gap-3\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n disabled={disabled || !value.trim()}\n onClick={() => {\n try {\n onChange(JSON.stringify(JSON.parse(value), null, 2))\n setInvalid('')\n } catch {\n setInvalid('أدخل بيانات JSON صحيحة')\n }\n }}\n >\n تنسيق JSON\n </Button>\n {invalid && !error && (\n <p role=\"alert\" className=\"text-xs text-destructive\">\n {invalid}\n </p>\n )}\n </div>\n </div>\n )\n}\n\n/** Uploads on selection so the saved record only references an already-owned attachment id. */\nfunction AttachmentControl({\n id,\n value,\n onChange,\n disabled,\n error,\n field,\n label,\n attachment,\n resource,\n}: FieldProps) {\n const [current, setCurrent] = useState<AttachmentValue | null>(attachment ?? null)\n const [progress, setProgress] = useState<number | null>(null)\n const [failure, setFailure] = useState('')\n const input = useRef<HTMLInputElement>(null)\n const upload = async (file: File) => {\n setFailure('')\n setProgress(0)\n const body = new FormData()\n body.append('file', file)\n if (resource) body.append('resource', resource)\n body.append('field', field.key)\n try {\n const response = await axios.post<{ data: AttachmentValue }>('/attachments', body, {\n headers: { Accept: 'application/json' },\n withXSRFToken: true,\n onUploadProgress: (event) =>\n setProgress(event.total ? Math.round((event.loaded / event.total) * 100) : 50),\n })\n setCurrent(response.data.data)\n onChange(String(response.data.data.id))\n } catch (caught) {\n setFailure(\n axios.isAxiosError(caught)\n ? (caught.response?.data?.error?.message ?? 'تعذر رفع الملف. حاول مجدداً.')\n : 'تعذر الاتصال بالخادم.'\n )\n } finally {\n setProgress(null)\n if (input.current) input.current.value = ''\n }\n }\n const shown = value && current && String(current.id) === value ? current : null\n return (\n <div className=\"space-y-2\">\n <input\n ref={input}\n id={id}\n type=\"file\"\n className=\"sr-only\"\n disabled={disabled || progress !== null}\n aria-label={label}\n aria-describedby={error ? `${id}-error` : undefined}\n onChange={(event) => {\n const file = event.target.files?.[0]\n if (file) void upload(file)\n }}\n />\n {shown ? (\n <div className=\"flex flex-wrap items-center gap-3 rounded-md border bg-white px-3 py-2 text-sm\">\n <Paperclip size={15} className=\"text-muted-foreground\" />\n <a\n href={shown.url}\n download={shown.name}\n className=\"text-primary underline-offset-4 hover:underline\"\n >\n {shown.name}\n </a>\n <span className=\"text-xs text-muted-foreground\">{fileSize(shown.size)}</span>\n <span className=\"ms-auto flex gap-1\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n disabled={disabled}\n onClick={() => input.current?.click()}\n >\n <Upload size={14} />\n استبدال\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n disabled={disabled}\n className=\"text-destructive\"\n onClick={() => onChange('')}\n >\n <X size={14} />\n إزالة\n </Button>\n </span>\n </div>\n ) : (\n <Button\n type=\"button\"\n variant=\"outline\"\n disabled={disabled || progress !== null}\n onClick={() => input.current?.click()}\n >\n <Upload size={15} />\n {progress === null ? 'اختيار ملف' : `جارٍ الرفع ${progress}%`}\n </Button>\n )}\n {progress !== null && (\n <div\n role=\"progressbar\"\n aria-valuemin={0}\n aria-valuemax={100}\n aria-valuenow={progress}\n aria-label={`رفع ${label ?? field.label.ar}`}\n className=\"h-1.5 w-full overflow-hidden rounded-full bg-muted\"\n >\n <div className=\"h-full bg-primary transition-all\" style={{ width: `${progress}%` }} />\n </div>\n )}\n {failure && (\n <p role=\"alert\" className=\"text-xs text-destructive\">\n {failure}\n </p>\n )}\n </div>\n )\n}\n\ntype OptionPage = { data: Option[]; nextCursor: string | null }\n/** Server-side search through the authorized options route; keyset \"more\" keeps large relations usable. */\nfunction RelationCombobox({\n id,\n value,\n onChange,\n disabled,\n error,\n field,\n label,\n options = [],\n relation,\n}: FieldProps) {\n const [open, setOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [loaded, setLoaded] = useState<Option[]>(options)\n const [cursor, setCursor] = useState<string | null>(null)\n const [fetched, setFetched] = useState(false)\n const [failure, setFailure] = useState('')\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined)\n const http = useHttp<Record<string, never>, OptionPage>({})\n const listId = useId()\n const current =\n loaded.find((option) => option.value === value) ??\n options.find((option) => option.value === value)\n const load = async (more: boolean, term = search) => {\n if (!relation) return\n setFailure('')\n const url = new URL(relation.url, window.location.origin)\n if (term && relation.searchable) url.searchParams.set('search', term)\n if (more && cursor) url.searchParams.set('cursor', cursor)\n try {\n const page = await http.get(url.pathname + url.search)\n if (!page) return\n setFetched(true)\n setCursor(page.nextCursor)\n setLoaded((previous) => {\n const keep = more ? previous : previous.filter((option) => option.value === value)\n return [\n ...new Map([...keep, ...page.data].map((option) => [option.value, option])).values(),\n ]\n })\n } catch {\n setFailure('تعذر تحميل الخيارات')\n }\n }\n useEffect(() => () => clearTimeout(timer.current), [])\n const searchable = Boolean(relation?.searchable)\n return (\n <Popover\n open={open}\n onOpenChange={(next) => {\n setOpen(next)\n if (next && relation && !fetched) void load(false, '')\n }}\n >\n <PopoverTrigger asChild>\n <Button\n id={id}\n type=\"button\"\n role=\"combobox\"\n variant=\"outline\"\n aria-expanded={open}\n aria-controls={listId}\n aria-label={label}\n aria-invalid={error ? true : undefined}\n aria-describedby={error ? `${id}-error` : undefined}\n disabled={disabled}\n className={`h-10 w-full justify-between font-normal ${current ? '' : 'text-muted-foreground'}`}\n >\n <span className=\"truncate\">{current?.label ?? (value ? `#${value}` : 'اختر سجلاً')}</span>\n <ChevronsUpDown className=\"opacity-50\" />\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-(--radix-popover-trigger-width) min-w-64 p-0\" align=\"start\">\n <Command shouldFilter={!searchable}>\n <CommandInput\n placeholder={searchable ? 'ابحث في السجلات المرتبطة' : 'تصفية الخيارات'}\n aria-label={`البحث في ${label ?? field.label.ar}`}\n value={search}\n onValueChange={(term) => {\n setSearch(term)\n if (!searchable) return\n clearTimeout(timer.current)\n timer.current = setTimeout(() => void load(false, term), 300)\n }}\n />\n <CommandList id={listId}>\n <CommandEmpty>\n {http.processing ? 'جارٍ البحث...' : 'لا توجد سجلات مطابقة'}\n </CommandEmpty>\n <CommandGroup>\n {value && !field.required && (\n <CommandItem\n value=\"__none__\"\n onSelect={() => {\n onChange('')\n setOpen(false)\n }}\n >\n <X />\n بدون اختيار\n </CommandItem>\n )}\n {loaded.map((option) => (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label]}\n onSelect={() => {\n onChange(option.value)\n setOpen(false)\n }}\n >\n <Check className={option.value === value ? 'opacity-100' : 'opacity-0'} />\n {option.label}\n </CommandItem>\n ))}\n </CommandGroup>\n {cursor && (\n <div className=\"border-t p-1\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full\"\n disabled={http.processing}\n onClick={() => void load(true)}\n >\n {http.processing ? 'جارٍ التحميل...' : 'المزيد'}\n </Button>\n </div>\n )}\n {failure && (\n <p role=\"alert\" className=\"p-2 text-xs text-destructive\">\n {failure}\n </p>\n )}\n </CommandList>\n </Command>\n </PopoverContent>\n </Popover>\n )\n}\n\nlet rowSequence = 0\nexport function newInlineRow(fields: Field[]): InlineRow {\n rowSequence += 1\n return {\n key: `new-${rowSequence}`,\n values: Object.fromEntries(\n fields.map((field) => [field.key, field.type === 'boolean' ? 'false' : ''])\n ),\n dirty: true,\n }\n}\n\n/** Inline hasMany rows: new rows, versioned updates and explicit `_delete` markers in one editable table. */\nexport function InlineRows({\n field,\n inline,\n rows,\n onChange,\n errors = {},\n sectionError,\n disabled,\n resource,\n}: {\n field: Field\n inline: InlineDefinition\n rows: InlineRow[]\n onChange: (rows: InlineRow[]) => void\n errors?: Record<string, Record<string, string>>\n sectionError?: string\n disabled?: boolean\n resource: string\n}) {\n const update = (key: string, patch: Partial<InlineRow>) =>\n onChange(rows.map((row) => (row.key === key ? { ...row, ...patch } : row)))\n const editable = (row: InlineRow, key: string) =>\n row.id === undefined\n ? inline.createFields.includes(key)\n : Boolean(inline.permissions[String(row.id)]?.update) &&\n (inline.updateFields[String(row.id)] ?? []).includes(key)\n const live = rows.filter((row) => !row.deleted)\n return (\n <section aria-label={field.label.ar} className=\"rounded-xl border bg-white\">\n <div className=\"flex flex-wrap items-center justify-between gap-3 border-b px-7 py-4\">\n <div>\n <h2 className=\"text-sm font-semibold\">{field.label.ar}</h2>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {live.length ? `${live.length} بند` : 'لا توجد بنود بعد'}\n {inline.hasMore && ' · تظهر أول 100 بند فقط'}\n </p>\n </div>\n {inline.canCreate && (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n disabled={disabled}\n onClick={() => onChange([...rows, newInlineRow(inline.fields)])}\n >\n <Plus size={15} />\n إضافة بند\n </Button>\n )}\n </div>\n {sectionError && (\n <p role=\"alert\" className=\"border-b px-7 py-3 text-sm text-destructive\">\n {sectionError}\n </p>\n )}\n {rows.length > 0 && (\n <div className=\"overflow-x-auto\">\n <table className=\"w-full min-w-[560px] text-sm\">\n <thead className=\"bg-muted text-xs text-muted-foreground\">\n <tr>\n <th className=\"w-12 px-4 py-3 text-start font-medium\">#</th>\n {inline.fields.map((child) => (\n <th key={child.key} className=\"px-3 py-3 text-start font-medium\">\n {child.label.ar}\n {child.required && (\n <span className=\"text-destructive\" aria-hidden=\"true\">\n {' '}\n *\n </span>\n )}\n </th>\n ))}\n <th className=\"w-24 px-4 py-3 text-start font-medium\">\n <span className=\"sr-only\">الإجراء</span>\n </th>\n </tr>\n </thead>\n <tbody>\n {rows.map((row, index) => {\n const number = index + 1\n const rowErrors = errors[row.key] ?? {}\n return (\n <tr\n key={row.key}\n aria-label={`بند ${number}`}\n className={`border-b align-top last:border-0 ${row.deleted ? 'bg-destructive/5 opacity-60' : ''}`}\n >\n <td className=\"px-4 py-3 tabular-nums text-muted-foreground\">{number}</td>\n {inline.fields.map((child) => (\n <td key={child.key} className=\"min-w-40 px-3 py-2\">\n <FieldControl\n field={child}\n id={`${field.key}-${row.key}-${child.key}`}\n label={`${child.label.ar} للبند ${number}`}\n value={row.values[child.key] ?? ''}\n disabled={disabled || row.deleted || !editable(row, child.key)}\n error={rowErrors[child.key]}\n resource={resource}\n relation={\n child.type === 'belongsTo'\n ? {\n url: `/resources/${(field as { resource?: string }).resource}/options/${child.key}`,\n searchable: false,\n }\n : undefined\n }\n onChange={(value) =>\n update(row.key, {\n values: { ...row.values, [child.key]: value },\n dirty: true,\n })\n }\n />\n {rowErrors[child.key] && (\n <p role=\"alert\" className=\"mt-1 text-xs text-destructive\">\n {rowErrors[child.key]}\n </p>\n )}\n </td>\n ))}\n <td className=\"px-4 py-2\">\n {row.id === undefined ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n disabled={disabled}\n aria-label={`إزالة البند ${number}`}\n onClick={() => onChange(rows.filter((entry) => entry.key !== row.key))}\n >\n <Trash2 className=\"text-destructive\" />\n </Button>\n ) : inline.permissions[String(row.id)]?.delete ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n disabled={disabled}\n aria-label={\n row.deleted ? `التراجع عن حذف البند ${number}` : `حذف البند ${number}`\n }\n onClick={() => update(row.key, { deleted: !row.deleted })}\n >\n {row.deleted ? <Undo2 /> : <Trash2 className=\"text-destructive\" />}\n </Button>\n ) : null}\n </td>\n </tr>\n )\n })}\n </tbody>\n </table>\n </div>\n )}\n </section>\n )\n}\n",
28
+ "content": "import { useEffect, useId, useRef, useState, type ComponentProps } from 'react'\nimport { useHttp } from '@inertiajs/react'\nimport axios from 'axios'\nimport { format } from 'date-fns'\nimport {\n CalendarDays,\n Check,\n ChevronsUpDown,\n Paperclip,\n Plus,\n Trash2,\n Undo2,\n Upload,\n X,\n} from 'lucide-react'\nimport type { CalendarSystem, ResourceEditor, ResourceField as Field } from '@adula/kit'\nimport { Button } from '~/components/ui/button'\nimport { Calendar } from '~/components/ui/calendar'\nimport { HijriCalendar } from '~/components/ui/hijri-calendar'\nimport { calendarInput, calendarIso } from '~/components/ui/calendar_date'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from '~/components/ui/command'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Popover, PopoverContent, PopoverTrigger } from '~/components/ui/popover'\nimport { Switch } from '~/components/ui/switch'\nimport { Textarea } from '~/components/ui/textarea'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/components/ui/select'\nimport {\n CURRENCY_LABEL,\n fileSize,\n moneyDisplay,\n moneyInput,\n parseMoney,\n toLatinDigits,\n type AttachmentValue,\n type Option,\n} from '~/components/ui/resource-value'\n\nexport type RelationSource = { url: string; searchable: boolean }\nexport type InlineDefinition = ResourceEditor['inline'][string]\nexport type InlineRow = {\n key: string\n id?: number\n version?: number\n values: Record<string, string>\n deleted?: boolean\n dirty?: boolean\n}\nexport type FieldProps = {\n field: Field\n id: string\n value: string\n onChange: (value: string) => void\n disabled?: boolean\n error?: string\n options?: Option[]\n relation?: RelationSource\n attachment?: AttachmentValue | null\n /** Resource name; attachment uploads are claimed against it. */\n resource?: string\n /** Accessible name when the visible label lives elsewhere, e.g. inline rows. */\n label?: string\n}\nexport function ResourceSelect({\n value,\n onChange,\n options,\n placeholder = 'اختر قيمة',\n required,\n disabled,\n className = 'h-10 w-full',\n ...trigger\n}: Pick<\n ComponentProps<typeof SelectTrigger>,\n 'id' | 'aria-label' | 'aria-invalid' | 'aria-describedby'\n> & {\n value: string\n onChange: (value: string) => void\n options: Option[]\n placeholder?: string\n required?: boolean\n disabled?: boolean\n /** Trigger size and width; forms keep the default, toolbars pass a compact size. */\n className?: string\n}) {\n // Encode values so an empty selection cannot collide with a business lookup key.\n return (\n <Select\n value={`value:${value}`}\n onValueChange={(next) => onChange(next.slice(6))}\n required={required}\n disabled={disabled}\n >\n <SelectTrigger {...trigger} className={className} aria-required={required}>\n <SelectValue placeholder={placeholder} />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"value:\">{placeholder}</SelectItem>\n {options\n .filter((option) => String(option.value) !== '')\n .map((option) => (\n <SelectItem key={option.value} value={`value:${option.value}`}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )\n}\n\nexport function ResourceField(props: FieldProps) {\n const { field, id, error } = props\n return (\n <div\n className={`min-w-0 space-y-2 ${['text', 'json'].includes(field.type) ? 'md:col-span-2' : ''}`}\n >\n {/* The marker stays outside the label so the field's accessible name is the label alone. */}\n <div className=\"flex items-center gap-1\">\n <Label htmlFor={id}>{field.label.ar}</Label>\n {field.required && (\n <span className=\"text-destructive\" aria-hidden=\"true\">\n *\n </span>\n )}\n </div>\n <FieldControl {...props} />\n {error && (\n <p role=\"alert\" id={`${id}-error`} className=\"text-xs text-destructive\">\n {error}\n </p>\n )}\n </div>\n )\n}\n\nexport function FieldControl(props: FieldProps) {\n const { field, id, value, onChange, disabled, error, label } = props\n const shared = {\n id,\n disabled,\n 'required': field.required,\n 'aria-label': label,\n 'aria-invalid': error ? true : undefined,\n 'aria-describedby': error ? `${id}-error` : undefined,\n }\n switch (field.type) {\n case 'belongsTo':\n case 'user':\n return <RelationCombobox {...props} />\n case 'lookup':\n return (\n <ResourceSelect\n {...shared}\n value={value}\n onChange={onChange}\n options={props.options ?? []}\n />\n )\n case 'boolean':\n return (\n <div className=\"flex h-10 items-center gap-3\">\n <Switch\n {...shared}\n required={undefined}\n checked={value === 'true'}\n onCheckedChange={(checked) => onChange(checked ? 'true' : 'false')}\n />\n <span className=\"text-sm text-muted-foreground\">{value === 'true' ? 'نعم' : 'لا'}</span>\n </div>\n )\n case 'attachment':\n return <AttachmentControl {...props} />\n case 'text':\n return (\n <Textarea\n {...shared}\n rows={4}\n value={value}\n onChange={(event) => onChange(event.target.value)}\n />\n )\n case 'json':\n return <JsonControl {...props} />\n case 'money':\n return <MoneyControl {...props} />\n case 'date':\n return <DateControl {...props} />\n case 'datetime':\n return <DatetimeControl {...props} />\n case 'integer':\n return (\n <Input\n {...shared}\n type=\"number\"\n step={1}\n inputMode=\"numeric\"\n dir=\"ltr\"\n className=\"h-10\"\n value={value}\n onChange={(event) => onChange(event.target.value)}\n />\n )\n default:\n return (\n <Input\n {...shared}\n type=\"text\"\n className=\"h-10\"\n value={value}\n onChange={(event) => onChange(event.target.value)}\n />\n )\n }\n}\n\nfunction MoneyControl({ id, value, onChange, disabled, error, field, label }: FieldProps) {\n let preview = ''\n try {\n if (value) preview = moneyDisplay(parseMoney(value))\n } catch {\n preview = ''\n }\n return (\n <div className=\"space-y-1\">\n <div className=\"relative\">\n <Input\n id={id}\n type=\"text\"\n inputMode=\"decimal\"\n dir=\"ltr\"\n placeholder=\"0.00\"\n className=\"h-10 pe-16 text-end tabular-nums\"\n value={value}\n disabled={disabled}\n required={field.required}\n aria-label={label}\n aria-invalid={error ? true : undefined}\n aria-describedby={error ? `${id}-error` : undefined}\n onChange={(event) => onChange(event.target.value)}\n onBlur={() => {\n try {\n if (value) onChange(moneyInput(parseMoney(value)))\n } catch {\n // Keep the typed text so the submit error can point at it.\n }\n }}\n />\n <span className=\"pointer-events-none absolute inset-y-0 end-3 flex items-center text-xs text-muted-foreground\">\n {CURRENCY_LABEL}\n </span>\n </div>\n {preview && (\n <p className=\"text-xs text-muted-foreground\" dir=\"ltr\">\n {preview} {CURRENCY_LABEL}\n </p>\n )}\n </div>\n )\n}\n\nfunction DateControl({ id, value, onChange, disabled, error, field, label }: FieldProps) {\n const { calendar: preference } = useUiPreferences()\n const [entryCalendar, setEntryCalendar] = useState<CalendarSystem>('gregory')\n const calendar = preference === 'both' ? entryCalendar : preference\n const [open, setOpen] = useState(false)\n let normalized = toLatinDigits(value)\n try {\n if (value.startsWith('hijri:')) normalized = calendarIso(value.slice(6), 'islamic-umalqura')\n } catch {\n normalized = ''\n }\n const displayed =\n calendar === 'islamic-umalqura'\n ? value.startsWith('hijri:')\n ? value.slice(6)\n : calendarInput(value, calendar)\n : normalized\n const selected = /^\\d{4}-\\d{2}-\\d{2}$/.test(normalized)\n ? new Date(`${normalized}T00:00:00`)\n : undefined\n const valid = selected !== undefined && !Number.isNaN(selected.getTime())\n return (\n <div className=\"space-y-1\">\n {preference === 'both' && (\n <div\n className=\"flex gap-1\"\n role=\"group\"\n aria-label={`تقويم الإدخال: ${label ?? field.label.ar}`}\n >\n <Button\n type=\"button\"\n size=\"sm\"\n variant={calendar === 'gregory' ? 'secondary' : 'ghost'}\n aria-pressed={calendar === 'gregory'}\n onClick={() => setEntryCalendar('gregory')}\n >\n إدخال ميلادي\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n variant={calendar === 'islamic-umalqura' ? 'secondary' : 'ghost'}\n aria-pressed={calendar === 'islamic-umalqura'}\n onClick={() => setEntryCalendar('islamic-umalqura')}\n >\n إدخال هجري\n </Button>\n </div>\n )}\n <div className=\"flex gap-2\">\n <Input\n id={id}\n type=\"text\"\n inputMode=\"numeric\"\n dir=\"ltr\"\n placeholder={calendar === 'islamic-umalqura' ? '1447-09-01' : '2026-01-31'}\n className=\"h-10 tabular-nums\"\n value={displayed}\n disabled={disabled}\n required={field.required}\n aria-label={label}\n aria-invalid={error ? true : undefined}\n aria-describedby={error ? `${id}-error` : undefined}\n onChange={(event) =>\n onChange(\n calendar === 'islamic-umalqura' && event.target.value\n ? `hijri:${event.target.value}`\n : event.target.value\n )\n }\n />\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-lg\"\n disabled={disabled}\n aria-label={`التقويم: ${label ?? field.label.ar}`}\n >\n <CalendarDays />\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-auto p-0\" align=\"start\">\n {calendar === 'islamic-umalqura' ? (\n <HijriCalendar\n value={valid ? normalized : ''}\n onSelect={(iso) => {\n onChange(iso)\n setOpen(false)\n }}\n />\n ) : (\n <Calendar\n mode=\"single\"\n selected={valid ? selected : undefined}\n defaultMonth={valid ? selected : undefined}\n onSelect={(date) => {\n onChange(date ? format(date, 'yyyy-MM-dd') : '')\n setOpen(false)\n }}\n />\n )}\n </PopoverContent>\n </Popover>\n </div>\n {calendar === 'islamic-umalqura' && (\n <p className=\"text-xs text-muted-foreground\">\n هجري · أم القرى {valid && <span dir=\"ltr\">({normalized} ميلادي)</span>}\n </p>\n )}\n {preference === 'both' && calendar === 'gregory' && valid && (\n <p className=\"text-xs text-muted-foreground\" aria-live=\"polite\">\n الموافق هجريًا: <span dir=\"ltr\">{calendarInput(normalized, 'islamic-umalqura')}</span> هـ\n </p>\n )}\n </div>\n )\n}\n\nfunction DatetimeControl(props: FieldProps) {\n const { calendar } = useUiPreferences()\n if (calendar === 'gregory')\n return (\n <Input\n id={props.id}\n type=\"datetime-local\"\n dir=\"ltr\"\n className=\"h-10\"\n value={props.value}\n disabled={props.disabled}\n required={props.field.required}\n aria-label={props.label}\n aria-invalid={Boolean(props.error)}\n onChange={(event) => props.onChange(event.target.value)}\n />\n )\n const [date, time = '00:00'] = props.value.split('T')\n return (\n <div className=\"space-y-2\">\n <DateControl\n {...props}\n value={date}\n onChange={(next) => props.onChange(next ? `${next}T${time}` : '')}\n />\n <Input\n type=\"time\"\n dir=\"ltr\"\n aria-label={`الوقت: ${props.label ?? props.field.label.ar}`}\n value={time}\n disabled={props.disabled}\n onChange={(event) => props.onChange(`${date}T${event.target.value}`)}\n />\n </div>\n )\n}\n\nfunction JsonControl({ id, value, onChange, disabled, error, field, label }: FieldProps) {\n const [invalid, setInvalid] = useState('')\n const check = () => {\n if (!value.trim()) return setInvalid('')\n try {\n JSON.parse(value)\n setInvalid('')\n } catch {\n setInvalid('أدخل بيانات JSON صحيحة')\n }\n }\n return (\n <div className=\"space-y-2\">\n <Textarea\n id={id}\n dir=\"ltr\"\n rows={6}\n spellCheck={false}\n className=\"font-mono text-xs\"\n value={value}\n disabled={disabled}\n required={field.required}\n aria-label={label}\n aria-invalid={error || invalid ? true : undefined}\n aria-describedby={error ? `${id}-error` : undefined}\n onChange={(event) => onChange(event.target.value)}\n onBlur={check}\n />\n <div className=\"flex items-center gap-3\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n disabled={disabled || !value.trim()}\n onClick={() => {\n try {\n onChange(JSON.stringify(JSON.parse(value), null, 2))\n setInvalid('')\n } catch {\n setInvalid('أدخل بيانات JSON صحيحة')\n }\n }}\n >\n تنسيق JSON\n </Button>\n {invalid && !error && (\n <p role=\"alert\" className=\"text-xs text-destructive\">\n {invalid}\n </p>\n )}\n </div>\n </div>\n )\n}\n\n/** Uploads on selection so the saved record only references an already-owned attachment id. */\nfunction AttachmentControl({\n id,\n value,\n onChange,\n disabled,\n error,\n field,\n label,\n attachment,\n resource,\n}: FieldProps) {\n const [current, setCurrent] = useState<AttachmentValue | null>(attachment ?? null)\n const [progress, setProgress] = useState<number | null>(null)\n const [failure, setFailure] = useState('')\n const input = useRef<HTMLInputElement>(null)\n const upload = async (file: File) => {\n setFailure('')\n setProgress(0)\n const body = new FormData()\n body.append('file', file)\n if (resource) body.append('resource', resource)\n body.append('field', field.key)\n try {\n const response = await axios.post<{ data: AttachmentValue }>('/attachments', body, {\n headers: { Accept: 'application/json' },\n withXSRFToken: true,\n onUploadProgress: (event) =>\n setProgress(event.total ? Math.round((event.loaded / event.total) * 100) : 50),\n })\n setCurrent(response.data.data)\n onChange(String(response.data.data.id))\n } catch (caught) {\n setFailure(\n axios.isAxiosError(caught)\n ? (caught.response?.data?.error?.message ?? 'تعذر رفع الملف. حاول مجدداً.')\n : 'تعذر الاتصال بالخادم.'\n )\n } finally {\n setProgress(null)\n if (input.current) input.current.value = ''\n }\n }\n const shown = value && current && String(current.id) === value ? current : null\n return (\n <div className=\"space-y-2\">\n <input\n ref={input}\n id={id}\n type=\"file\"\n className=\"sr-only\"\n disabled={disabled || progress !== null}\n aria-label={label}\n aria-describedby={error ? `${id}-error` : undefined}\n onChange={(event) => {\n const file = event.target.files?.[0]\n if (file) void upload(file)\n }}\n />\n {shown ? (\n <div className=\"flex flex-wrap items-center gap-3 rounded-md border bg-white px-3 py-2 text-sm\">\n <Paperclip size={15} className=\"text-muted-foreground\" />\n <a\n href={shown.url}\n download={shown.name}\n className=\"text-primary underline-offset-4 hover:underline\"\n >\n {shown.name}\n </a>\n <span className=\"text-xs text-muted-foreground\">{fileSize(shown.size)}</span>\n <span className=\"ms-auto flex gap-1\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n disabled={disabled}\n onClick={() => input.current?.click()}\n >\n <Upload size={14} />\n استبدال\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n disabled={disabled}\n className=\"text-destructive\"\n onClick={() => onChange('')}\n >\n <X size={14} />\n إزالة\n </Button>\n </span>\n </div>\n ) : (\n <Button\n type=\"button\"\n variant=\"outline\"\n disabled={disabled || progress !== null}\n onClick={() => input.current?.click()}\n >\n <Upload size={15} />\n {progress === null ? 'اختيار ملف' : `جارٍ الرفع ${progress}%`}\n </Button>\n )}\n {progress !== null && (\n <div\n role=\"progressbar\"\n aria-valuemin={0}\n aria-valuemax={100}\n aria-valuenow={progress}\n aria-label={`رفع ${label ?? field.label.ar}`}\n className=\"h-1.5 w-full overflow-hidden rounded-full bg-muted\"\n >\n <div className=\"h-full bg-primary transition-all\" style={{ width: `${progress}%` }} />\n </div>\n )}\n {failure && (\n <p role=\"alert\" className=\"text-xs text-destructive\">\n {failure}\n </p>\n )}\n </div>\n )\n}\n\ntype OptionPage = { data: Option[]; nextCursor: string | null }\n/** Server-side search through the authorized options route; keyset \"more\" keeps large relations usable. */\nfunction RelationCombobox({\n id,\n value,\n onChange,\n disabled,\n error,\n field,\n label,\n options = [],\n relation,\n}: FieldProps) {\n const [open, setOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [loaded, setLoaded] = useState<Option[]>(options)\n const [cursor, setCursor] = useState<string | null>(null)\n const [fetched, setFetched] = useState(false)\n const [failure, setFailure] = useState('')\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined)\n const http = useHttp<Record<string, never>, OptionPage>({})\n const listId = useId()\n const current =\n loaded.find((option) => option.value === value) ??\n options.find((option) => option.value === value)\n const load = async (more: boolean, term = search) => {\n if (!relation) return\n setFailure('')\n const url = new URL(relation.url, window.location.origin)\n if (term && relation.searchable) url.searchParams.set('search', term)\n if (more && cursor) url.searchParams.set('cursor', cursor)\n try {\n const page = await http.get(url.pathname + url.search)\n if (!page) return\n setFetched(true)\n setCursor(page.nextCursor)\n setLoaded((previous) => {\n const keep = more ? previous : previous.filter((option) => option.value === value)\n return [\n ...new Map([...keep, ...page.data].map((option) => [option.value, option])).values(),\n ]\n })\n } catch {\n setFailure('تعذر تحميل الخيارات')\n }\n }\n useEffect(() => () => clearTimeout(timer.current), [])\n const searchable = Boolean(relation?.searchable)\n return (\n <Popover\n open={open}\n onOpenChange={(next) => {\n setOpen(next)\n if (next && relation && !fetched) void load(false, '')\n }}\n >\n <PopoverTrigger asChild>\n <Button\n id={id}\n type=\"button\"\n role=\"combobox\"\n variant=\"outline\"\n aria-expanded={open}\n aria-controls={listId}\n aria-label={label}\n aria-invalid={error ? true : undefined}\n aria-describedby={error ? `${id}-error` : undefined}\n disabled={disabled}\n className={`h-10 w-full justify-between font-normal ${current ? '' : 'text-muted-foreground'}`}\n >\n <span className=\"truncate\">\n {current?.label ??\n (value ? `#${value}` : field.type === 'user' ? 'اختر مستخدماً' : 'اختر سجلاً')}\n </span>\n <ChevronsUpDown className=\"opacity-50\" />\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-(--radix-popover-trigger-width) min-w-64 p-0\" align=\"start\">\n <Command shouldFilter={!searchable}>\n <CommandInput\n placeholder={\n field.type === 'user'\n ? 'ابحث بالاسم'\n : searchable\n ? 'ابحث في السجلات المرتبطة'\n : 'تصفية الخيارات'\n }\n aria-label={`البحث في ${label ?? field.label.ar}`}\n value={search}\n onValueChange={(term) => {\n setSearch(term)\n if (!searchable) return\n clearTimeout(timer.current)\n timer.current = setTimeout(() => void load(false, term), 300)\n }}\n />\n <CommandList id={listId}>\n <CommandEmpty>\n {http.processing ? 'جارٍ البحث...' : 'لا توجد سجلات مطابقة'}\n </CommandEmpty>\n <CommandGroup>\n {value && !field.required && (\n <CommandItem\n value=\"__none__\"\n onSelect={() => {\n onChange('')\n setOpen(false)\n }}\n >\n <X />\n بدون اختيار\n </CommandItem>\n )}\n {loaded.map((option) => (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label]}\n onSelect={() => {\n onChange(option.value)\n setOpen(false)\n }}\n >\n <Check className={option.value === value ? 'opacity-100' : 'opacity-0'} />\n {option.label}\n </CommandItem>\n ))}\n </CommandGroup>\n {cursor && (\n <div className=\"border-t p-1\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full\"\n disabled={http.processing}\n onClick={() => void load(true)}\n >\n {http.processing ? 'جارٍ التحميل...' : 'المزيد'}\n </Button>\n </div>\n )}\n {failure && (\n <p role=\"alert\" className=\"p-2 text-xs text-destructive\">\n {failure}\n </p>\n )}\n </CommandList>\n </Command>\n </PopoverContent>\n </Popover>\n )\n}\n\nlet rowSequence = 0\nexport function newInlineRow(fields: Field[]): InlineRow {\n rowSequence += 1\n return {\n key: `new-${rowSequence}`,\n values: Object.fromEntries(\n fields.map((field) => [field.key, field.type === 'boolean' ? 'false' : ''])\n ),\n dirty: true,\n }\n}\n\n/** Inline hasMany rows: new rows, versioned updates and explicit `_delete` markers in one editable table. */\nexport function InlineRows({\n field,\n inline,\n rows,\n onChange,\n errors = {},\n sectionError,\n disabled,\n resource,\n}: {\n field: Field\n inline: InlineDefinition\n rows: InlineRow[]\n onChange: (rows: InlineRow[]) => void\n errors?: Record<string, Record<string, string>>\n sectionError?: string\n disabled?: boolean\n resource: string\n}) {\n const update = (key: string, patch: Partial<InlineRow>) =>\n onChange(rows.map((row) => (row.key === key ? { ...row, ...patch } : row)))\n const editable = (row: InlineRow, key: string) =>\n row.id === undefined\n ? inline.createFields.includes(key)\n : Boolean(inline.permissions[String(row.id)]?.update) &&\n (inline.updateFields[String(row.id)] ?? []).includes(key)\n const live = rows.filter((row) => !row.deleted)\n return (\n <section aria-label={field.label.ar} className=\"rounded-xl border bg-white\">\n <div className=\"flex flex-wrap items-center justify-between gap-3 border-b px-7 py-4\">\n <div>\n <h2 className=\"text-sm font-semibold\">{field.label.ar}</h2>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {live.length ? `${live.length} بند` : 'لا توجد بنود بعد'}\n {inline.hasMore && ' · تظهر أول 100 بند فقط'}\n </p>\n </div>\n {inline.canCreate && (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n disabled={disabled}\n onClick={() => onChange([...rows, newInlineRow(inline.fields)])}\n >\n <Plus size={15} />\n إضافة بند\n </Button>\n )}\n </div>\n {sectionError && (\n <p role=\"alert\" className=\"border-b px-7 py-3 text-sm text-destructive\">\n {sectionError}\n </p>\n )}\n {rows.length > 0 && (\n <div className=\"overflow-x-auto\">\n <table className=\"w-full min-w-[560px] text-sm\">\n <thead className=\"bg-muted text-xs text-muted-foreground\">\n <tr>\n <th className=\"w-12 px-4 py-3 text-start font-medium\">#</th>\n {inline.fields.map((child) => (\n <th key={child.key} className=\"px-3 py-3 text-start font-medium\">\n {child.label.ar}\n {child.required && (\n <span className=\"text-destructive\" aria-hidden=\"true\">\n {' '}\n *\n </span>\n )}\n </th>\n ))}\n <th className=\"w-24 px-4 py-3 text-start font-medium\">\n <span className=\"sr-only\">الإجراء</span>\n </th>\n </tr>\n </thead>\n <tbody>\n {rows.map((row, index) => {\n const number = index + 1\n const rowErrors = errors[row.key] ?? {}\n return (\n <tr\n key={row.key}\n aria-label={`بند ${number}`}\n className={`border-b align-top last:border-0 ${row.deleted ? 'bg-destructive/5 opacity-60' : ''}`}\n >\n <td className=\"px-4 py-3 tabular-nums text-muted-foreground\">{number}</td>\n {inline.fields.map((child) => (\n <td key={child.key} className=\"min-w-40 px-3 py-2\">\n <FieldControl\n field={child}\n id={`${field.key}-${row.key}-${child.key}`}\n label={`${child.label.ar} للبند ${number}`}\n value={row.values[child.key] ?? ''}\n disabled={disabled || row.deleted || !editable(row, child.key)}\n error={rowErrors[child.key]}\n resource={resource}\n relation={\n child.type === 'belongsTo' || child.type === 'user'\n ? {\n url: `/resources/${(field as { resource?: string }).resource}/options/${child.key}`,\n searchable: child.type === 'user',\n }\n : undefined\n }\n onChange={(value) =>\n update(row.key, {\n values: { ...row.values, [child.key]: value },\n dirty: true,\n })\n }\n />\n {rowErrors[child.key] && (\n <p role=\"alert\" className=\"mt-1 text-xs text-destructive\">\n {rowErrors[child.key]}\n </p>\n )}\n </td>\n ))}\n <td className=\"px-4 py-2\">\n {row.id === undefined ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n disabled={disabled}\n aria-label={`إزالة البند ${number}`}\n onClick={() => onChange(rows.filter((entry) => entry.key !== row.key))}\n >\n <Trash2 className=\"text-destructive\" />\n </Button>\n ) : inline.permissions[String(row.id)]?.delete ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n disabled={disabled}\n aria-label={\n row.deleted ? `التراجع عن حذف البند ${number}` : `حذف البند ${number}`\n }\n onClick={() => update(row.key, { deleted: !row.deleted })}\n >\n {row.deleted ? <Undo2 /> : <Trash2 className=\"text-destructive\" />}\n </Button>\n ) : null}\n </td>\n </tr>\n )\n })}\n </tbody>\n </table>\n </div>\n )}\n </section>\n )\n}\n",
29
29
  "target": "inertia/components/ui/resource-field.tsx"
30
30
  }
31
31
  ],
@@ -18,7 +18,7 @@
18
18
  {
19
19
  "path": "registry/ui/resource-form.tsx",
20
20
  "type": "registry:ui",
21
- "content": "import { useState, type FormEvent } from 'react'\nimport { router } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport axios from 'axios'\nimport { toast } from 'sonner'\nimport { RefreshCw, Save } from 'lucide-react'\nimport type { RecordPermissions, ResourceEditor, ResourceField as Field } from '@adula/kit'\nimport { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'\nimport { Button } from '~/components/ui/button'\nimport { ResourceActions, failureMessage, isConflict } from '~/components/ui/resource-actions'\nimport {\n InlineRows,\n ResourceField,\n ResourceSelect,\n type InlineRow,\n} from '~/components/ui/resource-field'\nimport { Label } from '~/components/ui/label'\nimport { inputValue, isAttachment, parseValue } from '~/components/ui/resource-value'\n\ntype Failure = { message: string; conflict: boolean }\ntype RowErrors = Record<string, Record<string, Record<string, string>>>\ntype ServerError = { field?: string; message: string }\n\nfunction initialValue(field: Field, value: unknown) {\n if (field.type === 'boolean' && (value === null || value === undefined)) return 'false'\n return inputValue(field, value)\n}\n\nexport function ResourceForm({\n editor,\n permissions = {},\n}: {\n editor: ResourceEditor\n permissions?: RecordPermissions\n}) {\n const base = `/resources/${editor.name}`\n const scalar = editor.fields.filter((field) => field.type !== 'hasMany')\n const inlineFields = editor.fields.filter(\n (field) => field.type === 'hasMany' && field.inline && editor.inline[field.key]\n )\n const [values, setValues] = useState<Record<string, string>>(() =>\n Object.fromEntries(\n scalar.map((field) => [field.key, initialValue(field, editor.record?.[field.key])])\n )\n )\n const [rows, setRows] = useState<Record<string, InlineRow[]>>(() =>\n Object.fromEntries(\n inlineFields.map((field) => {\n const definition = editor.inline[field.key]\n return [\n field.key,\n definition.rows.map((row) => ({\n key: `row-${row.id}`,\n id: Number(row.id),\n version:\n row.version === undefined || row.version === null ? undefined : Number(row.version),\n values: Object.fromEntries(\n definition.fields.map((child) => [child.key, initialValue(child, row[child.key])])\n ),\n })),\n ]\n })\n )\n )\n const [unit, setUnit] = useState(\n String(editor.record?.orgUnitId ?? editor.orgUnits[0]?.value ?? '')\n )\n const [failure, setFailure] = useState<Failure | null>(null)\n const [errors, setErrors] = useState<Record<string, string>>({})\n const [rowErrors, setRowErrors] = useState<RowErrors>({})\n const [sectionErrors, setSectionErrors] = useState<Record<string, string>>({})\n const [busy, setBusy] = useState(false)\n\n const inlinePayload = (field: Field, collected: RowErrors) => {\n const definition = editor.inline[field.key]\n const list = rows[field.key] ?? []\n const changed = list.filter((row) =>\n row.id === undefined ? !row.deleted : Boolean(row.dirty || row.deleted)\n )\n const payload = changed.map((row) => {\n if (row.deleted) return { id: row.id, version: row.version, _delete: true }\n const allowed =\n row.id === undefined\n ? definition.createFields\n : (definition.updateFields[String(row.id)] ?? [])\n const entry: Record<string, unknown> =\n row.id === undefined ? {} : { id: row.id, version: row.version }\n for (const child of definition.fields) {\n if (!allowed.includes(child.key)) continue\n const raw = row.values[child.key] ?? ''\n if (raw === '' && !child.required && row.id === undefined) continue\n try {\n const parsed = parseValue(child, raw)\n if (parsed === null && child.required) throw new Error('هذا الحقل مطلوب')\n entry[child.key] = parsed\n } catch (error) {\n collected[field.key] ??= {}\n collected[field.key][row.key] ??= {}\n collected[field.key][row.key][child.key] = (error as Error).message\n }\n }\n return entry\n })\n return changed.length ? payload : undefined\n }\n\n const mapServerErrors = (entries: ServerError[]) => {\n const fieldErrors: Record<string, string> = {}\n const sections: Record<string, string> = {}\n const unknown: string[] = []\n for (const entry of entries) {\n const key = entry.field ?? ''\n if (scalar.some((field) => field.key === key)) {\n fieldErrors[key] = entry.message\n continue\n }\n const section = inlineFields.find((field) =>\n editor.inline[field.key].fields.some(\n (child) => child.key === key || key.endsWith(`.${child.key}`)\n )\n )\n if (section) {\n const child = editor.inline[section.key].fields.find(\n (candidate) => candidate.key === key || key.endsWith(`.${candidate.key}`)\n )\n // Nested validators report the child field only, never which row; surface it on the section.\n sections[section.key] = `${child?.label.ar ?? key}: ${entry.message}`\n continue\n }\n unknown.push(key ? `${key}: ${entry.message}` : entry.message)\n }\n setErrors(fieldErrors)\n setSectionErrors(sections)\n return unknown\n }\n\n const submit = async (event: FormEvent) => {\n event.preventDefault()\n setFailure(null)\n setErrors({})\n setRowErrors({})\n setSectionErrors({})\n const payload: Record<string, unknown> = {}\n const fieldErrors: Record<string, string> = {}\n const collected: RowErrors = {}\n for (const field of editor.fields) {\n if (field.type === 'hasMany') {\n if (!editor.inline[field.key]) continue\n const inline = inlinePayload(field, collected)\n if (inline) payload[field.key] = inline\n continue\n }\n const raw = values[field.key] ?? ''\n // Updates submit the whole form: the resource validator checks the submitted\n // document, and `version` — not a partial payload — protects concurrent edits.\n if (editor.mode === 'create' && raw === '' && !field.required) continue\n try {\n const parsed = parseValue(field, raw)\n if (parsed === null && field.required) throw new Error('هذا الحقل مطلوب')\n payload[field.key] = parsed\n } catch (error) {\n fieldErrors[field.key] = (error as Error).message\n }\n }\n if (Object.keys(fieldErrors).length || Object.keys(collected).length) {\n setErrors(fieldErrors)\n setRowErrors(collected)\n setFailure({ message: 'راجع الحقول المحددة ثم أعد المحاولة.', conflict: false })\n return\n }\n if (editor.scoped) payload.orgUnitId = Number(unit)\n if (editor.record?.version !== undefined && editor.record.version !== null)\n payload.version = editor.record.version\n setBusy(true)\n try {\n const response = await axios.request<{ data: { id: number } }>({\n method: editor.mode === 'create' ? 'post' : 'patch',\n url: base + (editor.record ? `/${editor.record.id}` : ''),\n data: payload,\n headers: { Accept: 'application/json' },\n withXSRFToken: true,\n })\n toast.success(editor.mode === 'create' ? 'تمت إضافة السجل' : 'تم حفظ التعديلات')\n router.visit(`${base}/${response.data.data.id}`)\n } catch (error) {\n const unknown =\n axios.isAxiosError(error) && Array.isArray(error.response?.data?.errors)\n ? mapServerErrors(error.response.data.errors as ServerError[])\n : []\n const conflict = isConflict(error)\n setFailure({\n conflict,\n message: conflict\n ? 'تغير السجل منذ فتحه. احتفظ بتعديلاتك ثم أعد تحميل الصفحة.'\n : unknown.length\n ? unknown.join(' · ')\n : failureMessage(error, 'تعذر الحفظ. راجع البيانات وحاول مجدداً.'),\n })\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <form onSubmit={submit} noValidate className=\"max-w-4xl space-y-6\" aria-busy={busy}>\n {failure && (\n <Alert variant=\"destructive\" role=\"alert\">\n <AlertTitle>{failure.conflict ? 'تعارض في الإصدار' : 'تعذر الحفظ'}</AlertTitle>\n <AlertDescription className=\"flex flex-wrap items-center gap-3\">\n {failure.message}\n {failure.conflict && (\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => router.reload()}>\n <RefreshCw size={14} />\n إعادة التحميل\n </Button>\n )}\n </AlertDescription>\n </Alert>\n )}\n <section className=\"rounded-xl border bg-white\">\n <div className=\"border-b px-7 py-5\">\n <h2 className=\"text-sm font-semibold\">تفاصيل السجل</h2>\n <p className=\"mt-2 text-xs text-muted-foreground\">\n الحقول المتاحة بحسب صلاحياتك · الحقول المعلّمة بـ{' '}\n <span className=\"text-destructive\">*</span> مطلوبة\n </p>\n </div>\n <div className=\"grid gap-6 p-7 md:grid-cols-2\">\n {editor.scoped && (\n <div className=\"min-w-0 space-y-2\">\n <Label htmlFor=\"orgUnitId\" className=\"flex items-center gap-2 text-sm font-medium\">\n الوحدة التنظيمية\n <span className=\"text-destructive\" aria-hidden=\"true\">\n *\n </span>\n </Label>\n <ResourceSelect\n id=\"orgUnitId\"\n required\n value={unit}\n disabled={busy}\n onChange={setUnit}\n options={editor.orgUnits}\n />\n </div>\n )}\n {scalar.map((field) => {\n const stored = editor.record?.[field.key]\n return (\n <ResourceField\n key={field.key}\n field={field}\n id={field.key}\n value={values[field.key] ?? ''}\n options={editor.options[field.key]}\n error={errors[field.key]}\n disabled={busy}\n resource={editor.name}\n attachment={field.type === 'attachment' && isAttachment(stored) ? stored : null}\n onChange={(value) => setValues((current) => ({ ...current, [field.key]: value }))}\n relation={\n field.type === 'belongsTo'\n ? {\n url: `${base}/options/${field.key}${editor.record ? `?id=${editor.record.id}` : ''}`,\n searchable: editor.relationSearch[field.key] ?? false,\n }\n : undefined\n }\n />\n )\n })}\n </div>\n </section>\n {inlineFields.map((field) => (\n <InlineRows\n key={field.key}\n field={field}\n inline={editor.inline[field.key]}\n rows={rows[field.key] ?? []}\n errors={rowErrors[field.key]}\n sectionError={sectionErrors[field.key]}\n disabled={busy}\n resource={editor.name}\n onChange={(next) => setRows((current) => ({ ...current, [field.key]: next }))}\n />\n ))}\n {editor.record?.version !== undefined && editor.record.version !== null && (\n <input type=\"hidden\" name=\"version\" value={String(editor.record.version)} />\n )}\n <div className=\"flex flex-wrap items-center justify-between gap-3\">\n <div className=\"flex flex-wrap gap-2\">\n {editor.record && (\n <ResourceActions\n resource={editor.name}\n id={editor.record.id as number}\n version={editor.record.version ?? undefined}\n permissions={permissions}\n onDone={(action) =>\n router.visit(action === 'delete' ? base : `${base}/${editor.record?.id}`)\n }\n />\n )}\n </div>\n <div className=\"flex gap-3\">\n <Button type=\"button\" variant=\"outline\" asChild>\n <Link href={editor.record ? `${base}/${editor.record.id}` : base}>إلغاء</Link>\n </Button>\n <Button type=\"submit\" disabled={busy}>\n <Save size={16} />\n {busy ? 'جارٍ الحفظ...' : 'حفظ السجل'}\n </Button>\n </div>\n </div>\n </form>\n )\n}\n",
21
+ "content": "import { useState, type FormEvent } from 'react'\nimport { router } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport axios from 'axios'\nimport { toast } from 'sonner'\nimport { RefreshCw, Save } from 'lucide-react'\nimport type { RecordPermissions, ResourceEditor, ResourceField as Field } from '@adula/kit'\nimport { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'\nimport { Button } from '~/components/ui/button'\nimport { ResourceActions, failureMessage, isConflict } from '~/components/ui/resource-actions'\nimport {\n InlineRows,\n ResourceField,\n ResourceSelect,\n type InlineRow,\n} from '~/components/ui/resource-field'\nimport { Label } from '~/components/ui/label'\nimport { inputValue, isAttachment, parseValue } from '~/components/ui/resource-value'\n\ntype Failure = { message: string; conflict: boolean }\ntype RowErrors = Record<string, Record<string, Record<string, string>>>\ntype ServerError = { field?: string; message: string }\n\nfunction initialValue(field: Field, value: unknown) {\n if (field.type === 'boolean' && (value === null || value === undefined)) return 'false'\n return inputValue(field, value)\n}\n\nexport function ResourceForm({\n editor,\n permissions = {},\n onSaved,\n onCancel,\n onAction,\n}: {\n editor: ResourceEditor\n permissions?: RecordPermissions\n /** Called with the saved record id instead of visiting its page (record dialogs). */\n onSaved?: (id: number) => void\n /** Replaces the cancel link (record dialogs return to the details). */\n onCancel?: () => void\n /** Called after a record action (submit, delete...) instead of visiting a page. */\n onAction?: (action: string) => void\n}) {\n const base = `/resources/${editor.name}`\n const scalar = editor.fields.filter((field) => field.type !== 'hasMany')\n const inlineFields = editor.fields.filter(\n (field) => field.type === 'hasMany' && field.inline && editor.inline[field.key]\n )\n const [values, setValues] = useState<Record<string, string>>(() =>\n Object.fromEntries(\n scalar.map((field) => [\n field.key,\n initialValue(field, editor.record?.[field.key] ?? editor.defaults?.[field.key]),\n ])\n )\n )\n const [rows, setRows] = useState<Record<string, InlineRow[]>>(() =>\n Object.fromEntries(\n inlineFields.map((field) => {\n const definition = editor.inline[field.key]\n return [\n field.key,\n definition.rows.map((row) => ({\n key: `row-${row.id}`,\n id: Number(row.id),\n version:\n row.version === undefined || row.version === null ? undefined : Number(row.version),\n values: Object.fromEntries(\n definition.fields.map((child) => [child.key, initialValue(child, row[child.key])])\n ),\n })),\n ]\n })\n )\n )\n const [unit, setUnit] = useState(\n String(editor.record?.orgUnitId ?? editor.orgUnits[0]?.value ?? '')\n )\n const [failure, setFailure] = useState<Failure | null>(null)\n const [errors, setErrors] = useState<Record<string, string>>({})\n const [rowErrors, setRowErrors] = useState<RowErrors>({})\n const [sectionErrors, setSectionErrors] = useState<Record<string, string>>({})\n const [busy, setBusy] = useState(false)\n\n const inlinePayload = (field: Field, collected: RowErrors) => {\n const definition = editor.inline[field.key]\n const list = rows[field.key] ?? []\n const changed = list.filter((row) =>\n row.id === undefined ? !row.deleted : Boolean(row.dirty || row.deleted)\n )\n const payload = changed.map((row) => {\n if (row.deleted) return { id: row.id, version: row.version, _delete: true }\n const allowed =\n row.id === undefined\n ? definition.createFields\n : (definition.updateFields[String(row.id)] ?? [])\n const entry: Record<string, unknown> =\n row.id === undefined ? {} : { id: row.id, version: row.version }\n for (const child of definition.fields) {\n if (!allowed.includes(child.key)) continue\n const raw = row.values[child.key] ?? ''\n if (raw === '' && !child.required && row.id === undefined) continue\n try {\n const parsed = parseValue(child, raw)\n if (parsed === null && child.required) throw new Error('هذا الحقل مطلوب')\n entry[child.key] = parsed\n } catch (error) {\n collected[field.key] ??= {}\n collected[field.key][row.key] ??= {}\n collected[field.key][row.key][child.key] = (error as Error).message\n }\n }\n return entry\n })\n return changed.length ? payload : undefined\n }\n\n const mapServerErrors = (entries: ServerError[]) => {\n const fieldErrors: Record<string, string> = {}\n const sections: Record<string, string> = {}\n const unknown: string[] = []\n for (const entry of entries) {\n const key = entry.field ?? ''\n if (scalar.some((field) => field.key === key)) {\n fieldErrors[key] = entry.message\n continue\n }\n const section = inlineFields.find((field) =>\n editor.inline[field.key].fields.some(\n (child) => child.key === key || key.endsWith(`.${child.key}`)\n )\n )\n if (section) {\n const child = editor.inline[section.key].fields.find(\n (candidate) => candidate.key === key || key.endsWith(`.${candidate.key}`)\n )\n // Nested validators report the child field only, never which row; surface it on the section.\n sections[section.key] = `${child?.label.ar ?? key}: ${entry.message}`\n continue\n }\n unknown.push(key ? `${key}: ${entry.message}` : entry.message)\n }\n setErrors(fieldErrors)\n setSectionErrors(sections)\n return unknown\n }\n\n const submit = async (event: FormEvent) => {\n event.preventDefault()\n setFailure(null)\n setErrors({})\n setRowErrors({})\n setSectionErrors({})\n const payload: Record<string, unknown> = {}\n const fieldErrors: Record<string, string> = {}\n const collected: RowErrors = {}\n for (const field of editor.fields) {\n if (field.type === 'hasMany') {\n if (!editor.inline[field.key]) continue\n const inline = inlinePayload(field, collected)\n if (inline) payload[field.key] = inline\n continue\n }\n const raw = values[field.key] ?? ''\n // Updates submit the whole form: the resource validator checks the submitted\n // document, and `version` — not a partial payload — protects concurrent edits.\n if (editor.mode === 'create' && raw === '' && !field.required) continue\n try {\n const parsed = parseValue(field, raw)\n if (parsed === null && field.required) throw new Error('هذا الحقل مطلوب')\n payload[field.key] = parsed\n } catch (error) {\n fieldErrors[field.key] = (error as Error).message\n }\n }\n if (Object.keys(fieldErrors).length || Object.keys(collected).length) {\n setErrors(fieldErrors)\n setRowErrors(collected)\n setFailure({ message: 'راجع الحقول المحددة ثم أعد المحاولة.', conflict: false })\n return\n }\n if (editor.scoped && !editor.scopeFrom) payload.orgUnitId = Number(unit)\n if (editor.record?.version !== undefined && editor.record.version !== null)\n payload.version = editor.record.version\n setBusy(true)\n try {\n const response = await axios.request<{ data: { id: number } }>({\n method: editor.mode === 'create' ? 'post' : 'patch',\n url: base + (editor.record ? `/${editor.record.id}` : ''),\n data: payload,\n headers: { Accept: 'application/json' },\n withXSRFToken: true,\n })\n toast.success(\n editor.mode === 'create'\n ? editor.recordLabel\n ? `تمت إضافة ${editor.recordLabel}`\n : 'تمت إضافة السجل'\n : 'تم حفظ التعديلات'\n )\n if (onSaved) onSaved(response.data.data.id)\n // The details replace the form in the same dialog instead of closing and reopening.\n else router.visit(`${base}/${response.data.data.id}`, { preserveState: true })\n } catch (error) {\n const unknown =\n axios.isAxiosError(error) && Array.isArray(error.response?.data?.errors)\n ? mapServerErrors(error.response.data.errors as ServerError[])\n : []\n const conflict = isConflict(error)\n setFailure({\n conflict,\n message: conflict\n ? 'تغير السجل منذ فتحه. احتفظ بتعديلاتك ثم أعد تحميل الصفحة.'\n : unknown.length\n ? unknown.join(' · ')\n : failureMessage(error, 'تعذر الحفظ. راجع البيانات وحاول مجدداً.'),\n })\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <form onSubmit={submit} noValidate className=\"max-w-4xl space-y-6\" aria-busy={busy}>\n {failure && (\n <Alert variant=\"destructive\" role=\"alert\">\n <AlertTitle>{failure.conflict ? 'تعارض في الإصدار' : 'تعذر الحفظ'}</AlertTitle>\n <AlertDescription className=\"flex flex-wrap items-center gap-3\">\n {failure.message}\n {failure.conflict && (\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => router.reload()}>\n <RefreshCw size={14} />\n إعادة التحميل\n </Button>\n )}\n </AlertDescription>\n </Alert>\n )}\n <section className=\"rounded-xl border bg-white\">\n <div className=\"border-b px-7 py-5\">\n <h2 className=\"text-sm font-semibold\">تفاصيل السجل</h2>\n <p className=\"mt-2 text-xs text-muted-foreground\">\n الحقول المتاحة بحسب صلاحياتك · الحقول المعلّمة بـ{' '}\n <span className=\"text-destructive\">*</span> مطلوبة\n </p>\n </div>\n <div className=\"grid gap-6 p-7 md:grid-cols-2\">\n {editor.scoped && !editor.scopeFrom && (\n <div className=\"min-w-0 space-y-2\">\n <Label htmlFor=\"orgUnitId\" className=\"flex items-center gap-2 text-sm font-medium\">\n الوحدة التنظيمية\n <span className=\"text-destructive\" aria-hidden=\"true\">\n *\n </span>\n </Label>\n <ResourceSelect\n id=\"orgUnitId\"\n required\n value={unit}\n disabled={busy}\n onChange={setUnit}\n options={editor.orgUnits}\n />\n </div>\n )}\n {scalar.map((field) => {\n const stored = editor.record?.[field.key]\n return (\n <ResourceField\n // User choices depend on the unit: a new unit reloads them.\n key={field.type === 'user' ? `${field.key}:${unit}` : field.key}\n field={field}\n id={field.key}\n value={values[field.key] ?? ''}\n options={editor.options[field.key]}\n error={errors[field.key]}\n disabled={busy}\n resource={editor.name}\n attachment={field.type === 'attachment' && isAttachment(stored) ? stored : null}\n onChange={(value) => setValues((current) => ({ ...current, [field.key]: value }))}\n relation={\n field.type === 'belongsTo'\n ? {\n url: `${base}/options/${field.key}${editor.record ? `?id=${editor.record.id}` : ''}`,\n searchable: editor.relationSearch[field.key] ?? false,\n }\n : field.type === 'user'\n ? {\n url: `${base}/options/${field.key}?${new URLSearchParams({\n ...(editor.record ? { id: String(editor.record.id) } : {}),\n // An inherited unit is resolved on the server from the record.\n ...(editor.scoped && !editor.scopeFrom && unit\n ? { orgUnitId: unit }\n : {}),\n })}`,\n searchable: true,\n }\n : undefined\n }\n />\n )\n })}\n </div>\n </section>\n {inlineFields.map((field) => (\n <InlineRows\n key={field.key}\n field={field}\n inline={editor.inline[field.key]}\n rows={rows[field.key] ?? []}\n errors={rowErrors[field.key]}\n sectionError={sectionErrors[field.key]}\n disabled={busy}\n resource={editor.name}\n onChange={(next) => setRows((current) => ({ ...current, [field.key]: next }))}\n />\n ))}\n {editor.record?.version !== undefined && editor.record.version !== null && (\n <input type=\"hidden\" name=\"version\" value={String(editor.record.version)} />\n )}\n <div className=\"flex flex-wrap items-center justify-between gap-3\">\n <div className=\"flex flex-wrap gap-2\">\n {editor.record && (\n <ResourceActions\n resource={editor.name}\n id={editor.record.id as number}\n version={editor.record.version ?? undefined}\n permissions={permissions}\n onDone={(action) =>\n onAction\n ? onAction(action)\n : router.visit(action === 'delete' ? base : `${base}/${editor.record?.id}`)\n }\n />\n )}\n </div>\n <div className=\"flex gap-3\">\n {onCancel ? (\n <Button type=\"button\" variant=\"outline\" onClick={onCancel}>\n إلغاء\n </Button>\n ) : (\n <Button type=\"button\" variant=\"outline\" asChild>\n <Link href={editor.record ? `${base}/${editor.record.id}` : base}>إلغاء</Link>\n </Button>\n )}\n <Button type=\"submit\" disabled={busy}>\n <Save size={16} />\n {busy ? 'جارٍ الحفظ...' : 'حفظ السجل'}\n </Button>\n </div>\n </div>\n </form>\n )\n}\n",
22
22
  "target": "inertia/components/ui/resource-form.tsx"
23
23
  }
24
24
  ],
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "resource-import",
3
+ "type": "registry:ui",
4
+ "registryDependencies": [
5
+ "button",
6
+ "input",
7
+ "label",
8
+ "dialog",
9
+ "select"
10
+ ],
11
+ "dependencies": [
12
+ "lucide-react@1.47.0",
13
+ "axios@1.19.0"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "registry/ui/resource-import.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import { useState } from 'react'\nimport { router } from '@inertiajs/react'\nimport axios from 'axios'\nimport { FileUp } from 'lucide-react'\nimport type { ImportBatch } from '@adula/kit'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '~/components/ui/dialog'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/components/ui/select'\n\nconst json = { headers: { Accept: 'application/json' }, withXSRFToken: true }\nconst SKIP = '__skip__'\n\n/** Upload a CSV, map its columns to writable fields, then queue the import. */\nexport function ResourceImport({ resource, label }: { resource: string; label: string }) {\n const [open, setOpen] = useState(false)\n const [file, setFile] = useState<File | null>(null)\n const [batch, setBatch] = useState<ImportBatch | null>(null)\n const [mapping, setMapping] = useState<Record<string, string>>({})\n const [error, setError] = useState('')\n const [busy, setBusy] = useState(false)\n const reset = () => {\n setFile(null)\n setBatch(null)\n setMapping({})\n setError('')\n }\n const failure = (caught: unknown, fallback: string) =>\n setError(\n axios.isAxiosError(caught) && caught.response?.data?.error?.message\n ? String(caught.response.data.error.message)\n : fallback\n )\n const upload = async (event: React.FormEvent) => {\n event.preventDefault()\n if (!file) return\n setBusy(true)\n setError('')\n try {\n const form = new FormData()\n form.append('file', file)\n const response = await axios.post<{ data: ImportBatch }>(\n `/resources/${resource}/imports`,\n form,\n json\n )\n setBatch(response.data.data)\n setMapping(response.data.data.mapping)\n } catch (caught) {\n failure(caught, 'تعذر قراءة الملف.')\n } finally {\n setBusy(false)\n }\n }\n const start = async (event: React.FormEvent) => {\n event.preventDefault()\n if (!batch) return\n setBusy(true)\n setError('')\n try {\n await axios.post(`/imports/${batch.id}/start`, { mapping }, json)\n setOpen(false)\n reset()\n router.visit('/imports')\n } catch (caught) {\n failure(caught, 'تعذر بدء الاستيراد.')\n } finally {\n setBusy(false)\n }\n }\n return (\n <>\n <Button variant=\"outline\" onClick={() => setOpen(true)}>\n <FileUp size={16} />\n استيراد CSV\n </Button>\n <Dialog\n open={open}\n onOpenChange={(value) => {\n setOpen(value)\n if (!value) reset()\n }}\n >\n <DialogContent dir=\"rtl\" className=\"sm:max-w-2xl\">\n <DialogHeader>\n <DialogTitle>استيراد إلى {label}</DialogTitle>\n <DialogDescription>\n {batch\n ? `طابِق أعمدة الملف (${batch.total} صفاً) مع الحقول، ثم ابدأ الاستيراد.`\n : 'ملف CSV بترميز UTF-8، صفه الأول عناوين الأعمدة، حتى 5000 صف.'}\n </DialogDescription>\n </DialogHeader>\n {!batch ? (\n <form id=\"import-upload\" className=\"space-y-2\" onSubmit={upload}>\n <Label htmlFor=\"import-file\">الملف</Label>\n <Input\n id=\"import-file\"\n type=\"file\"\n accept=\".csv,text/csv\"\n onChange={(event) => setFile(event.target.files?.[0] ?? null)}\n />\n </form>\n ) : (\n <form\n id=\"import-mapping\"\n className=\"max-h-96 space-y-3 overflow-y-auto\"\n onSubmit={start}\n >\n {batch.headers.map((header, index) => (\n <div key={index} className=\"grid grid-cols-2 items-center gap-3\">\n <div className=\"min-w-0\">\n <p className=\"truncate text-sm font-medium\">{header || `عمود ${index + 1}`}</p>\n <p className=\"truncate text-xs text-muted-foreground\">\n مثال: {batch.sample[0]?.[index] || '—'}\n </p>\n </div>\n <Select\n value={mapping[String(index)] ?? SKIP}\n onValueChange={(value) =>\n setMapping((current) => {\n const next = { ...current }\n if (value === SKIP) delete next[String(index)]\n else next[String(index)] = value\n return next\n })\n }\n >\n <SelectTrigger aria-label={`حقل العمود ${header || index + 1}`}>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value={SKIP}>تجاهل العمود</SelectItem>\n {batch.targets.map((target) => (\n <SelectItem key={target.key} value={target.key}>\n {target.label}\n {target.required ? ' *' : ''}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n ))}\n </form>\n )}\n {error && (\n <p role=\"alert\" className=\"rounded-lg bg-red-50 px-3 py-2 text-xs text-red-800\">\n {error}\n </p>\n )}\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => setOpen(false)}>\n إلغاء\n </Button>\n {batch ? (\n <Button type=\"submit\" form=\"import-mapping\" disabled={busy}>\n بدء الاستيراد\n </Button>\n ) : (\n <Button type=\"submit\" form=\"import-upload\" disabled={busy || !file}>\n رفع الملف\n </Button>\n )}\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n",
20
+ "target": "inertia/components/ui/resource-import.tsx"
21
+ }
22
+ ],
23
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json"
24
+ }
@@ -6,7 +6,8 @@
6
6
  "data-table",
7
7
  "resource-form",
8
8
  "resource-show",
9
- "resource-surface"
9
+ "resource-surface",
10
+ "resource-import"
10
11
  ],
11
12
  "dependencies": [
12
13
  "lucide-react@1.47.0"
@@ -15,7 +16,7 @@
15
16
  {
16
17
  "path": "registry/ui/resource-page.tsx",
17
18
  "type": "registry:ui",
18
- "content": "import { Head } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { Plus } from 'lucide-react'\nimport type {\n RecordPermissions,\n ResourceActivity,\n ResourceChildren,\n ResourceDescription,\n ResourceEditor,\n ResourceList,\n ResourceLookups,\n SavedView,\n ResourceShow as Show,\n} from '@adula/kit'\nimport { Button } from '~/components/ui/button'\nimport { DataTable } from '~/components/ui/data-table'\nimport { ResourceForm } from '~/components/ui/resource-form'\nimport { ResourceShow } from '~/components/ui/resource-show'\nimport { ResourceSurface, type ResourcePresentation } from '~/components/ui/resource-surface'\n\n/** The discriminated view stays nested: Inertia's page typing flattens top-level unions. */\nexport type ResourceView =\n | {\n mode: 'index'\n resource: ResourceDescription\n lookups: ResourceLookups\n savedViews: SavedView[]\n }\n | { mode: 'form'; editor: ResourceEditor; permissions: RecordPermissions }\n | {\n mode: 'show'\n resource: ResourceDescription\n result: Show\n lookups: ResourceLookups\n childResources: Record<string, ResourceDescription>\n }\nexport type ResourcePageProps = {\n view: ResourceView\n /** Index pages receive the list as a top-level `inertia.scroll` prop so pages merge into `result.data`. */\n result?: ResourceList\n childrenData?: ResourceChildren\n activity?: ResourceActivity\n /** Set to page only when the user requests a non-modal form/detail override. */\n presentation?: ResourcePresentation\n}\n\nexport function ResourcePage({\n view,\n result,\n childrenData,\n activity,\n presentation,\n}: ResourcePageProps) {\n const name = view.mode === 'form' ? view.editor.name : view.resource.name\n const label = view.mode === 'form' ? view.editor.label : view.resource.label\n const title =\n view.mode === 'index'\n ? label\n : view.mode === 'form'\n ? `${view.editor.mode === 'create' ? 'إضافة' : 'تعديل'} · ${label}`\n : `تفاصيل · ${label}`\n return (\n <>\n <Head title={title} />\n {view.mode === 'index' && (\n <div className=\"mb-8 flex flex-wrap items-start justify-between gap-5\">\n <div>\n <h1 className=\"text-3xl font-semibold tracking-tight\">{title}</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">\n سجلاتك ومتابعتك اليومية في مكان واحد.\n </p>\n </div>\n {view.mode === 'index' && view.resource.canCreate && (\n <Button asChild>\n <Link href={`/resources/${name}/create`}>\n <Plus size={16} />\n إضافة سجل\n </Link>\n </Button>\n )}\n </div>\n )}\n {view.mode === 'index' ? (\n result ? (\n <DataTable\n key={name}\n resource={view.resource}\n result={result}\n lookups={view.lookups}\n savedViews={view.savedViews}\n />\n ) : null\n ) : (\n <ResourceSurface\n key={`${name}:${view.mode}:${view.mode === 'form' ? (view.editor.record?.id ?? 'new') : view.result.data.id}`}\n mode={view.mode === 'form' ? 'edit' : 'view'}\n title={title}\n description={\n view.mode === 'form'\n ? 'أكمل الحقول المطلوبة ثم احفظ السجل.'\n : 'راجع التفاصيل المتاحة لك وأكمل عملك.'\n }\n backHref={`/resources/${name}`}\n presentation={presentation}\n >\n {view.mode === 'form' ? (\n <ResourceForm\n key={`${name}:${view.editor.record?.id ?? 'new'}`}\n editor={view.editor}\n permissions={view.permissions}\n />\n ) : (\n <ResourceShow\n key={`${name}:${view.result.data.id}`}\n resource={view.resource}\n result={view.result}\n lookups={view.lookups}\n childResources={view.childResources}\n childrenData={childrenData}\n activity={activity}\n />\n )}\n </ResourceSurface>\n )}\n </>\n )\n}\n",
19
+ "content": "import { Head, usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { Plus } from 'lucide-react'\nimport type {\n RecordPermissions,\n ResourceActivity,\n ResourceChildren,\n ResourceDescription,\n ResourceEditor,\n ResourceList,\n ResourceLookups,\n SavedView,\n ResourceShow as Show,\n} from '@adula/kit'\nimport { Button } from '~/components/ui/button'\nimport { DataTable } from '~/components/ui/data-table'\nimport { ResourceForm } from '~/components/ui/resource-form'\nimport { ResourceShow } from '~/components/ui/resource-show'\nimport { ResourceImport } from '~/components/ui/resource-import'\nimport {\n ResourceSurface,\n closeRecord,\n openRecord,\n type RecordOverlay,\n type ResourcePresentation,\n} from '~/components/ui/resource-surface'\n\n/** The discriminated view stays nested: Inertia's page typing flattens top-level unions. */\nexport type ResourceView =\n | {\n mode: 'index'\n resource: ResourceDescription\n lookups: ResourceLookups\n savedViews: SavedView[]\n }\n | { mode: 'form'; editor: ResourceEditor; permissions: RecordPermissions }\n | {\n mode: 'show'\n resource: ResourceDescription\n result: Show\n lookups: ResourceLookups\n childResources: Record<string, ResourceDescription>\n }\nexport type ResourcePageProps = {\n view: ResourceView\n /** Index pages receive the list as a top-level `inertia.scroll` prop so pages merge into `result.data`. */\n result?: ResourceList\n childrenData?: ResourceChildren\n activity?: ResourceActivity\n /** Set to page only when the user requests a non-modal form/detail override. */\n presentation?: ResourcePresentation\n}\ntype RecordView = Exclude<ResourceView, { mode: 'index' }>\n\n/** Title of a record dialog: «إضافة حساب», «تعديل · الحسابات» or «تفاصيل · الحسابات». */\nfunction recordTitle(view: RecordView) {\n if (view.mode === 'show') return `تفاصيل · ${view.resource.label}`\n const verb = view.editor.mode === 'create' ? 'إضافة' : 'تعديل'\n // A singular record noun («حساب») reads better than the plural list label (#37).\n return view.editor.recordLabel\n ? `${verb} ${view.editor.recordLabel}`\n : `${verb} · ${view.editor.label}`\n}\n\nfunction RecordBody({\n view,\n childrenData,\n activity,\n onSaved,\n onCancel,\n onEdit,\n onAction,\n}: {\n view: RecordView\n childrenData?: ResourceChildren\n activity?: ResourceActivity\n onSaved?: (id: number) => void\n onCancel?: () => void\n onEdit?: () => void\n onAction?: (action: string) => void\n}) {\n return view.mode === 'form' ? (\n <ResourceForm\n key={`${view.editor.name}:${view.editor.record?.id ?? 'new'}`}\n editor={view.editor}\n permissions={view.permissions}\n onSaved={onSaved}\n onCancel={onCancel}\n onAction={onAction}\n />\n ) : (\n <ResourceShow\n key={`${view.resource.name}:${view.result.data.id}`}\n resource={view.resource}\n result={view.result}\n lookups={view.lookups}\n childResources={view.childResources}\n childrenData={childrenData}\n activity={activity}\n onEdit={onEdit}\n onAction={onAction}\n />\n )\n}\n\nexport function ResourcePage({\n view,\n result,\n childrenData,\n activity,\n presentation,\n}: ResourcePageProps) {\n // A record opened over this list by openRecord(); a client-only prop, never sent by the server.\n const overlay = usePage<{ overlay?: RecordOverlay }>().props.overlay\n const name = view.mode === 'form' ? view.editor.name : view.resource.name\n const label = view.mode === 'form' ? view.editor.label : view.resource.label\n const title = view.mode === 'index' ? label : recordTitle(view)\n const noun = view.mode === 'index' ? view.resource.recordLabel : null\n const createText =\n view.mode === 'index'\n ? (view.resource.createLabel ?? (noun ? `إضافة ${noun}` : 'إضافة سجل'))\n : ''\n const overlayView = view.mode === 'index' ? (overlay?.view as RecordView | undefined) : undefined\n const overlayRecord =\n overlayView?.mode === 'form' ? overlayView.editor.record?.id : overlayView?.result.data.id\n const overlayId =\n overlayRecord === undefined || overlayRecord === null ? undefined : String(overlayRecord)\n return (\n <>\n <Head title={title} />\n {view.mode === 'index' && (\n <div className=\"mb-8 flex flex-wrap items-start justify-between gap-5\">\n <div>\n <h1 className=\"text-3xl font-semibold tracking-tight\">{title}</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">\n سجلاتك ومتابعتك اليومية في مكان واحد.\n </p>\n </div>\n {view.mode === 'index' && view.resource.canCreate && (\n <div className=\"flex flex-wrap gap-2\">\n <ResourceImport resource={name} label={label} />\n <Button asChild>\n <Link href={`/resources/${name}/create`}>\n <Plus size={16} />\n {createText}\n </Link>\n </Button>\n </div>\n )}\n </div>\n )}\n {view.mode === 'index' ? (\n <>\n {result ? (\n <DataTable\n key={name}\n resource={view.resource}\n result={result}\n lookups={view.lookups}\n savedViews={view.savedViews}\n />\n ) : null}\n {overlay && overlayView && overlayId !== undefined && (\n // Keyed by record, so saving an edit switches the same dialog to the details.\n <ResourceSurface\n key={`overlay:${name}:${overlayId}`}\n mode={overlayView.mode === 'form' ? 'edit' : 'view'}\n title={recordTitle(overlayView)}\n description={\n overlayView.mode === 'form'\n ? 'أكمل الحقول المطلوبة ثم احفظ السجل.'\n : 'راجع التفاصيل المتاحة لك وأكمل عملك.'\n }\n backHref={overlay.list}\n onClose={() => closeRecord(overlay)}\n >\n <RecordBody\n view={overlayView}\n childrenData={childrenData}\n activity={activity}\n onSaved={(id) =>\n void openRecord(name, id, 'show', { replace: true, changed: true })\n }\n onCancel={() => void openRecord(name, overlayId, 'show', { replace: true })}\n onEdit={() => void openRecord(name, overlayId, 'edit', { replace: true })}\n onAction={(action) =>\n action === 'delete'\n ? closeRecord({ ...overlay, changed: true })\n : void openRecord(name, overlayId, 'show', { replace: true, changed: true })\n }\n />\n </ResourceSurface>\n )}\n </>\n ) : (\n <ResourceSurface\n // Keyed by record, not mode: saving an edit shows the details in the same dialog.\n key={`${name}:${view.mode === 'form' ? (view.editor.record?.id ?? 'new') : view.result.data.id}`}\n mode={view.mode === 'form' ? 'edit' : 'view'}\n title={title}\n description={\n view.mode === 'form'\n ? 'أكمل الحقول المطلوبة ثم احفظ السجل.'\n : 'راجع التفاصيل المتاحة لك وأكمل عملك.'\n }\n backHref={`/resources/${name}`}\n presentation={presentation}\n >\n <RecordBody view={view} childrenData={childrenData} activity={activity} />\n </ResourceSurface>\n )}\n </>\n )\n}\n",
19
20
  "target": "inertia/components/ui/resource-page.tsx"
20
21
  }
21
22
  ],
@@ -8,7 +8,10 @@
8
8
  "can",
9
9
  "resource-actions",
10
10
  "resource-value",
11
- "ui-preferences"
11
+ "ui-preferences",
12
+ "record-collaboration",
13
+ "record-assignments",
14
+ "record-workflows"
12
15
  ],
13
16
  "dependencies": [
14
17
  "lucide-react@1.47.0"
@@ -17,7 +20,7 @@
17
20
  {
18
21
  "path": "registry/ui/resource-show.tsx",
19
22
  "type": "registry:ui",
20
- "content": "import { Deferred, router } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { History, Pencil } from 'lucide-react'\nimport type {\n ResourceActivity,\n ResourceChildren as Children,\n ResourceDescription,\n ResourceShow as Show,\n SerializedRecord,\n} from '@adula/kit'\nimport { Badge } from '~/components/ui/badge'\nimport { Button } from '~/components/ui/button'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\nimport { Can } from '~/components/ui/can'\nimport { Skeleton } from '~/components/ui/skeleton'\nimport { ResourceActions } from '~/components/ui/resource-actions'\nimport { ResourceValue, formatDatetime, type LookupOptions } from '~/components/ui/resource-value'\n\nexport type ResourceChildren = Children\nconst meta = new Set(['id', 'version', 'docStatus', 'orgUnitId'])\nconst activityLabels: Record<string, string> = {\n create: 'إنشاء السجل',\n update: 'تعديل السجل',\n delete: 'حذف السجل',\n submit: 'اعتماد المستند',\n cancel: 'إلغاء الاعتماد',\n amend: 'تعديل بعد الاعتماد',\n}\n\nexport function documentStatus(status: unknown) {\n return status === 1 ? 'معتمد' : status === 2 ? 'ملغي' : 'مسودة'\n}\n\nexport function ResourceShow({\n resource,\n result,\n lookups = {},\n childResources = {},\n childrenData,\n activity,\n}: {\n resource: ResourceDescription\n result: Show\n lookups?: LookupOptions\n childResources?: Record<string, ResourceDescription>\n childrenData?: ResourceChildren\n activity?: ResourceActivity\n}) {\n const { calendar } = useUiPreferences()\n const base = `/resources/${resource.name}/${result.data.id}`\n const fields = resource.fields.filter(\n (field) =>\n resource.show.includes(field.key) && field.type !== 'hasMany' && field.key in result.data\n )\n const children = resource.fields.filter(\n (field) => field.type === 'hasMany' && resource.show.includes(field.key)\n )\n const fieldLabel = (key: string) =>\n resource.fields.find((field) => field.key === key)?.label.ar ?? key\n return (\n <div className=\"max-w-5xl space-y-6\">\n <div className=\"flex flex-wrap items-center gap-3\">\n <Can permissions={result.permissions} action=\"update\">\n <Button asChild>\n <Link href={`${base}/edit`}>\n <Pencil size={15} />\n تعديل السجل\n </Link>\n </Button>\n </Can>\n <ResourceActions\n resource={resource.name}\n id={result.data.id as number}\n version={result.data.version ?? undefined}\n permissions={result.permissions}\n onDone={(action) =>\n action === 'delete' ? router.visit(`/resources/${resource.name}`) : router.reload()\n }\n />\n {resource.submittable && (\n <Badge\n variant={result.data.docStatus === 1 ? 'default' : 'secondary'}\n className={result.data.docStatus === 2 ? 'bg-red-50 text-red-800' : ''}\n >\n {documentStatus(result.data.docStatus)}\n </Badge>\n )}\n </div>\n <dl className=\"grid gap-7 rounded-xl border bg-white p-7 md:grid-cols-2\">\n {fields.map((field) => (\n <div\n key={field.key}\n className={['text', 'json'].includes(field.type) ? 'md:col-span-2' : ''}\n >\n <dt className=\"mb-2 text-xs text-muted-foreground\">{field.label.ar}</dt>\n <dd className=\"break-words text-sm whitespace-pre-wrap\">\n <ResourceValue\n field={field}\n row={result.data}\n related={result.related}\n lookups={lookups}\n />\n </dd>\n </div>\n ))}\n {resource.scoped && result.data.orgUnitId !== undefined && (\n <div>\n <dt className=\"mb-2 text-xs text-muted-foreground\">الوحدة التنظيمية</dt>\n <dd className=\"text-sm\">#{String(result.data.orgUnitId)}</dd>\n </div>\n )}\n </dl>\n {children.length > 0 && (\n <Deferred data=\"childrenData\" fallback={<SectionSkeleton title=\"البنود\" rows={3} />}>\n <div className=\"space-y-5\">\n {children.map((field) => {\n const group = childrenData?.[field.key]\n if (!group) return null\n const child = childResources[field.key]\n const columns = child\n ? child.fields.filter(\n (entry) => child.list.includes(entry.key) && entry.type !== 'hasMany'\n )\n : []\n const keys = columns.length\n ? columns.map((entry) => entry.key)\n : [...new Set(group.rows.flatMap((row) => Object.keys(row)))].filter(\n (key) => !meta.has(key)\n )\n return (\n <section\n key={field.key}\n aria-label={field.label.ar}\n className=\"rounded-xl border bg-white\"\n >\n <div className=\"flex items-center justify-between border-b px-7 py-4\">\n <h2 className=\"text-sm font-semibold\">{field.label.ar}</h2>\n <span className=\"text-xs text-muted-foreground\">\n {group.rows.length} بند{group.hasMore ? ' · تظهر أول 100 بند متاح' : ''}\n </span>\n </div>\n {group.rows.length ? (\n <div className=\"overflow-x-auto\">\n <table className=\"w-full min-w-[480px] text-sm\">\n <thead className=\"bg-muted text-xs text-muted-foreground\">\n <tr>\n {keys.map((key) => (\n <th\n key={key}\n scope=\"col\"\n className=\"px-5 py-3 text-start font-medium\"\n >\n {columns.find((entry) => entry.key === key)?.label.ar ?? key}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {group.rows.map((row) => (\n <tr key={String(row.id)} className=\"border-b last:border-0\">\n {keys.map((key) => {\n const column = columns.find((entry) => entry.key === key)\n return (\n <td key={key} className=\"px-5 py-3\">\n {column ? (\n <ResourceValue field={column} row={row} lookups={lookups} />\n ) : (\n plain(row, key)\n )}\n </td>\n )\n })}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n ) : (\n <p className=\"px-7 py-6 text-xs text-muted-foreground\">لا توجد بنود متاحة.</p>\n )}\n </section>\n )\n })}\n </div>\n </Deferred>\n )}\n <Deferred data=\"activity\" fallback={<SectionSkeleton title=\"سجل النشاط\" rows={4} />}>\n <section aria-label=\"سجل النشاط\" className=\"rounded-xl border bg-white\">\n <div className=\"flex items-center gap-2 border-b px-7 py-4\">\n <History size={16} className=\"text-muted-foreground\" />\n <h2 className=\"text-sm font-semibold\">سجل النشاط</h2>\n <span className=\"ms-auto text-xs text-muted-foreground\">\n آخر {activity?.length ?? 0} إجراء\n </span>\n </div>\n {activity?.length ? (\n <ol className=\"divide-y\">\n {activity.map((entry) => (\n <li\n key={entry.id}\n className=\"flex flex-wrap items-baseline gap-x-4 gap-y-1 px-7 py-3 text-sm\"\n >\n <span className=\"font-medium\">\n {activityLabels[entry.action] ?? entry.action}\n </span>\n <span className=\"text-xs text-muted-foreground\">\n {entry.actorName ?? `مستخدم #${entry.actorId}`}\n </span>\n <time\n dateTime={entry.createdAt}\n className=\"text-xs text-muted-foreground tabular-nums\"\n >\n {formatDatetime(entry.createdAt, calendar)}\n </time>\n {entry.fields.length > 0 && (\n <span className=\"basis-full text-xs text-muted-foreground\">\n الحقول: {entry.fields.map(fieldLabel).join('، ')}\n </span>\n )}\n </li>\n ))}\n </ol>\n ) : (\n <p className=\"px-7 py-6 text-xs text-muted-foreground\">لا يوجد نشاط مسجل بعد.</p>\n )}\n </section>\n </Deferred>\n </div>\n )\n}\n\nfunction plain(row: SerializedRecord, key: string) {\n const value = row[key]\n if (value === null || value === undefined) return '—'\n return typeof value === 'object' ? JSON.stringify(value) : String(value)\n}\n\nfunction SectionSkeleton({ title, rows }: { title: string; rows: number }) {\n return (\n <section\n role=\"status\"\n aria-label={`جارٍ تحميل ${title}`}\n className=\"rounded-xl border bg-white\"\n >\n <div className=\"border-b px-7 py-4\">\n <Skeleton className=\"h-4 w-32\" />\n </div>\n <div className=\"space-y-3 px-7 py-5\">\n {Array.from({ length: rows }, (_, index) => (\n <Skeleton key={index} className=\"h-4 w-full\" />\n ))}\n </div>\n </section>\n )\n}\n",
23
+ "content": "import { Deferred, router } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { History, Pencil, Printer } from 'lucide-react'\nimport type {\n ResourceActivity,\n ResourceChildren as Children,\n ResourceDescription,\n ResourceShow as Show,\n SerializedRecord,\n} from '@adula/kit'\nimport { Badge } from '~/components/ui/badge'\nimport { Button } from '~/components/ui/button'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\nimport { Can } from '~/components/ui/can'\nimport { Skeleton } from '~/components/ui/skeleton'\nimport { ResourceActions } from '~/components/ui/resource-actions'\nimport { ResourceValue, formatDatetime, type LookupOptions } from '~/components/ui/resource-value'\nimport { RecordCollaboration } from '~/components/ui/record-collaboration'\nimport { RecordAssignments } from '~/components/ui/record-assignments'\nimport { RecordWorkflows } from '~/components/ui/record-workflows'\n\nexport type ResourceChildren = Children\nconst meta = new Set(['id', 'version', 'docStatus', 'orgUnitId'])\nconst activityLabels: Record<string, string> = {\n create: 'إنشاء السجل',\n update: 'تعديل السجل',\n delete: 'حذف السجل',\n submit: 'اعتماد المستند',\n cancel: 'إلغاء الاعتماد',\n amend: 'تعديل بعد الاعتماد',\n assignment_closed: 'إغلاق مهمة مع السجل',\n}\n\nexport function documentStatus(status: unknown) {\n return status === 1 ? 'معتمد' : status === 2 ? 'ملغي' : 'مسودة'\n}\n\nexport function ResourceShow({\n resource,\n result,\n lookups = {},\n childResources = {},\n childrenData,\n activity,\n onEdit,\n onAction,\n}: {\n resource: ResourceDescription\n result: Show\n lookups?: LookupOptions\n childResources?: Record<string, ResourceDescription>\n childrenData?: ResourceChildren\n activity?: ResourceActivity\n /** Replaces the edit link (record dialogs switch to the form in place). */\n onEdit?: () => void\n /** Called after a record action instead of reloading or leaving the page. */\n onAction?: (action: string) => void\n}) {\n const { calendar } = useUiPreferences()\n const base = `/resources/${resource.name}/${result.data.id}`\n const fields = resource.fields.filter(\n (field) =>\n resource.show.includes(field.key) && field.type !== 'hasMany' && field.key in result.data\n )\n const children = resource.fields.filter(\n (field) => field.type === 'hasMany' && resource.show.includes(field.key)\n )\n const fieldLabel = (key: string) =>\n resource.fields.find((field) => field.key === key)?.label.ar ?? key\n return (\n <div className=\"max-w-5xl space-y-6\">\n <div className=\"flex flex-wrap items-center gap-3\">\n <Can permissions={result.permissions} action=\"update\">\n {onEdit ? (\n <Button onClick={onEdit}>\n <Pencil size={15} />\n تعديل السجل\n </Button>\n ) : (\n <Button asChild>\n <Link href={`${base}/edit`}>\n <Pencil size={15} />\n تعديل السجل\n </Link>\n </Button>\n )}\n </Can>\n <Button variant=\"outline\" asChild>\n <a href={`${base}/print`} target=\"_blank\" rel=\"noopener\">\n <Printer size={15} />\n طباعة\n </a>\n </Button>\n <ResourceActions\n resource={resource.name}\n id={result.data.id as number}\n version={result.data.version ?? undefined}\n permissions={result.permissions}\n onDone={(action) =>\n onAction\n ? onAction(action)\n : action === 'delete'\n ? router.visit(`/resources/${resource.name}`)\n : router.reload()\n }\n />\n {resource.submittable && (\n <Badge\n variant={result.data.docStatus === 1 ? 'default' : 'secondary'}\n className={result.data.docStatus === 2 ? 'bg-red-50 text-red-800' : ''}\n >\n {documentStatus(result.data.docStatus)}\n </Badge>\n )}\n </div>\n <dl className=\"grid gap-7 rounded-xl border bg-white p-7 md:grid-cols-2\">\n {fields.map((field) => (\n <div\n key={field.key}\n className={['text', 'json'].includes(field.type) ? 'md:col-span-2' : ''}\n >\n <dt className=\"mb-2 text-xs text-muted-foreground\">{field.label.ar}</dt>\n <dd className=\"break-words text-sm whitespace-pre-wrap\">\n <ResourceValue\n field={field}\n row={result.data}\n related={result.related}\n lookups={lookups}\n />\n </dd>\n </div>\n ))}\n {resource.scoped && result.data.orgUnitId !== undefined && (\n <div>\n <dt className=\"mb-2 text-xs text-muted-foreground\">الوحدة التنظيمية</dt>\n <dd className=\"text-sm\">#{String(result.data.orgUnitId)}</dd>\n </div>\n )}\n </dl>\n {children.length > 0 && (\n <Deferred data=\"childrenData\" fallback={<SectionSkeleton title=\"البنود\" rows={3} />}>\n <div className=\"space-y-5\">\n {children.map((field) => {\n const group = childrenData?.[field.key]\n if (!group) return null\n const child = childResources[field.key]\n const columns = child\n ? child.fields.filter(\n (entry) => child.list.includes(entry.key) && entry.type !== 'hasMany'\n )\n : []\n const keys = columns.length\n ? columns.map((entry) => entry.key)\n : [...new Set(group.rows.flatMap((row) => Object.keys(row)))].filter(\n (key) => !meta.has(key)\n )\n return (\n <section\n key={field.key}\n aria-label={field.label.ar}\n className=\"rounded-xl border bg-white\"\n >\n <div className=\"flex items-center justify-between border-b px-7 py-4\">\n <h2 className=\"text-sm font-semibold\">{field.label.ar}</h2>\n <span className=\"text-xs text-muted-foreground\">\n {group.rows.length} بند{group.hasMore ? ' · تظهر أول 100 بند متاح' : ''}\n </span>\n </div>\n {group.rows.length ? (\n <div className=\"overflow-x-auto\">\n <table className=\"w-full min-w-[480px] text-sm\">\n <thead className=\"bg-muted text-xs text-muted-foreground\">\n <tr>\n {keys.map((key) => (\n <th\n key={key}\n scope=\"col\"\n className=\"px-5 py-3 text-start font-medium\"\n >\n {columns.find((entry) => entry.key === key)?.label.ar ?? key}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {group.rows.map((row) => (\n <tr key={String(row.id)} className=\"border-b last:border-0\">\n {keys.map((key) => {\n const column = columns.find((entry) => entry.key === key)\n return (\n <td key={key} className=\"px-5 py-3\">\n {column ? (\n <ResourceValue field={column} row={row} lookups={lookups} />\n ) : (\n plain(row, key)\n )}\n </td>\n )\n })}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n ) : (\n <p className=\"px-7 py-6 text-xs text-muted-foreground\">لا توجد بنود متاحة.</p>\n )}\n </section>\n )\n })}\n </div>\n </Deferred>\n )}\n {resource.submittable && (\n <RecordWorkflows resource={resource.name} id={result.data.id as number} />\n )}\n <RecordAssignments\n resource={resource.name}\n id={result.data.id as number}\n canAssign={Boolean(result.permissions.update)}\n />\n <RecordCollaboration resource={resource} id={result.data.id as number} />\n <Deferred data=\"activity\" fallback={<SectionSkeleton title=\"سجل النشاط\" rows={4} />}>\n <section aria-label=\"سجل النشاط\" className=\"rounded-xl border bg-white\">\n <div className=\"flex items-center gap-2 border-b px-7 py-4\">\n <History size={16} className=\"text-muted-foreground\" />\n <h2 className=\"text-sm font-semibold\">سجل النشاط</h2>\n <span className=\"ms-auto text-xs text-muted-foreground\">\n آخر {activity?.length ?? 0} إجراء\n </span>\n </div>\n {activity?.length ? (\n <ol className=\"divide-y\">\n {activity.map((entry) => (\n <li\n key={entry.id}\n className=\"flex flex-wrap items-baseline gap-x-4 gap-y-1 px-7 py-3 text-sm\"\n >\n <span className=\"font-medium\">\n {activityLabels[entry.action] ?? entry.action}\n </span>\n <span className=\"text-xs text-muted-foreground\">\n {entry.actorName ?? `مستخدم #${entry.actorId}`}\n </span>\n <time\n dateTime={entry.createdAt}\n className=\"text-xs text-muted-foreground tabular-nums\"\n >\n {formatDatetime(entry.createdAt, calendar)}\n </time>\n {entry.fields.length > 0 && (\n <span className=\"basis-full text-xs text-muted-foreground\">\n الحقول: {entry.fields.map(fieldLabel).join('، ')}\n </span>\n )}\n </li>\n ))}\n </ol>\n ) : (\n <p className=\"px-7 py-6 text-xs text-muted-foreground\">لا يوجد نشاط مسجل بعد.</p>\n )}\n </section>\n </Deferred>\n </div>\n )\n}\n\nfunction plain(row: SerializedRecord, key: string) {\n const value = row[key]\n if (value === null || value === undefined) return '—'\n return typeof value === 'object' ? JSON.stringify(value) : String(value)\n}\n\nfunction SectionSkeleton({ title, rows }: { title: string; rows: number }) {\n return (\n <section\n role=\"status\"\n aria-label={`جارٍ تحميل ${title}`}\n className=\"rounded-xl border bg-white\"\n >\n <div className=\"border-b px-7 py-4\">\n <Skeleton className=\"h-4 w-32\" />\n </div>\n <div className=\"space-y-3 px-7 py-5\">\n {Array.from({ length: rows }, (_, index) => (\n <Skeleton key={index} className=\"h-4 w-full\" />\n ))}\n </div>\n </section>\n )\n}\n",
21
24
  "target": "inertia/components/ui/resource-show.tsx"
22
25
  }
23
26
  ],
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "registry/ui/resource-surface.tsx",
12
12
  "type": "registry:ui",
13
- "content": "import { useEffect, useRef, useState, type ReactNode } from 'react'\nimport { router } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { Button } from '~/components/ui/button'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n} from '~/components/ui/dialog'\n\nexport type ResourcePresentation = 'dialog' | 'page'\n\n/** Forms and record details are modal unless the user requests a page override. */\nexport function ResourceSurface({\n title,\n description,\n backHref,\n presentation = 'dialog',\n mode,\n children,\n}: {\n title: string\n description: string\n backHref: string\n presentation?: ResourcePresentation\n mode?: 'view' | 'edit'\n children: ReactNode\n}) {\n const [open, setOpen] = useState(true)\n const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)\n useEffect(() => () => clearTimeout(closeTimer.current), [])\n if (presentation === 'page')\n return (\n <section dir=\"rtl\" className=\"space-y-6\">\n <Button variant=\"outline\" asChild>\n <Link href={backHref}>العودة إلى القائمة</Link>\n </Button>\n <header>\n <h1 className=\"text-3xl font-semibold\">{title}</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">{description}</p>\n </header>\n {children}\n </section>\n )\n\n return (\n <Dialog\n mode={mode}\n open={open}\n onOpenChange={(open) => {\n if (!open) {\n setOpen(false)\n closeTimer.current = setTimeout(\n () => {\n router.visit(backHref, {\n onSuccess: () => {\n // A direct route has no mounted DialogTrigger to restore focus to.\n const heading = document.querySelector<HTMLElement>('main h1')\n heading?.setAttribute('tabindex', '-1')\n heading?.focus()\n },\n })\n },\n window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 160\n )\n }\n }}\n >\n <DialogContent\n dir=\"rtl\"\n className=\"flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl\"\n >\n <DialogHeader className=\"shrink-0 border-b px-6 py-5 pe-12 text-start\">\n <DialogTitle>{title}</DialogTitle>\n <DialogDescription>{description}</DialogDescription>\n </DialogHeader>\n <div className=\"min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6\">{children}</div>\n </DialogContent>\n </Dialog>\n )\n}\n",
13
+ "content": "import { useEffect, useRef, useState, type ReactNode } from 'react'\nimport { router } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport axios from 'axios'\nimport { Button } from '~/components/ui/button'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n} from '~/components/ui/dialog'\n\nexport type ResourcePresentation = 'dialog' | 'page'\n\nconst LIST_RETURN = 'adula:list-return'\ntype ListReturn = { href: string; scroll: number; record: string }\n\n/**\n * Remembers the list view (query and scroll) before a record opens from it, so closing the\n * record dialog returns to that view. DataTable calls it; page overrides that link to records\n * from their own list can call it too.\n */\nexport function rememberListView(record: string) {\n try {\n sessionStorage.setItem(\n LIST_RETURN,\n JSON.stringify({\n href: window.location.pathname + window.location.search,\n scroll: window.scrollY,\n record,\n } satisfies ListReturn)\n )\n } catch {\n // Storage can be unavailable (private mode); closing then falls back to backHref.\n }\n}\n\nfunction listReturn(backHref: string): ListReturn | null {\n try {\n const saved = JSON.parse(sessionStorage.getItem(LIST_RETURN) ?? 'null') as ListReturn | null\n const path = (href: string) => new URL(href, window.location.origin).pathname\n if (saved && typeof saved.href === 'string' && path(saved.href) === path(backHref)) return saved\n } catch {\n // Ignore unreadable entries.\n }\n return null\n}\n\n/**\n * A record opened over the list it belongs to (#31): the list stays mounted, keeps its\n * query, loaded pages and scroll, and the URL names the record so it can be shared or\n * reloaded as a standalone page.\n */\nexport type RecordOverlay = {\n /** The record's view or edit form, as served by GET /resources/:resource/:id/view. */\n view: unknown\n /** The list URL to return to. */\n list: string\n /** The record that opened the dialog, for focus return. */\n record: string\n /** Set after a save or action, so closing refreshes the list rows. */\n changed?: boolean\n}\ntype OverlayPage = Record<string, unknown> & { overlay?: RecordOverlay }\n\n/**\n * Opens a record in a dialog over the current generic list without leaving it. Page\n * overrides can call it for their own rows. Resources with their own record pages, and\n * failures, fall back to a normal visit.\n */\nexport async function openRecord(\n resource: string,\n id: string | number,\n mode: 'show' | 'edit' = 'show',\n options: { replace?: boolean; changed?: boolean } = {}\n) {\n const href = `/resources/${resource}/${id}${mode === 'edit' ? '/edit' : ''}`\n let data: { view: unknown; childrenData?: unknown; activity?: unknown }\n try {\n const response = await axios.get(`/resources/${resource}/${id}/view`, {\n params: mode === 'edit' ? { mode } : {},\n headers: { Accept: 'application/json' },\n })\n data = response.data\n } catch {\n router.visit(href)\n return\n }\n const list = window.location.pathname + window.location.search\n const visit = options.replace ? router.replace.bind(router) : router.push.bind(router)\n visit({\n url: href,\n props: (current: OverlayPage) => ({\n ...current,\n childrenData: data.childrenData,\n activity: data.activity,\n overlay: {\n view: data.view,\n list: options.replace ? (current.overlay?.list ?? list) : list,\n record: options.replace ? (current.overlay?.record ?? href) : href,\n changed: Boolean(options.changed || (options.replace && current.overlay?.changed)),\n },\n }),\n preserveState: true,\n preserveScroll: true,\n })\n}\n\n/** Closes a record dialog opened by openRecord and returns to its list, refreshed if changed. */\nexport function closeRecord(overlay: RecordOverlay) {\n if (overlay.changed) {\n // Replace the record's history entry, so Back does not reopen a record that changed.\n router.visit(overlay.list, {\n replace: true,\n preserveScroll: true,\n onSuccess: () => restoreFocus(overlay.record),\n })\n return\n }\n router.replace({\n url: overlay.list,\n props: (current: OverlayPage) => {\n const { overlay: _closed, childrenData: _children, activity: _activity, ...rest } = current\n return rest\n },\n preserveState: true,\n preserveScroll: true,\n onSuccess: () => restoreFocus(overlay.record),\n })\n}\n\n/** Return focus to the control that opened the record, else to the page heading without a ring. */\nfunction restoreFocus(record: string | undefined) {\n const trigger = record\n ? document.querySelector<HTMLElement>(`main a[href=\"${CSS.escape(record)}\"]`)\n : null\n const target = trigger ?? document.querySelector<HTMLElement>('main h1')\n if (!trigger && target) {\n target.setAttribute('tabindex', '-1')\n target.classList.add('outline-none')\n }\n target?.focus({ preventScroll: true })\n}\n\n/** Forms and record details are modal unless the user requests a page override. */\nexport function ResourceSurface({\n title,\n description,\n backHref,\n presentation = 'dialog',\n mode,\n onClose,\n children,\n}: {\n title: string\n description: string\n backHref: string\n presentation?: ResourcePresentation\n mode?: 'view' | 'edit'\n /** Replaces the default return visit, for dialogs opened over a mounted list. */\n onClose?: () => void\n children: ReactNode\n}) {\n const [open, setOpen] = useState(true)\n const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)\n useEffect(() => () => clearTimeout(closeTimer.current), [])\n if (presentation === 'page')\n return (\n <section dir=\"rtl\" className=\"space-y-6\">\n <Button variant=\"outline\" asChild>\n <Link href={backHref}>العودة إلى القائمة</Link>\n </Button>\n <header>\n <h1 className=\"text-3xl font-semibold\">{title}</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">{description}</p>\n </header>\n {children}\n </section>\n )\n\n return (\n <Dialog\n mode={mode}\n open={open}\n onOpenChange={(open) => {\n if (!open) {\n setOpen(false)\n closeTimer.current = setTimeout(\n () => {\n if (onClose) return onClose()\n // Return to the list view the record was opened from: same query and scroll.\n const saved = listReturn(backHref)\n router.visit(saved?.href ?? backHref, {\n onSuccess: () => {\n if (saved) window.scrollTo(0, saved.scroll)\n // A direct route has no mounted DialogTrigger to restore focus to.\n restoreFocus(saved?.record)\n },\n })\n },\n window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 160\n )\n }\n }}\n >\n <DialogContent\n dir=\"rtl\"\n className=\"flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl\"\n >\n <DialogHeader className=\"shrink-0 border-b px-6 py-5 pe-12 text-start\">\n <DialogTitle>{title}</DialogTitle>\n <DialogDescription>{description}</DialogDescription>\n </DialogHeader>\n <div className=\"min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6\">{children}</div>\n </DialogContent>\n </Dialog>\n )\n}\n",
14
14
  "target": "inertia/components/ui/resource-surface.tsx"
15
15
  }
16
16
  ],