@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.
- package/README.md +5 -3
- package/build/calendar-date.json +1 -1
- package/build/data-table.json +5 -2
- package/build/manifest.json +69 -49
- package/build/record-assignments.json +28 -0
- package/build/record-collaboration.json +28 -0
- package/build/record-workflows.json +26 -0
- package/build/resource-actions.json +1 -1
- package/build/resource-field.json +1 -1
- package/build/resource-form.json +1 -1
- package/build/resource-import.json +24 -0
- package/build/resource-page.json +3 -2
- package/build/resource-show.json +5 -2
- package/build/resource-surface.json +1 -1
- package/build/resource-value.json +1 -1
- package/build/theme.css +8 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @adula/ui
|
|
2
2
|
|
|
3
|
-
Version 0.
|
|
3
|
+
Version 1.0.0, accepted by the owner (ADR 028). For complete consumer setup, use the canonical [installation guide](https://github.com/adulash/adula-kit/blob/main/docs/installation.md).
|
|
4
4
|
|
|
5
5
|
Arabic RTL shadcn registry for project-owned React components. This package ships JSON assets, not a runtime React component library. Install with `node ace adula:ui add all` or `node ace adula:ui add dialog`. The command invokes the pinned shadcn CLI and records each generated file hash in `ui.lock.json`. Project modifications are preserved; use `--preview` to review upstream differences before merging an update.
|
|
6
6
|
|
|
@@ -12,6 +12,8 @@ Required host tooling: Node 24, React 19, shadcn 4.21.0, Tailwind CSS 4.3.3 and
|
|
|
12
12
|
|
|
13
13
|
The theme disables automatic Tailwind source discovery and explicitly scans the consumer's inertia directory. It does not scan node_modules or unrelated workspace files. Add explicit source paths for any additional application-owned frontend directories.
|
|
14
14
|
|
|
15
|
+
Components merge `className` with `cn` (shadcn's clsx + tailwind-merge replacement), so a class passed by a page replaces the variant class of the same utility and modifier: `<Button variant="outline" className="bg-emerald-600 text-white">` drops `bg-background`. Classes under other modifiers stay, as in every shadcn project: the outline variant's `hover:bg-accent`, `hover:text-accent-foreground` and `dark:bg-input/30` still apply on hover and in dark mode. To restyle a selected or colored state, start from the `default` variant or override those modifiers too (`hover:bg-emerald-700 dark:bg-emerald-600`).
|
|
16
|
+
|
|
15
17
|
## Resource components
|
|
16
18
|
|
|
17
19
|
Nine kit components sit on top of the primitives and are registered by `scripts/register-resource-ui.mjs`. They consume the `ResourceDescription`, `ResourceList`, `ResourceEditor`, `ResourceShow`, `ResourceChildren`, `ResourceLookups` and `ResourceActivity` contracts exported by `@adula/kit`; the copied files are project-owned like every other registry item.
|
|
@@ -19,10 +21,10 @@ Nine kit components sit on top of the primitives and are registered by `scripts/
|
|
|
19
21
|
| Component | Role |
|
|
20
22
|
|---|---|
|
|
21
23
|
| `resource-page` | Page shell for the `index`, `form` and `show` modes. The discriminated `view` prop stays nested because Inertia page typing flattens top-level unions; the index list arrives as a top-level `result` prop built with `inertia.scroll()`. |
|
|
22
|
-
| `resource-surface` | Default shadcn Dialog for forms and record details, with RTL, scrollable content, accessible title/description and route dismissal. Use `presentation="page"` only for an explicit user-requested page. Custom overrides pass title, description and backHref. |
|
|
24
|
+
| `resource-surface` | Default shadcn Dialog for forms and record details, with RTL, scrollable content, accessible title/description and route dismissal. Use `presentation="page"` only for an explicit user-requested page. Custom overrides pass title, description and backHref. `openRecord()` opens a record in a dialog over the current list without leaving it (the generic list uses it); `closeRecord()` returns to that list. |
|
|
23
25
|
| `data-table` | URL-driven sort, text search and per-kind filters (lookup select, boolean select, typed date with calendar, related-record search, text). Keyset paging uses Inertia's `InfiniteScroll` in manual mode with a scroll trigger; pages merge into `result.data` (`matchOn('id')`) while row permissions and related labels accumulate client-side. Rows above 200 render through `@tanstack/react-virtual`. CSV export writes the loaded rows with a UTF-8 BOM, Arabic headers and the same formatting as the cells. Row actions go through `Can` and `resource-actions`. |
|
|
24
26
|
| `resource-form` | Controlled state per field, JSON `POST`/`PATCH` with the hidden `version`, VineJS `422` errors mapped to fields, inline child errors surfaced on their section (nested validators do not report the row), `409` conflicts with a reload action, organizational unit select, and delete/submit/cancel buttons rendered only from the record permissions. |
|
|
25
|
-
| `resource-field` | One control per field kind: text/textarea, integer, money (minor units in and out, Arabic-locale display with two decimals and `CURRENCY_LABEL`), boolean switch, date (typed `YYYY-MM-DD` plus calendar popover), datetime, JSON (parse check and pretty printing), attachment (uploads on selection to `POST /attachments`, progress, download link, replace and clear; the saved value is the attachment id), lookup select, belongsTo combobox (server search through `/resources/:resource/options/:field` with debounce and keyset "more") and inline hasMany rows (`InlineRows`: add, edit, `_delete` markers, per-row `version`). |
|
|
27
|
+
| `resource-field` | One control per field kind: text/textarea, integer, money (minor units in and out, Arabic-locale display with two decimals and `CURRENCY_LABEL`), boolean switch, date (typed `YYYY-MM-DD` plus calendar popover), datetime, JSON (parse check and pretty printing), attachment (uploads on selection to `POST /attachments`, progress, download link, replace and clear; the saved value is the attachment id), lookup select, belongsTo combobox (server search through `/resources/:resource/options/:field` with debounce and keyset "more"), user combobox (active members of the chosen unit and its ancestors, searched by name through the same route) and inline hasMany rows (`InlineRows`: add, edit, `_delete` markers, per-row `version`). |
|
|
26
28
|
| `resource-value` | Formatting and parsing shared by the table, the show page and CSV export, plus the `ResourceValue` renderer (download links, badges, `<pre>` JSON, LTR numerics). |
|
|
27
29
|
| `resource-show` | Field cards, status badge, actions by permission, deferred `childrenData` tables described by `childResources`, and the deferred `activity` list, each behind a skeleton. |
|
|
28
30
|
| `resource-actions` | Confirmation dialog and versioned `DELETE`/`submit`/`cancel` requests shared by the table and the show page. |
|
package/build/calendar-date.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
{
|
|
8
8
|
"path": "registry/ui/calendar_date.ts",
|
|
9
9
|
"type": "registry:ui",
|
|
10
|
-
"content": "import type { CalendarSystem, CalendarPreference } from '@adula/kit'\n\nconst dayMs = 86400000\nconst hijri = new Intl.DateTimeFormat('en-u-nu-latn', {\n calendar: 'islamic-umalqura',\n timeZone: 'UTC',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n})\n\nexport function calendarInput(iso: string, calendar: CalendarSystem): string {\n if (!iso) return ''\n if (calendar === 'gregory') return iso.slice(0, 10)\n const date = new Date(`${iso.slice(0, 10)}T00:00:00Z`)\n if (Number.isNaN(date.getTime())) return ''\n const parts = Object.fromEntries(hijri.formatToParts(date).map((part) => [part.type, part.value]))\n return `${parts.year.padStart(4, '0')}-${parts.month.padStart(2, '0')}-${parts.day.padStart(2, '0')}`\n}\n\n/** Invert the platform's Umm al-Qura calendar; never approximate lunar months. */\nexport function calendarIso(raw: string, calendar: CalendarSystem): string {\n const value = raw\n .replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)))\n .replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))\n .trim()\n if (!value) return ''\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) throw new Error('أدخل التاريخ بصيغة سنة-شهر-يوم')\n if (calendar === 'gregory') {\n const date = new Date(`${value}T00:00:00Z`)\n if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value)\n throw new Error('التاريخ الميلادي غير صالح')\n return value\n }\n const [year, month, day] = value.split('-').map(Number)\n if (year < 1300 || year > 1600 || month < 1 || month > 12 || day < 1 || day > 30)\n throw new Error('اختر تاريخ أم القرى بين عامي 1300 و1600 هـ')\n let low = Math.floor(Date.UTC(1882, 0, 1) / dayMs)\n let high = Math.floor(Date.UTC(2175, 11, 31) / dayMs)\n while (low <= high) {\n const middle = Math.floor((low + high) / 2)\n const iso = new Date(middle * dayMs).toISOString().slice(0, 10)\n const candidate = calendarInput(iso, calendar)\n if (candidate === value) return iso\n if (candidate < value) low = middle + 1\n else high = middle - 1\n }\n throw new Error('هذا اليوم غير موجود في تقويم أم القرى')\n}\n\nexport function calendarDisplay(\n value: unknown,\n calendar: CalendarPreference,\n withTime = false\n): string {\n if (!value) return '—'\n if (calendar === 'both')\n return `${calendarDisplay(value, 'gregory', withTime)} · ${calendarDisplay(value, 'islamic-umalqura', withTime)}`\n const date = new Date(String(value))\n if (Number.isNaN(date.getTime())) return String(value)\n return new Intl.DateTimeFormat('ar-u-nu-latn', {\n calendar,\n dateStyle: 'medium',\n ...(withTime ? { timeStyle: 'short' as const } : { timeZone: 'UTC' }),\n }).format(date)\n}\n",
|
|
10
|
+
"content": "import type { CalendarSystem, CalendarPreference } from '@adula/kit'\n\nconst dayMs = 86400000\nconst hijri = new Intl.DateTimeFormat('en-u-nu-latn', {\n calendar: 'islamic-umalqura',\n timeZone: 'UTC',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n})\n\nexport function calendarInput(iso: string, calendar: CalendarSystem): string {\n if (!iso) return ''\n if (calendar === 'gregory') return iso.slice(0, 10)\n const date = new Date(`${iso.slice(0, 10)}T00:00:00Z`)\n if (Number.isNaN(date.getTime())) return ''\n const parts = Object.fromEntries(hijri.formatToParts(date).map((part) => [part.type, part.value]))\n return `${parts.year.padStart(4, '0')}-${parts.month.padStart(2, '0')}-${parts.day.padStart(2, '0')}`\n}\n\n/** Invert the platform's Umm al-Qura calendar; never approximate lunar months. */\nexport function calendarIso(raw: string, calendar: CalendarSystem): string {\n const value = raw\n .replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)))\n .replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))\n .trim()\n if (!value) return ''\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) throw new Error('أدخل التاريخ بصيغة سنة-شهر-يوم')\n if (calendar === 'gregory') {\n const date = new Date(`${value}T00:00:00Z`)\n if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value)\n throw new Error('التاريخ الميلادي غير صالح')\n return value\n }\n const [year, month, day] = value.split('-').map(Number)\n if (year < 1300 || year > 1600 || month < 1 || month > 12 || day < 1 || day > 30)\n throw new Error('اختر تاريخ أم القرى بين عامي 1300 و1600 هـ')\n let low = Math.floor(Date.UTC(1882, 0, 1) / dayMs)\n let high = Math.floor(Date.UTC(2175, 11, 31) / dayMs)\n while (low <= high) {\n const middle = Math.floor((low + high) / 2)\n const iso = new Date(middle * dayMs).toISOString().slice(0, 10)\n const candidate = calendarInput(iso, calendar)\n if (candidate === value) return iso\n if (candidate < value) low = middle + 1\n else high = middle - 1\n }\n throw new Error('هذا اليوم غير موجود في تقويم أم القرى')\n}\n\nexport function calendarDisplay(\n value: unknown,\n calendar: CalendarPreference,\n withTime = false\n): string {\n if (!value) return '—'\n if (calendar === 'both')\n return `${calendarDisplay(value, 'gregory', withTime)} · ${calendarDisplay(value, 'islamic-umalqura', withTime)}`\n const date = new Date(String(value))\n if (Number.isNaN(date.getTime())) return String(value)\n // ICU wraps Arabic separators in bidi marks, which scramble the digits in any\n // container whose direction differs from the text; callers isolate the value instead.\n return new Intl.DateTimeFormat('ar-u-nu-latn', {\n calendar,\n dateStyle: 'medium',\n ...(withTime ? { timeStyle: 'short' as const } : { timeZone: 'UTC' }),\n })\n .format(date)\n .replace(/[\\u200e\\u200f\\u061c]/g, '')\n}\n",
|
|
11
11
|
"target": "inertia/components/ui/calendar_date.ts"
|
|
12
12
|
}
|
|
13
13
|
],
|
package/build/data-table.json
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
"resource-actions",
|
|
10
10
|
"resource-field",
|
|
11
11
|
"resource-value",
|
|
12
|
-
"ui-preferences"
|
|
12
|
+
"ui-preferences",
|
|
13
|
+
"command",
|
|
14
|
+
"popover",
|
|
15
|
+
"resource-surface"
|
|
13
16
|
],
|
|
14
17
|
"dependencies": [
|
|
15
18
|
"@tanstack/react-table@9.2.4",
|
|
@@ -21,7 +24,7 @@
|
|
|
21
24
|
{
|
|
22
25
|
"path": "registry/ui/data-table.tsx",
|
|
23
26
|
"type": "registry:ui",
|
|
24
|
-
"content": "import { useRef, useState, type ComponentRef, type ReactNode } from 'react'\nimport { InfiniteScroll, router, usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { tableFeatures, useTable } from '@tanstack/react-table'\nimport { useVirtualizer } from '@tanstack/react-virtual'\nimport axios from 'axios'\nimport {\n ArrowDown,\n ArrowDownUp,\n ArrowUp,\n Download,\n Inbox,\n Loader2,\n Bookmark,\n BookmarkPlus,\n Search,\n SlidersHorizontal,\n X,\n} from 'lucide-react'\nimport type {\n ResourceDescription,\n ResourceField,\n ResourceList,\n SavedView,\n SavedViewQuery,\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 { Input } from '~/components/ui/input'\nimport { ResourceActions } from '~/components/ui/resource-actions'\nimport { FieldControl } from '~/components/ui/resource-field'\nimport {\n csvCell,\n displayValue,\n parseValue,\n relationLabel,\n type LookupOptions,\n type Option,\n} from '~/components/ui/resource-value'\n\n/** Rows above this count render through the virtualizer (plan section 13). */\nexport const VIRTUAL_ROWS = 200\nconst ROW_HEIGHT = 52\nconst PAGE_SIZE = 100\nconst features = tableFeatures({})\ntype Query = Record<string, string | undefined>\n\nexport function DataTable({\n resource,\n result,\n lookups = {},\n savedViews = [],\n}: {\n resource: ResourceDescription\n result: ResourceList\n lookups?: LookupOptions\n savedViews?: SavedView[]\n}) {\n const { calendar } = useUiPreferences()\n const { url } = usePage()\n const query = new URL(url, 'http://localhost').searchParams\n const [search, setSearch] = useState(query.get('search') ?? '')\n const [failure, setFailure] = useState('')\n const container = useRef<HTMLDivElement>(null)\n const scroller = useRef<ComponentRef<typeof InfiniteScroll>>(null)\n // Inertia merges `result.data` page by page; permissions and labels of earlier pages accumulate here.\n const store = useRef<{\n source: ResourceList | null\n permissions: ResourceList['permissions']\n related: ResourceList['related']\n }>({ source: null, permissions: {}, related: {} })\n if (store.current.source !== result) {\n const related = { ...store.current.related }\n for (const [key, rows] of Object.entries(result.related))\n related[key] = [\n ...new Map([...(related[key] ?? []), ...rows].map((row) => [String(row.id), row])).values(),\n ]\n store.current = {\n source: result,\n permissions: { ...store.current.permissions, ...result.permissions },\n related,\n }\n }\n const { permissions, related } = store.current\n const fields = resource.fields.filter(\n (field) => resource.list.includes(field.key) && field.type !== 'hasMany'\n )\n const filterable = fields.filter((field) => field.filterable)\n const table = useTable({\n features,\n data: result.data,\n columns: fields.map((field) => ({ accessorKey: field.key, id: field.key })),\n getRowId: (row) => String(row.id),\n })\n const rows = table.getRowModel().rows\n const virtualized = rows.length > VIRTUAL_ROWS\n const virtual = useVirtualizer({\n count: rows.length,\n getScrollElement: () => container.current,\n estimateSize: () => ROW_HEIGHT,\n overscan: 10,\n enabled: virtualized,\n getItemKey: (index) => rows[index].id,\n })\n const items = virtualized\n ? virtual.getVirtualItems()\n : rows.map((_, index) => ({ index, start: 0, end: 0 }))\n const navigate = (changes: Query) => {\n const next = new URLSearchParams(query)\n next.delete('cursor')\n for (const [key, value] of Object.entries(changes))\n value ? next.set(key, value) : next.delete(key)\n const suffix = next.toString()\n router.get(\n `/resources/${resource.name}${suffix ? `?${suffix}` : ''}`,\n {},\n { preserveScroll: true }\n )\n }\n const sort = query.get('sort')\n const direction = query.get('direction') === 'desc' ? 'desc' : 'asc'\n const activeFilters = filterable.filter((field) => query.get(`filters[${field.key}]`))\n const csv = () => {\n const content = [\n fields.map((field) => field.label.ar),\n ...result.data.map((row) =>\n fields.map((field) => displayValue(field, row, related, lookups, calendar))\n ),\n ]\n const href = URL.createObjectURL(\n new Blob(['' + content.map((row) => row.map(csvCell).join(',')).join('\\r\\n')], {\n type: 'text/csv;charset=utf-8',\n })\n )\n const link = document.createElement('a')\n link.href = href\n link.download = `${resource.label}.csv`\n link.click()\n URL.revokeObjectURL(href)\n }\n const refresh = () => router.visit(url, { preserveScroll: true })\n const currentQuery = (): SavedViewQuery => {\n const view: SavedViewQuery = {}\n const text = query.get('search')\n if (text) view.search = text\n if (sort) {\n view.sort = sort\n view.direction = direction\n }\n const filters: Record<string, string> = {}\n for (const field of filterable) {\n const value = query.get(`filters[${field.key}]`)\n if (value) filters[field.key] = value\n }\n if (Object.keys(filters).length) view.filters = filters\n return view\n }\n const applyView = (view: SavedView) => {\n const next = new URLSearchParams()\n if (view.query.search) next.set('search', view.query.search)\n if (view.query.sort) {\n next.set('sort', view.query.sort)\n next.set('direction', view.query.direction ?? 'asc')\n }\n for (const [key, value] of Object.entries(view.query.filters ?? {}))\n if (value !== null) next.set(`filters[${key}]`, String(value))\n setSearch(view.query.search ?? '')\n const suffix = next.toString()\n router.get(\n `/resources/${resource.name}${suffix ? `?${suffix}` : ''}`,\n {},\n { preserveScroll: true }\n )\n }\n return (\n <section\n aria-label={`قائمة ${resource.label}`}\n className=\"min-w-0 overflow-hidden rounded-xl border border-border bg-white shadow-[0_2px_10px_#1c302804]\"\n >\n <div className=\"flex flex-wrap items-center justify-between gap-4 border-b px-5 py-4\">\n <div className=\"flex flex-wrap items-center gap-3\">\n <h2 className=\"text-sm font-semibold\">سجل {resource.label}</h2>\n <Badge variant=\"secondary\" className=\"font-normal tabular-nums\">\n {rows.length} معروض\n </Badge>\n <span className=\"text-xs text-muted-foreground\">\n {result.meta.estimatedTotal !== undefined\n ? `نحو ${result.meta.estimatedTotal} سجل ضمن نطاقك`\n : 'حسب نطاق صلاحياتك'}\n </span>\n </div>\n <Button variant=\"outline\" size=\"sm\" onClick={csv} disabled={!rows.length}>\n <Download size={15} />\n تصدير المعروض\n </Button>\n </div>\n {(resource.searchable || filterable.length > 0) && (\n <SavedViewsBar\n resource={resource}\n views={savedViews}\n current={currentQuery}\n onApply={applyView}\n onChanged={refresh}\n onFailure={setFailure}\n />\n )}\n {(resource.searchable || filterable.length > 0) && (\n <div className=\"flex flex-wrap items-end gap-3 border-b px-5 py-4\">\n {resource.searchable && (\n <form\n role=\"search\"\n className=\"relative w-full max-w-sm\"\n onSubmit={(event) => {\n event.preventDefault()\n navigate({ search })\n }}\n >\n <Search\n className=\"pointer-events-none absolute start-3 top-2.5 text-muted-foreground\"\n size={16}\n />\n <Input\n aria-label={`البحث في ${resource.label}`}\n value={search}\n onChange={(event) => setSearch(event.target.value)}\n placeholder=\"ابحث في السجلات...\"\n className=\"h-9 bg-background/60 ps-10 text-xs\"\n />\n </form>\n )}\n {filterable.length > 0 && (\n <span className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n <SlidersHorizontal size={15} />\n تصفية\n </span>\n )}\n {filterable.map((field) => (\n <Filter\n key={field.key}\n field={field}\n value={query.get(`filters[${field.key}]`) ?? ''}\n options={lookups[field.key]}\n onApply={(value) => navigate({ [`filters[${field.key}]`]: value })}\n />\n ))}\n {activeFilters.length > 0 && (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={() =>\n navigate(\n Object.fromEntries(\n activeFilters.map((field) => [`filters[${field.key}]`, undefined])\n )\n )\n }\n >\n <X size={14} />\n مسح التصفية\n </Button>\n )}\n </div>\n )}\n <InfiniteScroll\n ref={(instance) => {\n scroller.current = instance\n }}\n data=\"result\"\n manual\n onlyNext\n preserveUrl\n params={{\n data: { limit: PAGE_SIZE },\n onBefore: () => {\n setFailure('')\n },\n onHttpException: () => {\n setFailure('تعذر تحميل السجلات التالية. أعد المحاولة.')\n return false\n },\n onNetworkError: () => {\n setFailure('تعذر الاتصال بالخادم. تحقق من الاتصال ثم أعد المحاولة.')\n return false\n },\n }}\n next={({ fetch, loading, hasMore }) => (\n <div className=\"flex flex-wrap items-center justify-center gap-4 border-t px-5 py-3 text-xs text-muted-foreground\">\n {failure && (\n <p role=\"alert\" className=\"text-destructive\">\n {failure}\n </p>\n )}\n {loading ? (\n <span role=\"status\" className=\"inline-flex items-center gap-2\">\n <Loader2 size={14} className=\"animate-spin\" />\n جارٍ تحميل المزيد...\n </span>\n ) : hasMore ? (\n <Button variant=\"ghost\" size=\"sm\" onClick={fetch}>\n تحميل المزيد\n </Button>\n ) : (\n <span>{rows.length ? 'نهاية السجلات' : ''}</span>\n )}\n </div>\n )}\n >\n <div\n ref={container}\n data-slot=\"table-scroll\"\n className=\"max-h-[600px] overflow-auto\"\n onScroll={(event) => {\n const element = event.currentTarget\n if (\n !failure &&\n element.scrollHeight - element.scrollTop - element.clientHeight < 200 &&\n scroller.current?.hasNext()\n )\n scroller.current.fetchNext()\n }}\n >\n <table\n className=\"w-full min-w-[720px] text-start text-sm\"\n aria-rowcount={rows.length + 1}\n aria-label={resource.label}\n >\n <thead className=\"sticky top-0 z-10 bg-muted text-xs text-muted-foreground\">\n <tr>\n {fields.map((field) => (\n <th\n key={field.key}\n scope=\"col\"\n className=\"h-11 whitespace-nowrap px-5 text-start font-medium\"\n aria-sort={\n sort === field.key\n ? direction === 'desc'\n ? 'descending'\n : 'ascending'\n : undefined\n }\n >\n {field.sortable ? (\n <button\n type=\"button\"\n className=\"inline-flex items-center gap-2 hover:text-foreground\"\n onClick={() =>\n navigate({\n sort: field.key,\n direction: sort === field.key && direction === 'asc' ? 'desc' : 'asc',\n })\n }\n >\n {field.label.ar}\n {sort === field.key ? (\n direction === 'desc' ? (\n <ArrowDown size={12} />\n ) : (\n <ArrowUp size={12} />\n )\n ) : (\n <ArrowDownUp size={12} className=\"opacity-60\" />\n )}\n </button>\n ) : (\n field.label.ar\n )}\n </th>\n ))}\n <th scope=\"col\" className=\"px-5 text-start font-medium\">\n الإجراءات\n </th>\n </tr>\n </thead>\n <tbody>\n {virtualized && items[0] && items[0].start > 0 && (\n <tr aria-hidden=\"true\">\n <td colSpan={fields.length + 1} style={{ height: items[0].start }} />\n </tr>\n )}\n {items.map(({ index }) => {\n const row = rows[index].original\n const id = String(row.id)\n return (\n <tr\n key={id}\n aria-rowindex={index + 2}\n className=\"border-b last:border-0 hover:bg-background\"\n style={{ height: ROW_HEIGHT }}\n >\n {fields.map((field) => (\n <Cell\n key={field.key}\n field={field}\n row={row}\n related={related}\n lookups={lookups}\n />\n ))}\n <td className=\"whitespace-nowrap px-5 text-xs\">\n <span className=\"inline-flex items-center gap-1\">\n <Button variant=\"ghost\" size=\"sm\" asChild>\n <Link\n href={`/resources/${resource.name}/${id}`}\n aria-label={`عرض السجل ${id}`}\n >\n عرض\n </Link>\n </Button>\n <Can permissions={permissions[id] ?? {}} action=\"update\">\n <Button variant=\"ghost\" size=\"sm\" asChild>\n <Link\n href={`/resources/${resource.name}/${id}/edit`}\n aria-label={`تعديل السجل ${id}`}\n >\n تعديل\n </Link>\n </Button>\n </Can>\n <ResourceActions\n resource={resource.name}\n id={id}\n version={row.version ?? undefined}\n permissions={permissions[id] ?? {}}\n label={`السجل ${id}`}\n size=\"sm\"\n variant=\"ghost\"\n onDone={refresh}\n />\n </span>\n </td>\n </tr>\n )\n })}\n {virtualized && (\n <tr aria-hidden=\"true\">\n <td\n colSpan={fields.length + 1}\n style={{\n height: Math.max(0, virtual.getTotalSize() - (items.at(-1)?.end ?? 0)),\n }}\n />\n </tr>\n )}\n {!rows.length && (\n <tr>\n <td colSpan={fields.length + 1} className=\"py-16 text-center\">\n <Inbox\n className=\"mx-auto mb-3 text-muted-foreground\"\n size={30}\n strokeWidth={1.3}\n />\n <p className=\"text-sm font-medium\">لا توجد سجلات مطابقة</p>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {query.get('search') || activeFilters.length\n ? 'غيّر البحث أو التصفية لعرض سجلات أخرى.'\n : 'لم تُضف سجلات ضمن نطاقك بعد.'}\n </p>\n </td>\n </tr>\n )}\n </tbody>\n </table>\n </div>\n </InfiniteScroll>\n </section>\n )\n}\n\n/** Presets over the same query keys the list already accepts; the kit validates every one. */\nfunction SavedViewsBar({\n resource,\n views,\n current,\n onApply,\n onChanged,\n onFailure,\n}: {\n resource: ResourceDescription\n views: SavedView[]\n current: () => SavedViewQuery\n onApply: (view: SavedView) => void\n onChanged: () => void\n onFailure: (message: string) => void\n}) {\n const [naming, setNaming] = useState(false)\n const [name, setName] = useState('')\n const [shared, setShared] = useState(false)\n const [busy, setBusy] = useState(false)\n const base = `/resources/${resource.name}/views`\n const save = async (event: React.FormEvent) => {\n event.preventDefault()\n setBusy(true)\n onFailure('')\n try {\n await axios.post(\n base,\n { name, query: current(), shared },\n { headers: { Accept: 'application/json' }, withXSRFToken: true }\n )\n setNaming(false)\n setName('')\n setShared(false)\n onChanged()\n } catch (error) {\n onFailure(\n axios.isAxiosError(error) && error.response?.data?.error?.message\n ? String(error.response.data.error.message)\n : 'تعذر حفظ العرض.'\n )\n } finally {\n setBusy(false)\n }\n }\n const remove = async (view: SavedView) => {\n setBusy(true)\n onFailure('')\n try {\n await axios.delete(`${base}/${view.id}`, {\n headers: { Accept: 'application/json' },\n withXSRFToken: true,\n })\n onChanged()\n } catch {\n onFailure('تعذر حذف العرض.')\n } finally {\n setBusy(false)\n }\n }\n return (\n <div className=\"flex flex-wrap items-center gap-2 border-b px-5 py-3\">\n <span className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n <Bookmark size={15} />\n العروض المحفوظة\n </span>\n {views.length === 0 && <span className=\"text-xs text-muted-foreground\">لا عروض بعد</span>}\n {views.map((view) => (\n <span\n key={view.id}\n className=\"flex items-center gap-1 rounded-full border border-border bg-background/60 ps-1 pe-3\"\n >\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-7 rounded-full px-2 text-xs\"\n onClick={() => onApply(view)}\n >\n {view.name}\n {!view.own && <span className=\"text-muted-foreground\"> · مشترك</span>}\n </Button>\n {view.own && (\n <button\n type=\"button\"\n aria-label={`حذف العرض ${view.name}`}\n disabled={busy}\n onClick={() => remove(view)}\n className=\"text-muted-foreground transition-colors hover:text-destructive\"\n >\n <X size={13} />\n </button>\n )}\n </span>\n ))}\n {naming ? (\n <form onSubmit={save} className=\"flex flex-wrap items-center gap-2\">\n <Input\n aria-label=\"اسم العرض\"\n value={name}\n autoFocus\n maxLength={60}\n onChange={(event) => setName(event.target.value)}\n placeholder=\"اسم العرض\"\n className=\"h-8 w-44 text-xs\"\n />\n <label className=\"flex items-center gap-1 text-xs text-muted-foreground\">\n <input\n type=\"checkbox\"\n checked={shared}\n onChange={(event) => setShared(event.target.checked)}\n />\n مشترك\n </label>\n <Button type=\"submit\" size=\"sm\" className=\"h-8\" disabled={busy || !name.trim()}>\n حفظ\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-8\"\n onClick={() => setNaming(false)}\n >\n إلغاء\n </Button>\n </form>\n ) : (\n <Button variant=\"outline\" size=\"sm\" className=\"h-8 text-xs\" onClick={() => setNaming(true)}>\n <BookmarkPlus size={14} />\n حفظ العرض الحالي\n </Button>\n )}\n </div>\n )\n}\n\nfunction Cell({\n field,\n row,\n related,\n lookups,\n}: {\n field: ResourceField\n row: SerializedRecord\n related: ResourceList['related']\n lookups: LookupOptions\n}) {\n const { calendar } = useUiPreferences()\n const text = displayValue(field, row, related, lookups, calendar)\n const numeric = ['money', 'integer', 'date', 'datetime'].includes(field.type)\n return (\n <td\n className={`max-w-[280px] truncate px-5 ${numeric ? 'tabular-nums' : ''}`}\n dir={numeric ? 'ltr' : undefined}\n title={text}\n >\n {field.type === 'boolean' && row[field.key] !== null && row[field.key] !== undefined ? (\n <Badge variant={row[field.key] ? 'default' : 'secondary'}>{text}</Badge>\n ) : (\n text\n )}\n </td>\n )\n}\n\nfunction Filter({\n field,\n value,\n options,\n onApply,\n}: {\n field: ResourceField\n value: string\n options?: Option[]\n onApply: (value: string | undefined) => void\n}): ReactNode {\n const [draft, setDraft] = useState(value)\n const id = `filter-${field.key}`\n const select = (choices: Option[]) => (\n <select\n id={id}\n className=\"h-9 rounded-md border border-input bg-white px-3 text-xs\"\n value={value}\n onChange={(event) => onApply(event.target.value || undefined)}\n >\n <option value=\"\">الكل</option>\n {choices.map((option) => (\n <option key={option.value} value={option.value}>\n {option.label}\n </option>\n ))}\n </select>\n )\n return (\n <label htmlFor={id} className=\"flex flex-col gap-1 text-xs text-muted-foreground\">\n {field.label.ar}\n {field.type === 'lookup' ? (\n select(options ?? [])\n ) : field.type === 'boolean' ? (\n select([\n { value: 'true', label: 'نعم' },\n { value: 'false', label: 'لا' },\n ])\n ) : field.type === 'belongsTo' ? (\n <RelationFilter field={field} value={value} onApply={onApply} />\n ) : field.type === 'date' ? (\n <div className=\"w-56\">\n <FieldControl\n field={field}\n id={id}\n value={draft}\n label={`تصفية ${field.label.ar}`}\n onChange={(next) => {\n setDraft(next)\n try {\n const parsed = parseValue(field, next)\n onApply(parsed ? String(parsed) : undefined)\n } catch {\n // Keep partial or invalid input without issuing an invalid date query.\n }\n }}\n />\n </div>\n ) : (\n <Input\n id={id}\n className=\"h-9 w-44 text-xs\"\n value={draft}\n placeholder=\"اضغط Enter للتطبيق\"\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter') {\n event.preventDefault()\n onApply(draft || undefined)\n }\n }}\n />\n )}\n </label>\n )\n}\n\n/** Filters by a related record through the target list route, which enforces the target's own rules. */\nfunction RelationFilter({\n field,\n value,\n onApply,\n}: {\n field: ResourceField & { type: 'belongsTo' }\n value: string\n onApply: (value: string | undefined) => void\n}) {\n const [term, setTerm] = useState('')\n const [choices, setChoices] = useState<Option[]>([])\n const [busy, setBusy] = useState(false)\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined)\n const load = async (search: string) => {\n setBusy(true)\n try {\n const response = await axios.get<ResourceList>(`/resources/${field.resource}`, {\n params: { limit: 50, estimate: 'false', ...(search ? { search } : {}) },\n headers: { Accept: 'application/json' },\n })\n setChoices(\n response.data.data.map((row) => ({\n value: String(row.id),\n label: relationLabel(row, row.id),\n }))\n )\n } catch {\n setChoices([])\n } finally {\n setBusy(false)\n }\n }\n return (\n <span className=\"flex items-center gap-2\">\n <Input\n id={`filter-${field.key}`}\n className=\"h-9 w-44 text-xs\"\n value={term}\n placeholder={value ? `#${value} · ابحث للتغيير` : 'ابحث في السجلات المرتبطة'}\n onChange={(event) => {\n setTerm(event.target.value)\n clearTimeout(timer.current)\n timer.current = setTimeout(() => void load(event.target.value), 300)\n }}\n onFocus={() => {\n if (!choices.length) void load('')\n }}\n />\n <select\n aria-label={`اختيار ${field.label.ar}`}\n className=\"h-9 max-w-44 rounded-md border border-input bg-white px-2 text-xs\"\n value={value}\n disabled={busy && !choices.length}\n onChange={(event) => onApply(event.target.value || undefined)}\n >\n <option value=\"\">{busy ? 'جارٍ البحث...' : 'الكل'}</option>\n {value && !choices.some((choice) => choice.value === value) && (\n <option value={value}>#{value}</option>\n )}\n {choices.map((choice) => (\n <option key={choice.value} value={choice.value}>\n {choice.label}\n </option>\n ))}\n </select>\n </span>\n )\n}\n",
|
|
27
|
+
"content": "import {\n useEffect,\n useId,\n useRef,\n useState,\n type ComponentRef,\n type MouseEvent,\n type ReactNode,\n} from 'react'\nimport { InfiniteScroll, router, usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { tableFeatures, useTable } from '@tanstack/react-table'\nimport { useVirtualizer } from '@tanstack/react-virtual'\nimport axios from 'axios'\nimport {\n ArrowDown,\n ArrowDownUp,\n ArrowUp,\n Check,\n ChevronsUpDown,\n Download,\n Inbox,\n Loader2,\n Bookmark,\n BookmarkPlus,\n Search,\n SlidersHorizontal,\n X,\n} from 'lucide-react'\nimport type {\n ResourceDescription,\n ResourceField,\n ResourceList,\n SavedView,\n SavedViewQuery,\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 { Input } from '~/components/ui/input'\nimport { ResourceActions } from '~/components/ui/resource-actions'\nimport { openRecord, rememberListView } from '~/components/ui/resource-surface'\nimport { FieldControl, ResourceSelect } from '~/components/ui/resource-field'\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from '~/components/ui/command'\nimport { Popover, PopoverContent, PopoverTrigger } from '~/components/ui/popover'\nimport {\n csvCell,\n displayValue,\n parseValue,\n relationLabel,\n type LookupOptions,\n type Option,\n} from '~/components/ui/resource-value'\n\n/** Rows above this count render through the virtualizer (plan section 13). */\nexport const VIRTUAL_ROWS = 200\nconst ROW_HEIGHT = 52\nconst PAGE_SIZE = 100\nconst features = tableFeatures({})\ntype Query = Record<string, string | undefined>\n\nexport function DataTable({\n resource,\n result,\n lookups = {},\n savedViews = [],\n}: {\n resource: ResourceDescription\n result: ResourceList\n lookups?: LookupOptions\n savedViews?: SavedView[]\n}) {\n const { calendar } = useUiPreferences()\n const { url } = usePage()\n const query = new URL(url, 'http://localhost').searchParams\n const [search, setSearch] = useState(query.get('search') ?? '')\n const [failure, setFailure] = useState('')\n const container = useRef<HTMLDivElement>(null)\n const scroller = useRef<ComponentRef<typeof InfiniteScroll>>(null)\n // Opening one of this list's records remembers the view, so closing the dialog returns to it.\n useEffect(\n () =>\n router.on('before', (event) => {\n const { pathname } = event.detail.visit.url\n if (pathname.startsWith(`/resources/${resource.name}/`)) rememberListView(pathname)\n }),\n [resource.name]\n )\n // Inertia merges `result.data` page by page; permissions and labels of earlier pages accumulate here.\n const store = useRef<{\n source: ResourceList | null\n permissions: ResourceList['permissions']\n related: ResourceList['related']\n }>({ source: null, permissions: {}, related: {} })\n if (store.current.source !== result) {\n const related = { ...store.current.related }\n for (const [key, rows] of Object.entries(result.related))\n related[key] = [\n ...new Map([...(related[key] ?? []), ...rows].map((row) => [String(row.id), row])).values(),\n ]\n store.current = {\n source: result,\n permissions: { ...store.current.permissions, ...result.permissions },\n related,\n }\n }\n const { permissions, related } = store.current\n const fields = resource.fields.filter(\n (field) => resource.list.includes(field.key) && field.type !== 'hasMany'\n )\n const filterable = fields.filter((field) => field.filterable)\n const table = useTable({\n features,\n data: result.data,\n columns: fields.map((field) => ({ accessorKey: field.key, id: field.key })),\n getRowId: (row) => String(row.id),\n })\n const rows = table.getRowModel().rows\n const virtualized = rows.length > VIRTUAL_ROWS\n const virtual = useVirtualizer({\n count: rows.length,\n getScrollElement: () => container.current,\n estimateSize: () => ROW_HEIGHT,\n overscan: 10,\n enabled: virtualized,\n getItemKey: (index) => rows[index].id,\n })\n const items = virtualized\n ? virtual.getVirtualItems()\n : rows.map((_, index) => ({ index, start: 0, end: 0 }))\n const navigate = (changes: Query) => {\n const next = new URLSearchParams(query)\n next.delete('cursor')\n for (const [key, value] of Object.entries(changes))\n value ? next.set(key, value) : next.delete(key)\n const suffix = next.toString()\n router.get(\n `/resources/${resource.name}${suffix ? `?${suffix}` : ''}`,\n {},\n { preserveScroll: true }\n )\n }\n const sort = query.get('sort')\n const direction = query.get('direction') === 'desc' ? 'desc' : 'asc'\n const activeFilters = filterable.filter((field) => query.get(`filters[${field.key}]`))\n const csv = () => {\n const content = [\n fields.map((field) => field.label.ar),\n ...result.data.map((row) =>\n fields.map((field) => displayValue(field, row, related, lookups, calendar))\n ),\n ]\n const href = URL.createObjectURL(\n new Blob(['' + content.map((row) => row.map(csvCell).join(',')).join('\\r\\n')], {\n type: 'text/csv;charset=utf-8',\n })\n )\n const link = document.createElement('a')\n link.href = href\n link.download = `${resource.label}.csv`\n link.click()\n URL.revokeObjectURL(href)\n }\n const refresh = () => router.visit(url, { preserveScroll: true })\n // Records open in a dialog over this list, which stays mounted (#31); modified clicks\n // (new tab, new window) keep the link's own behavior.\n const overlay = (event: MouseEvent, id: string, mode: 'show' | 'edit') => {\n if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)\n return\n event.preventDefault()\n void openRecord(resource.name, id, mode)\n }\n const currentQuery = (): SavedViewQuery => {\n const view: SavedViewQuery = {}\n const text = query.get('search')\n if (text) view.search = text\n if (sort) {\n view.sort = sort\n view.direction = direction\n }\n const filters: Record<string, string> = {}\n for (const field of filterable) {\n const value = query.get(`filters[${field.key}]`)\n if (value) filters[field.key] = value\n }\n if (Object.keys(filters).length) view.filters = filters\n return view\n }\n const applyView = (view: SavedView) => {\n const next = new URLSearchParams()\n if (view.query.search) next.set('search', view.query.search)\n if (view.query.sort) {\n next.set('sort', view.query.sort)\n next.set('direction', view.query.direction ?? 'asc')\n }\n for (const [key, value] of Object.entries(view.query.filters ?? {}))\n if (value !== null) next.set(`filters[${key}]`, String(value))\n setSearch(view.query.search ?? '')\n const suffix = next.toString()\n router.get(\n `/resources/${resource.name}${suffix ? `?${suffix}` : ''}`,\n {},\n { preserveScroll: true }\n )\n }\n return (\n <section\n aria-label={`قائمة ${resource.label}`}\n className=\"min-w-0 overflow-hidden rounded-xl border border-border bg-white shadow-[0_2px_10px_#1c302804]\"\n >\n <div className=\"flex flex-wrap items-center justify-between gap-4 border-b px-5 py-4\">\n <div className=\"flex flex-wrap items-center gap-3\">\n <h2 className=\"text-sm font-semibold\">سجل {resource.label}</h2>\n <Badge variant=\"secondary\" className=\"font-normal tabular-nums\">\n {rows.length} معروض\n </Badge>\n <span className=\"text-xs text-muted-foreground\">\n {result.meta.estimatedTotal !== undefined\n ? `نحو ${result.meta.estimatedTotal} سجل ضمن نطاقك`\n : 'حسب نطاق صلاحياتك'}\n </span>\n </div>\n <Button variant=\"outline\" size=\"sm\" onClick={csv} disabled={!rows.length}>\n <Download size={15} />\n تصدير المعروض\n </Button>\n </div>\n {(resource.searchable || filterable.length > 0) && (\n <SavedViewsBar\n resource={resource}\n views={savedViews}\n current={currentQuery}\n onApply={applyView}\n onChanged={refresh}\n onFailure={setFailure}\n />\n )}\n {(resource.searchable || filterable.length > 0) && (\n <div className=\"flex flex-wrap items-end gap-3 border-b px-5 py-4\">\n {resource.searchable && (\n <form\n role=\"search\"\n className=\"relative w-full max-w-sm\"\n onSubmit={(event) => {\n event.preventDefault()\n navigate({ search })\n }}\n >\n <Search\n className=\"pointer-events-none absolute start-3 top-2.5 text-muted-foreground\"\n size={16}\n />\n <Input\n aria-label={`البحث في ${resource.label}`}\n value={search}\n onChange={(event) => setSearch(event.target.value)}\n placeholder=\"ابحث في السجلات...\"\n className=\"h-9 bg-background/60 ps-10 text-xs\"\n />\n </form>\n )}\n {filterable.length > 0 && (\n <span className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n <SlidersHorizontal size={15} />\n تصفية\n </span>\n )}\n {filterable.map((field) => (\n <Filter\n key={field.key}\n resource={resource.name}\n field={field}\n value={query.get(`filters[${field.key}]`) ?? ''}\n options={lookups[field.key]}\n related={related[field.key]}\n onApply={(value) => navigate({ [`filters[${field.key}]`]: value })}\n />\n ))}\n {activeFilters.length > 0 && (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={() =>\n navigate(\n Object.fromEntries(\n activeFilters.map((field) => [`filters[${field.key}]`, undefined])\n )\n )\n }\n >\n <X size={14} />\n مسح التصفية\n </Button>\n )}\n </div>\n )}\n <InfiniteScroll\n ref={(instance) => {\n scroller.current = instance\n }}\n data=\"result\"\n manual\n onlyNext\n preserveUrl\n params={{\n data: { limit: PAGE_SIZE },\n onBefore: () => {\n setFailure('')\n },\n onHttpException: () => {\n setFailure('تعذر تحميل السجلات التالية. أعد المحاولة.')\n return false\n },\n onNetworkError: () => {\n setFailure('تعذر الاتصال بالخادم. تحقق من الاتصال ثم أعد المحاولة.')\n return false\n },\n }}\n next={({ fetch, loading, hasMore }) => (\n <div className=\"flex flex-wrap items-center justify-center gap-4 border-t px-5 py-3 text-xs text-muted-foreground\">\n {failure && (\n <p role=\"alert\" className=\"text-destructive\">\n {failure}\n </p>\n )}\n {loading ? (\n <span role=\"status\" className=\"inline-flex items-center gap-2\">\n <Loader2 size={14} className=\"animate-spin\" />\n جارٍ تحميل المزيد...\n </span>\n ) : hasMore ? (\n <Button variant=\"ghost\" size=\"sm\" onClick={fetch}>\n تحميل المزيد\n </Button>\n ) : (\n <span>{rows.length ? 'نهاية السجلات' : ''}</span>\n )}\n </div>\n )}\n >\n <div\n ref={container}\n data-slot=\"table-scroll\"\n className=\"max-h-[600px] overflow-auto\"\n onScroll={(event) => {\n const element = event.currentTarget\n if (\n !failure &&\n element.scrollHeight - element.scrollTop - element.clientHeight < 200 &&\n scroller.current?.hasNext()\n )\n scroller.current.fetchNext()\n }}\n >\n <table\n className=\"w-full min-w-[720px] text-start text-sm\"\n aria-rowcount={rows.length + 1}\n aria-label={resource.label}\n >\n <thead className=\"sticky top-0 z-10 bg-muted text-xs text-muted-foreground\">\n <tr>\n {fields.map((field) => (\n <th\n key={field.key}\n scope=\"col\"\n className=\"h-11 whitespace-nowrap px-5 text-start font-medium\"\n aria-sort={\n sort === field.key\n ? direction === 'desc'\n ? 'descending'\n : 'ascending'\n : undefined\n }\n >\n {field.sortable ? (\n <button\n type=\"button\"\n className=\"inline-flex items-center gap-2 hover:text-foreground\"\n onClick={() =>\n navigate({\n sort: field.key,\n direction: sort === field.key && direction === 'asc' ? 'desc' : 'asc',\n })\n }\n >\n {field.label.ar}\n {sort === field.key ? (\n direction === 'desc' ? (\n <ArrowDown size={12} />\n ) : (\n <ArrowUp size={12} />\n )\n ) : (\n <ArrowDownUp size={12} className=\"opacity-60\" />\n )}\n </button>\n ) : (\n field.label.ar\n )}\n </th>\n ))}\n <th\n scope=\"col\"\n className=\"sticky end-0 border-s bg-muted px-5 text-start font-medium\"\n >\n الإجراءات\n </th>\n </tr>\n </thead>\n <tbody>\n {virtualized && items[0] && items[0].start > 0 && (\n <tr aria-hidden=\"true\">\n <td colSpan={fields.length + 1} style={{ height: items[0].start }} />\n </tr>\n )}\n {items.map(({ index }) => {\n const row = rows[index].original\n const id = String(row.id)\n return (\n <tr\n key={id}\n aria-rowindex={index + 2}\n className=\"group border-b last:border-0 hover:bg-background\"\n style={{ height: ROW_HEIGHT }}\n >\n {fields.map((field) => (\n <Cell\n key={field.key}\n field={field}\n row={row}\n related={related}\n lookups={lookups}\n />\n ))}\n {/* Pinned at the inline end so wide tables keep their row actions in view. */}\n <td className=\"sticky end-0 whitespace-nowrap border-s bg-card px-5 text-xs group-hover:bg-background\">\n <span className=\"inline-flex items-center gap-1\">\n <Button variant=\"ghost\" size=\"sm\" asChild>\n <Link\n href={`/resources/${resource.name}/${id}`}\n aria-label={`عرض السجل ${id}`}\n onClick={(event) => overlay(event, id, 'show')}\n >\n عرض\n </Link>\n </Button>\n <Can permissions={permissions[id] ?? {}} action=\"update\">\n <Button variant=\"ghost\" size=\"sm\" asChild>\n <Link\n href={`/resources/${resource.name}/${id}/edit`}\n aria-label={`تعديل السجل ${id}`}\n onClick={(event) => overlay(event, id, 'edit')}\n >\n تعديل\n </Link>\n </Button>\n </Can>\n <ResourceActions\n resource={resource.name}\n id={id}\n version={row.version ?? undefined}\n permissions={permissions[id] ?? {}}\n label={`السجل ${id}`}\n size=\"sm\"\n variant=\"ghost\"\n onDone={refresh}\n />\n </span>\n </td>\n </tr>\n )\n })}\n {virtualized && (\n <tr aria-hidden=\"true\">\n <td\n colSpan={fields.length + 1}\n style={{\n height: Math.max(0, virtual.getTotalSize() - (items.at(-1)?.end ?? 0)),\n }}\n />\n </tr>\n )}\n {!rows.length && (\n <tr>\n <td colSpan={fields.length + 1} className=\"py-16 text-center\">\n <Inbox\n className=\"mx-auto mb-3 text-muted-foreground\"\n size={30}\n strokeWidth={1.3}\n />\n <p className=\"text-sm font-medium\">لا توجد سجلات مطابقة</p>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {query.get('search') || activeFilters.length\n ? 'غيّر البحث أو التصفية لعرض سجلات أخرى.'\n : 'لم تُضف سجلات ضمن نطاقك بعد.'}\n </p>\n </td>\n </tr>\n )}\n </tbody>\n </table>\n </div>\n </InfiniteScroll>\n </section>\n )\n}\n\n/** Presets over the same query keys the list already accepts; the kit validates every one. */\nfunction SavedViewsBar({\n resource,\n views,\n current,\n onApply,\n onChanged,\n onFailure,\n}: {\n resource: ResourceDescription\n views: SavedView[]\n current: () => SavedViewQuery\n onApply: (view: SavedView) => void\n onChanged: () => void\n onFailure: (message: string) => void\n}) {\n const [naming, setNaming] = useState(false)\n const [name, setName] = useState('')\n const [shared, setShared] = useState(false)\n const [busy, setBusy] = useState(false)\n const base = `/resources/${resource.name}/views`\n const save = async (event: React.FormEvent) => {\n event.preventDefault()\n setBusy(true)\n onFailure('')\n try {\n await axios.post(\n base,\n { name, query: current(), shared },\n { headers: { Accept: 'application/json' }, withXSRFToken: true }\n )\n setNaming(false)\n setName('')\n setShared(false)\n onChanged()\n } catch (error) {\n onFailure(\n axios.isAxiosError(error) && error.response?.data?.error?.message\n ? String(error.response.data.error.message)\n : 'تعذر حفظ العرض.'\n )\n } finally {\n setBusy(false)\n }\n }\n const remove = async (view: SavedView) => {\n setBusy(true)\n onFailure('')\n try {\n await axios.delete(`${base}/${view.id}`, {\n headers: { Accept: 'application/json' },\n withXSRFToken: true,\n })\n onChanged()\n } catch {\n onFailure('تعذر حذف العرض.')\n } finally {\n setBusy(false)\n }\n }\n return (\n <div className=\"flex flex-wrap items-center gap-2 border-b px-5 py-3\">\n <span className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n <Bookmark size={15} />\n العروض المحفوظة\n </span>\n {views.length === 0 && <span className=\"text-xs text-muted-foreground\">لا عروض بعد</span>}\n {views.map((view) => (\n <span\n key={view.id}\n className=\"flex items-center gap-1 rounded-full border border-border bg-background/60 ps-1 pe-3\"\n >\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-7 rounded-full px-2 text-xs\"\n onClick={() => onApply(view)}\n >\n {view.name}\n {!view.own && <span className=\"text-muted-foreground\"> · مشترك</span>}\n </Button>\n {view.own && (\n <button\n type=\"button\"\n aria-label={`حذف العرض ${view.name}`}\n disabled={busy}\n onClick={() => remove(view)}\n className=\"text-muted-foreground transition-colors hover:text-destructive\"\n >\n <X size={13} />\n </button>\n )}\n </span>\n ))}\n {naming ? (\n <form onSubmit={save} className=\"flex flex-wrap items-center gap-2\">\n <Input\n aria-label=\"اسم العرض\"\n value={name}\n autoFocus\n maxLength={60}\n onChange={(event) => setName(event.target.value)}\n placeholder=\"اسم العرض\"\n className=\"h-8 w-44 text-xs\"\n />\n <label className=\"flex items-center gap-1 text-xs text-muted-foreground\">\n <input\n type=\"checkbox\"\n checked={shared}\n onChange={(event) => setShared(event.target.checked)}\n />\n مشترك\n </label>\n <Button type=\"submit\" size=\"sm\" className=\"h-8\" disabled={busy || !name.trim()}>\n حفظ\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-8\"\n onClick={() => setNaming(false)}\n >\n إلغاء\n </Button>\n </form>\n ) : (\n <Button variant=\"outline\" size=\"sm\" className=\"h-8 text-xs\" onClick={() => setNaming(true)}>\n <BookmarkPlus size={14} />\n حفظ العرض الحالي\n </Button>\n )}\n </div>\n )\n}\n\nfunction Cell({\n field,\n row,\n related,\n lookups,\n}: {\n field: ResourceField\n row: SerializedRecord\n related: ResourceList['related']\n lookups: LookupOptions\n}) {\n const { calendar } = useUiPreferences()\n const text = displayValue(field, row, related, lookups, calendar)\n const numeric = ['money', 'integer', 'date', 'datetime'].includes(field.type)\n return (\n <td\n className={`max-w-[280px] truncate px-5 ${numeric ? 'tabular-nums' : ''}`}\n dir={numeric ? 'ltr' : undefined}\n title={text}\n >\n {field.type === 'boolean' && row[field.key] !== null && row[field.key] !== undefined ? (\n <Badge variant={row[field.key] ? 'default' : 'secondary'}>{text}</Badge>\n ) : (\n text\n )}\n </td>\n )\n}\n\nfunction Filter({\n resource,\n field,\n value,\n options,\n related,\n onApply,\n}: {\n resource: string\n field: ResourceField\n value: string\n options?: Option[]\n related?: SerializedRecord[]\n onApply: (value: string | undefined) => void\n}): ReactNode {\n const [draft, setDraft] = useState(value)\n const id = `filter-${field.key}`\n const select = (choices: Option[]) => (\n <ResourceSelect\n id={id}\n className=\"h-9 w-44 text-xs\"\n value={value}\n placeholder=\"الكل\"\n options={choices}\n onChange={(next) => onApply(next || undefined)}\n />\n )\n return (\n <label htmlFor={id} className=\"flex flex-col gap-1 text-xs text-muted-foreground\">\n {field.label.ar}\n {field.type === 'lookup' ? (\n select(options ?? [])\n ) : field.type === 'boolean' ? (\n select([\n { value: 'true', label: 'نعم' },\n { value: 'false', label: 'لا' },\n ])\n ) : field.type === 'belongsTo' || field.type === 'user' ? (\n <RelationFilter\n resource={resource}\n field={field}\n value={value}\n related={related}\n onApply={onApply}\n />\n ) : field.type === 'date' ? (\n <div className=\"w-56\">\n <FieldControl\n field={field}\n id={id}\n value={draft}\n label={`تصفية ${field.label.ar}`}\n onChange={(next) => {\n setDraft(next)\n try {\n const parsed = parseValue(field, next)\n onApply(parsed ? String(parsed) : undefined)\n } catch {\n // Keep partial or invalid input without issuing an invalid date query.\n }\n }}\n />\n </div>\n ) : (\n <Input\n id={id}\n className=\"h-9 w-44 text-xs\"\n value={draft}\n placeholder=\"اضغط Enter للتطبيق\"\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter') {\n event.preventDefault()\n onApply(draft || undefined)\n }\n }}\n />\n )}\n </label>\n )\n}\n\n/**\n * Filters by a related record through the target list route, which enforces the target's\n * own rules. User fields list users through the field's options route instead.\n */\nfunction RelationFilter({\n resource,\n field,\n value,\n related = [],\n onApply,\n}: {\n resource: string\n field: ResourceField & { type: 'belongsTo' | 'user' }\n value: string\n /** Related rows already loaded with the list; they label the active filter after a reload. */\n related?: SerializedRecord[]\n onApply: (value: string | undefined) => void\n}) {\n const [open, setOpen] = useState(false)\n const [term, setTerm] = useState('')\n const [choices, setChoices] = useState<Option[]>([])\n const [selected, setSelected] = useState<Option | null>(null)\n const [busy, setBusy] = useState(false)\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined)\n const listId = useId()\n const load = async (search: string) => {\n setBusy(true)\n try {\n if (field.type === 'user') {\n const users = await axios.get<{ data: Option[] }>(\n `/resources/${resource}/options/${field.key}`,\n {\n params: { purpose: 'filter', ...(search ? { search } : {}) },\n headers: { Accept: 'application/json' },\n }\n )\n setChoices(users.data.data)\n return\n }\n const response = await axios.get<ResourceList>(`/resources/${field.resource}`, {\n params: {\n limit: 50,\n estimate: 'false',\n ...(search ? { search } : {}),\n },\n headers: { Accept: 'application/json' },\n })\n setChoices(\n response.data.data.map((row) => ({\n value: String(row.id),\n label: relationLabel(row, row.id),\n }))\n )\n } catch {\n setChoices([])\n } finally {\n setBusy(false)\n }\n }\n const loaded = related.find((row) => String(row.id) === value)\n const current =\n choices.find((choice) => choice.value === value) ??\n (selected?.value === value ? selected : null) ??\n (loaded ? { value, label: relationLabel(loaded, value) } : null)\n const choose = (option: Option | null) => {\n setSelected(option)\n setOpen(false)\n onApply(option?.value)\n }\n return (\n <Popover\n open={open}\n onOpenChange={(next) => {\n setOpen(next)\n if (next && !choices.length) void load(term)\n }}\n >\n <PopoverTrigger asChild>\n <Button\n id={`filter-${field.key}`}\n type=\"button\"\n role=\"combobox\"\n variant=\"outline\"\n aria-expanded={open}\n aria-controls={listId}\n className={`h-9 w-44 justify-between bg-transparent text-xs font-normal ${value ? '' : 'text-muted-foreground'}`}\n >\n <span className=\"truncate\">{value ? (current?.label ?? `#${value}`) : 'الكل'}</span>\n <ChevronsUpDown className=\"opacity-50\" />\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-64 p-0\" align=\"start\">\n <Command shouldFilter={false}>\n <CommandInput\n placeholder=\"ابحث في السجلات المرتبطة\"\n aria-label={`البحث في ${field.label.ar}`}\n value={term}\n onValueChange={(next) => {\n setTerm(next)\n clearTimeout(timer.current)\n timer.current = setTimeout(() => void load(next), 300)\n }}\n />\n <CommandList id={listId}>\n <CommandEmpty>{busy ? 'جارٍ البحث...' : 'لا توجد سجلات مطابقة'}</CommandEmpty>\n <CommandGroup>\n <CommandItem value=\"__all__\" onSelect={() => choose(null)}>\n <Check className={value ? 'opacity-0' : 'opacity-100'} />\n الكل\n </CommandItem>\n {choices.map((choice) => (\n <CommandItem\n key={choice.value}\n value={choice.value}\n onSelect={() => choose(choice)}\n >\n <Check className={choice.value === value ? 'opacity-100' : 'opacity-0'} />\n {choice.label}\n </CommandItem>\n ))}\n </CommandGroup>\n </CommandList>\n </Command>\n </PopoverContent>\n </Popover>\n )\n}\n",
|
|
25
28
|
"target": "inertia/components/ui/data-table.tsx"
|
|
26
29
|
}
|
|
27
30
|
],
|
package/build/manifest.json
CHANGED
|
@@ -1,195 +1,215 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "
|
|
3
|
-
"kitCompatibility": "
|
|
2
|
+
"version": "1.2.0",
|
|
3
|
+
"kitCompatibility": "1.2",
|
|
4
4
|
"items": [
|
|
5
5
|
{
|
|
6
6
|
"name": "button",
|
|
7
|
-
"version": "
|
|
7
|
+
"version": "1.2.0",
|
|
8
8
|
"hash": "cf1a045e41f5029fe9a8e3f3513a142dbe6550d6ffbe560b652a0aed1bb76f94"
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"name": "input",
|
|
12
|
-
"version": "
|
|
12
|
+
"version": "1.2.0",
|
|
13
13
|
"hash": "5a398efe048aff90155d858e63290dee2b7ad31c060936ac5bfce5ecf691e022"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"name": "textarea",
|
|
17
|
-
"version": "
|
|
17
|
+
"version": "1.2.0",
|
|
18
18
|
"hash": "f0f615a9112e64ccb0d580d9325558c97750c24bcce933211acd2fa01c24b008"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"name": "select",
|
|
22
|
-
"version": "
|
|
22
|
+
"version": "1.2.0",
|
|
23
23
|
"hash": "25524a9856101efd64d4fa9922cd3c3ab36da05d84d75f06aec102f9fbc3b554"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"name": "checkbox",
|
|
27
|
-
"version": "
|
|
27
|
+
"version": "1.2.0",
|
|
28
28
|
"hash": "631d1bacffcb6856a210bb2a40301f589cd0a74dfe28363e6fe3393e6c658ffa"
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"name": "switch",
|
|
32
|
-
"version": "
|
|
32
|
+
"version": "1.2.0",
|
|
33
33
|
"hash": "f41dc7b8d0b3d36a5b74460b651f20046c70a825321ea5ba8e32b575e87e9fb4"
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"name": "radio-group",
|
|
37
|
-
"version": "
|
|
37
|
+
"version": "1.2.0",
|
|
38
38
|
"hash": "2f6e9ad7d314703388e20cbea51d3f3bae35e0aedb239fe7e58f71dc892110e0"
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
"name": "table",
|
|
42
|
-
"version": "
|
|
42
|
+
"version": "1.2.0",
|
|
43
43
|
"hash": "2a76f50a6672357e167a2ebc1ea82535c9298629d94f5aa9ee57108e5c535f66"
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
"name": "form",
|
|
47
|
-
"version": "
|
|
47
|
+
"version": "1.2.0",
|
|
48
48
|
"hash": "2b164e3985524723c3997a334fe04b3232a52a4a37ac81c1acac25968f709628"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"name": "label",
|
|
52
|
-
"version": "
|
|
52
|
+
"version": "1.2.0",
|
|
53
53
|
"hash": "adb3d62721dfe74a6e812cee5ac8b2a0f5ad8821140a0d017e4bdabd2fd7b966"
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
56
|
"name": "dialog",
|
|
57
|
-
"version": "
|
|
57
|
+
"version": "1.2.0",
|
|
58
58
|
"hash": "04b47b3d9de9d4998eb610a6753c115cf3aa5db88e6889caac337f2d95f708bc"
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"name": "sheet",
|
|
62
|
-
"version": "
|
|
62
|
+
"version": "1.2.0",
|
|
63
63
|
"hash": "8b511eba90dbab3ea69faafa384e6b27d5aa1ba4742b47dc526238b2babcf97a"
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"name": "dropdown-menu",
|
|
67
|
-
"version": "
|
|
67
|
+
"version": "1.2.0",
|
|
68
68
|
"hash": "622b2ba53ab5d9ebf28de16078a0051adb5a1035d63f5febeb3992fc1c094ff9"
|
|
69
69
|
},
|
|
70
70
|
{
|
|
71
71
|
"name": "popover",
|
|
72
|
-
"version": "
|
|
72
|
+
"version": "1.2.0",
|
|
73
73
|
"hash": "288f01bbff64e03473dfb7c0b680f0ca77d610f646653b29364ae245d8491e03"
|
|
74
74
|
},
|
|
75
75
|
{
|
|
76
76
|
"name": "command",
|
|
77
|
-
"version": "
|
|
77
|
+
"version": "1.2.0",
|
|
78
78
|
"hash": "47fc8607025e87f3a35f974a55a946d9fee2d0eebd9a681276028e01731d1845"
|
|
79
79
|
},
|
|
80
80
|
{
|
|
81
81
|
"name": "calendar",
|
|
82
|
-
"version": "
|
|
82
|
+
"version": "1.2.0",
|
|
83
83
|
"hash": "a536d790c69a67daf9aad8e76901dd668e02520d38677a3f27e1eadcf6a90ad7"
|
|
84
84
|
},
|
|
85
85
|
{
|
|
86
86
|
"name": "badge",
|
|
87
|
-
"version": "
|
|
87
|
+
"version": "1.2.0",
|
|
88
88
|
"hash": "08a668f461549c6edf7da629899d59373e7cae5686d1533fd5d451fbf7c2e33e"
|
|
89
89
|
},
|
|
90
90
|
{
|
|
91
91
|
"name": "card",
|
|
92
|
-
"version": "
|
|
92
|
+
"version": "1.2.0",
|
|
93
93
|
"hash": "0c7ac44916cc64285da5d8734fd34896a72cabecada2119b68f7f04ac6995b9a"
|
|
94
94
|
},
|
|
95
95
|
{
|
|
96
96
|
"name": "tabs",
|
|
97
|
-
"version": "
|
|
97
|
+
"version": "1.2.0",
|
|
98
98
|
"hash": "ceac90f0124b6c52d5f0268226fc36ec1689feb8540664414158aa8fdebac0f7"
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
"name": "sonner",
|
|
102
|
-
"version": "
|
|
102
|
+
"version": "1.2.0",
|
|
103
103
|
"hash": "433f224e2266ddfa0f42fe7f81a87a55cb4e6284dce8187b97256154409fe10d"
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
106
|
"name": "skeleton",
|
|
107
|
-
"version": "
|
|
107
|
+
"version": "1.2.0",
|
|
108
108
|
"hash": "8dd2b789d3a4b70fb62a99922ef48ee7a0d23a7cda09b00cc1df49c8c29fac90"
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
"name": "pagination",
|
|
112
|
-
"version": "
|
|
112
|
+
"version": "1.2.0",
|
|
113
113
|
"hash": "d1119e2d1e6e80c2556b510c715370c4b9e039e8851461aa28e71c3c1d9d1329"
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
"name": "tooltip",
|
|
117
|
-
"version": "
|
|
117
|
+
"version": "1.2.0",
|
|
118
118
|
"hash": "616d81f4386a0fb6b714e74515fd77328db0314ddb573003adfaf4693f100fe6"
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
"name": "separator",
|
|
122
|
-
"version": "
|
|
122
|
+
"version": "1.2.0",
|
|
123
123
|
"hash": "deb8eb9daf72092fd11d69af65891b721f154fd1bb8d3c7fabb46f33cf612c87"
|
|
124
124
|
},
|
|
125
125
|
{
|
|
126
126
|
"name": "alert",
|
|
127
|
-
"version": "
|
|
127
|
+
"version": "1.2.0",
|
|
128
128
|
"hash": "17940923e71122bb30d830c569e36e3886109cbfdfb405fd3954774fed1f0ead"
|
|
129
129
|
},
|
|
130
130
|
{
|
|
131
131
|
"name": "can",
|
|
132
|
-
"version": "
|
|
132
|
+
"version": "1.2.0",
|
|
133
133
|
"hash": "3c88f2d7fe666e9ba919776177e5aecb388ff5cb77ecb5726ebde7d07d82007b"
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
136
|
"name": "resource-value",
|
|
137
|
-
"version": "
|
|
138
|
-
"hash": "
|
|
137
|
+
"version": "1.2.0",
|
|
138
|
+
"hash": "bfe1aa8b69125f3a6a816808ba062f2d6e8bee9039c97f6f9ac1dcc2025e7535"
|
|
139
139
|
},
|
|
140
140
|
{
|
|
141
141
|
"name": "resource-field",
|
|
142
|
-
"version": "
|
|
143
|
-
"hash": "
|
|
142
|
+
"version": "1.2.0",
|
|
143
|
+
"hash": "afd77212374440e1f2eaf7110c1897600f8f91ee759ed03ba825667659f98505"
|
|
144
144
|
},
|
|
145
145
|
{
|
|
146
146
|
"name": "data-table",
|
|
147
|
-
"version": "
|
|
148
|
-
"hash": "
|
|
147
|
+
"version": "1.2.0",
|
|
148
|
+
"hash": "26ece7a6535bca5ed2b97f1ba5a2dfd75b4b98bd86a916293da752dcf23f6541"
|
|
149
149
|
},
|
|
150
150
|
{
|
|
151
151
|
"name": "resource-form",
|
|
152
|
-
"version": "
|
|
153
|
-
"hash": "
|
|
152
|
+
"version": "1.2.0",
|
|
153
|
+
"hash": "bbc9687ed967ac5afb554bb18de31ca8344301d0e3d4326e32aee0769272077c"
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"name": "record-collaboration",
|
|
157
|
+
"version": "1.2.0",
|
|
158
|
+
"hash": "9cbbfc35065c65e2b9bfe17a5fb7a743cb8ce21dd2eaf32a736cedc60aa2df76"
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"name": "record-assignments",
|
|
162
|
+
"version": "1.2.0",
|
|
163
|
+
"hash": "10160aca4985f5cf2e90db4a3dce6b6daf7e30db0902575e43783b914232d662"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"name": "record-workflows",
|
|
167
|
+
"version": "1.2.0",
|
|
168
|
+
"hash": "4befae90ddafc32e205d2f39c2129be4b23f8b9da7b801716240ba5882215f26"
|
|
154
169
|
},
|
|
155
170
|
{
|
|
156
171
|
"name": "resource-show",
|
|
157
|
-
"version": "
|
|
158
|
-
"hash": "
|
|
172
|
+
"version": "1.2.0",
|
|
173
|
+
"hash": "e9d28f4ea6e1c565ec67702e45acaa7321e78511b16ecfdd6e0c8e8ca4efcd76"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"name": "resource-import",
|
|
177
|
+
"version": "1.2.0",
|
|
178
|
+
"hash": "05561c1ff02de8e75ca4aab1f7adb076ec114dfe271058585292ab17a509bd17"
|
|
159
179
|
},
|
|
160
180
|
{
|
|
161
181
|
"name": "resource-page",
|
|
162
|
-
"version": "
|
|
163
|
-
"hash": "
|
|
182
|
+
"version": "1.2.0",
|
|
183
|
+
"hash": "1c16996b14f004cad4e24d6398e3815029bab88099d83285a7f5c50f9cbb9073"
|
|
164
184
|
},
|
|
165
185
|
{
|
|
166
186
|
"name": "resource-actions",
|
|
167
|
-
"version": "
|
|
168
|
-
"hash": "
|
|
187
|
+
"version": "1.2.0",
|
|
188
|
+
"hash": "8a96d1319b894fdb46061cc259fe6dadc092b84a8567f94a3088cde1bdd1da12"
|
|
169
189
|
},
|
|
170
190
|
{
|
|
171
191
|
"name": "resource-surface",
|
|
172
|
-
"version": "
|
|
173
|
-
"hash": "
|
|
192
|
+
"version": "1.2.0",
|
|
193
|
+
"hash": "a7723c7e3baeb925a81b91142133e423a702df3ebbc5efb9aa3d99b6589034cb"
|
|
174
194
|
},
|
|
175
195
|
{
|
|
176
196
|
"name": "ui-preferences",
|
|
177
|
-
"version": "
|
|
197
|
+
"version": "1.2.0",
|
|
178
198
|
"hash": "999e93b4c4814eef4528dca532c76504060f524f6cc00de2a8f967291855ff03"
|
|
179
199
|
},
|
|
180
200
|
{
|
|
181
201
|
"name": "calendar-date",
|
|
182
|
-
"version": "
|
|
183
|
-
"hash": "
|
|
202
|
+
"version": "1.2.0",
|
|
203
|
+
"hash": "22c6fcd3c1c5da7e6df0138ae5617f8098a19ea2c4a9432f2612ccf8be5b787d"
|
|
184
204
|
},
|
|
185
205
|
{
|
|
186
206
|
"name": "hijri-calendar",
|
|
187
|
-
"version": "
|
|
207
|
+
"version": "1.2.0",
|
|
188
208
|
"hash": "39ca5de9c1df32e65e434241f3cbbae629463c9f21da93205274c177fc8b85f3"
|
|
189
209
|
},
|
|
190
210
|
{
|
|
191
211
|
"name": "confirm-action",
|
|
192
|
-
"version": "
|
|
212
|
+
"version": "1.2.0",
|
|
193
213
|
"hash": "7710115860f595324ab94f752abb1de09b58acc9256aadf56ea0113b3dd4bb09"
|
|
194
214
|
}
|
|
195
215
|
],
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "record-assignments",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"registryDependencies": [
|
|
5
|
+
"badge",
|
|
6
|
+
"button",
|
|
7
|
+
"input",
|
|
8
|
+
"label",
|
|
9
|
+
"textarea",
|
|
10
|
+
"dialog",
|
|
11
|
+
"select",
|
|
12
|
+
"ui-preferences",
|
|
13
|
+
"resource-value"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": [
|
|
16
|
+
"lucide-react@1.47.0",
|
|
17
|
+
"axios@1.19.0"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"path": "registry/ui/record-assignments.tsx",
|
|
22
|
+
"type": "registry:ui",
|
|
23
|
+
"content": "import { useCallback, useEffect, useState } from 'react'\nimport axios from 'axios'\nimport { ListChecks, Plus } from 'lucide-react'\nimport type { Assignment, MentionCandidate } from '@adula/kit'\nimport { Badge } from '~/components/ui/badge'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\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 {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/components/ui/select'\nimport { formatDate } from '~/components/ui/resource-value'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\n\nconst json = { headers: { Accept: 'application/json' }, withXSRFToken: true }\nconst statusLabel: Record<Assignment['status'], string> = {\n open: 'مفتوحة',\n done: 'منجزة',\n cancelled: 'ملغاة',\n}\n\n/** Tasks assigned on one record, with an assignment dialog for users who may update it. */\nexport function RecordAssignments({\n resource,\n id,\n canAssign,\n}: {\n resource: string\n id: number | string\n canAssign: boolean\n}) {\n const { calendar } = useUiPreferences()\n const base = `/resources/${resource}/${id}`\n const [rows, setRows] = useState<Assignment[] | null>(null)\n const [open, setOpen] = useState(false)\n const [people, setPeople] = useState<MentionCandidate[]>([])\n const [assigneeId, setAssigneeId] = useState('')\n const [title, setTitle] = useState('')\n const [note, setNote] = useState('')\n const [dueOn, setDueOn] = useState('')\n const [error, setError] = useState('')\n const [busy, setBusy] = useState(false)\n\n const load = useCallback(async () => {\n try {\n const response = await axios.get<{ data: Assignment[] }>(`${base}/assignments`, json)\n setRows(response.data.data)\n } catch {\n setRows([])\n }\n }, [base])\n useEffect(() => {\n void load()\n }, [load])\n useEffect(() => {\n if (!open) return\n axios\n .get<{ data: MentionCandidate[] }>(`${base}/mentions`, json)\n .then((response) => setPeople(response.data.data))\n .catch(() => setPeople([]))\n }, [open, base])\n\n const submit = async (event: React.FormEvent) => {\n event.preventDefault()\n setBusy(true)\n setError('')\n try {\n await axios.post(\n `${base}/assignments`,\n { assigneeId: Number(assigneeId), title, note, dueOn },\n json\n )\n setOpen(false)\n setTitle('')\n setNote('')\n setDueOn('')\n setAssigneeId('')\n await load()\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\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 <ListChecks size={16} className=\"text-muted-foreground\" />\n <h2 className=\"text-sm font-semibold\">المهام المسندة</h2>\n {canAssign && (\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n className=\"ms-auto\"\n onClick={() => setOpen(true)}\n >\n <Plus size={14} />\n إسناد مهمة\n </Button>\n )}\n </div>\n {rows === null ? (\n <p role=\"status\" className=\"px-7 py-5 text-xs text-muted-foreground\">\n جارٍ تحميل المهام…\n </p>\n ) : rows.length === 0 ? (\n <p className=\"px-7 py-5 text-xs text-muted-foreground\">لا مهام على هذا السجل.</p>\n ) : (\n <ul className=\"divide-y\">\n {rows.map((row) => (\n <li\n key={row.id}\n className=\"flex flex-wrap items-center gap-x-4 gap-y-1 px-7 py-3 text-sm\"\n >\n <span className=\"font-medium\">{row.title}</span>\n <Badge variant={row.status === 'open' ? 'default' : 'secondary'}>\n {statusLabel[row.status]}\n </Badge>\n <span className=\"text-xs text-muted-foreground\">\n المكلف: {row.assigneeName ?? `#${row.assigneeId}`}\n </span>\n {row.dueOn && (\n <span className=\"text-xs text-muted-foreground\">\n الاستحقاق <bdi className=\"tabular-nums\">{formatDate(row.dueOn, calendar)}</bdi>\n </span>\n )}\n </li>\n ))}\n </ul>\n )}\n <Dialog open={open} onOpenChange={setOpen}>\n <DialogContent dir=\"rtl\" className=\"sm:max-w-lg\">\n <DialogHeader>\n <DialogTitle>إسناد مهمة</DialogTitle>\n <DialogDescription>\n تظهر المهمة في «مهامي» لدى المكلف، ويُشعَر بها فوراً.\n </DialogDescription>\n </DialogHeader>\n <form id=\"assignment-form\" className=\"space-y-4\" onSubmit={submit}>\n <div className=\"space-y-2\">\n <Label htmlFor=\"assignment-assignee\">المكلف</Label>\n <Select value={assigneeId} onValueChange={setAssigneeId}>\n <SelectTrigger id=\"assignment-assignee\" aria-label=\"المكلف\">\n <SelectValue placeholder=\"اختر زميلاً يملك صلاحية العرض\" />\n </SelectTrigger>\n <SelectContent>\n {people.map((person) => (\n <SelectItem key={person.id} value={String(person.id)}>\n {person.name}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"assignment-title\">عنوان المهمة</Label>\n <Input\n id=\"assignment-title\"\n value={title}\n maxLength={200}\n required\n onChange={(event) => setTitle(event.target.value)}\n />\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"assignment-due\">تاريخ الاستحقاق</Label>\n <Input\n id=\"assignment-due\"\n type=\"date\"\n value={dueOn}\n onChange={(event) => setDueOn(event.target.value)}\n />\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"assignment-note\">ملاحظة</Label>\n <Textarea\n id=\"assignment-note\"\n value={note}\n maxLength={2000}\n onChange={(event) => setNote(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 </form>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => setOpen(false)}>\n إلغاء\n </Button>\n <Button\n type=\"submit\"\n form=\"assignment-form\"\n disabled={busy || !assigneeId || !title.trim()}\n >\n إسناد\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </section>\n )\n}\n",
|
|
24
|
+
"target": "inertia/components/ui/record-assignments.tsx"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json"
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "record-collaboration",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"registryDependencies": [
|
|
5
|
+
"badge",
|
|
6
|
+
"button",
|
|
7
|
+
"input",
|
|
8
|
+
"textarea",
|
|
9
|
+
"popover",
|
|
10
|
+
"command",
|
|
11
|
+
"tabs",
|
|
12
|
+
"ui-preferences",
|
|
13
|
+
"resource-value"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": [
|
|
16
|
+
"lucide-react@1.47.0",
|
|
17
|
+
"axios@1.19.0"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"path": "registry/ui/record-collaboration.tsx",
|
|
22
|
+
"type": "registry:ui",
|
|
23
|
+
"content": "import { useCallback, useEffect, useState } from 'react'\nimport axios from 'axios'\nimport { AtSign, Bell, BellOff, MessageSquare, Pencil, Tag, Trash2, X } from 'lucide-react'\nimport type { MentionCandidate, RecordCollaborationState, ResourceDescription } from '@adula/kit'\nimport { Badge } from '~/components/ui/badge'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Textarea } from '~/components/ui/textarea'\nimport { Popover, PopoverContent, PopoverTrigger } from '~/components/ui/popover'\nimport {\n Command,\n CommandEmpty,\n CommandInput,\n CommandItem,\n CommandList,\n} from '~/components/ui/command'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\nimport { formatDatetime } from '~/components/ui/resource-value'\n\nconst json = { headers: { Accept: 'application/json' }, withXSRFToken: true }\n\nfunction failure(error: unknown, fallback: string) {\n return axios.isAxiosError(error) && error.response?.data?.error?.message\n ? String(error.response.data.error.message)\n : fallback\n}\n\nfunction shown(value: unknown) {\n if (value === null || value === undefined || value === '') return '—'\n if (typeof value === 'boolean') return value ? 'نعم' : 'لا'\n if (typeof value === 'object') return JSON.stringify(value)\n return String(value)\n}\n\n/**\n * Comments with mentions, following, tags and field history for one record.\n * The server re-authorizes the record on every request; this panel only renders\n * what the current user may read.\n */\nexport function RecordCollaboration({\n resource,\n id,\n}: {\n resource: ResourceDescription\n id: number | string\n}) {\n const { calendar } = useUiPreferences()\n const base = `/resources/${resource.name}/${id}`\n const [state, setState] = useState<RecordCollaborationState | null>(null)\n const [error, setError] = useState('')\n const [busy, setBusy] = useState(false)\n const [body, setBody] = useState('')\n const [mentions, setMentions] = useState<MentionCandidate[]>([])\n const [editing, setEditing] = useState<{ id: number; body: string } | null>(null)\n const [tagText, setTagText] = useState<string | null>(null)\n\n const load = useCallback(async () => {\n try {\n const response = await axios.get<RecordCollaborationState>(`${base}/collaboration`, json)\n setState(response.data)\n } catch (caught) {\n setError(failure(caught, 'تعذر تحميل التعليقات.'))\n }\n }, [base])\n useEffect(() => {\n void load()\n }, [load])\n\n const run = async (action: () => Promise<unknown>, fallback: string) => {\n setBusy(true)\n setError('')\n try {\n await action()\n await load()\n return true\n } catch (caught) {\n setError(failure(caught, fallback))\n return false\n } finally {\n setBusy(false)\n }\n }\n const fieldLabel = (key: string) =>\n resource.fields.find((field) => field.key === key)?.label.ar ?? key\n\n if (!state)\n return (\n <section aria-label=\"التعاون حول السجل\" className=\"rounded-xl border bg-white p-7\">\n <p className=\"text-xs text-muted-foreground\" role=\"status\">\n {error || 'جارٍ تحميل التعليقات…'}\n </p>\n </section>\n )\n\n return (\n <section aria-label=\"التعاون حول السجل\" className=\"rounded-xl border bg-white\">\n <div className=\"flex flex-wrap items-center gap-3 border-b px-7 py-4\">\n <MessageSquare size={16} className=\"text-muted-foreground\" />\n <h2 className=\"text-sm font-semibold\">التعاون حول السجل</h2>\n <span className=\"text-xs text-muted-foreground\">{state.followers} متابع</span>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n className=\"ms-auto\"\n disabled={busy}\n onClick={() =>\n run(\n () => axios.put(`${base}/follow`, { following: !state.following }, json),\n 'تعذر تحديث المتابعة.'\n )\n }\n >\n {state.following ? <BellOff size={14} /> : <Bell size={14} />}\n {state.following ? 'إلغاء المتابعة' : 'متابعة السجل'}\n </Button>\n </div>\n <div className=\"flex flex-wrap items-center gap-2 border-b px-7 py-3\">\n <Tag size={14} className=\"text-muted-foreground\" />\n {tagText === null ? (\n <>\n {state.tags.length ? (\n state.tags.map((tag) => (\n <Badge key={tag} variant=\"secondary\">\n {tag}\n </Badge>\n ))\n ) : (\n <span className=\"text-xs text-muted-foreground\">بلا وسوم</span>\n )}\n {state.canTag && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setTagText(state.tags.join('، '))}\n >\n تعديل الوسوم\n </Button>\n )}\n </>\n ) : (\n <form\n className=\"flex flex-1 flex-wrap items-center gap-2\"\n onSubmit={async (event) => {\n event.preventDefault()\n const tags = tagText\n .split(/[,،]/)\n .map((tag) => tag.trim())\n .filter(Boolean)\n if (await run(() => axios.put(`${base}/tags`, { tags }, json), 'تعذر حفظ الوسوم.'))\n setTagText(null)\n }}\n >\n <Input\n aria-label=\"الوسوم مفصولة بفواصل\"\n className=\"max-w-sm flex-1\"\n value={tagText}\n onChange={(event) => setTagText(event.target.value)}\n />\n <Button type=\"submit\" size=\"sm\" disabled={busy}>\n حفظ الوسوم\n </Button>\n <Button type=\"button\" size=\"sm\" variant=\"ghost\" onClick={() => setTagText(null)}>\n إلغاء\n </Button>\n </form>\n )}\n </div>\n {error && (\n <p role=\"alert\" className=\"border-b bg-red-50 px-7 py-3 text-xs text-red-800\">\n {error}\n </p>\n )}\n <Tabs defaultValue=\"comments\" className=\"px-7 py-5\">\n <TabsList>\n <TabsTrigger value=\"comments\">التعليقات ({state.comments.length})</TabsTrigger>\n <TabsTrigger value=\"changes\">سجل التغييرات ({state.changes.length})</TabsTrigger>\n </TabsList>\n <TabsContent value=\"comments\" className=\"space-y-4 pt-4\">\n {state.comments.length === 0 && (\n <p className=\"text-xs text-muted-foreground\">لا توجد تعليقات بعد.</p>\n )}\n <ol className=\"space-y-3\">\n {state.comments.map((comment) => (\n <li key={comment.id} className=\"rounded-lg border px-4 py-3\">\n <div className=\"mb-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground\">\n <span className=\"font-medium text-foreground\">\n {comment.authorName ?? `مستخدم #${comment.authorId}`}\n </span>\n <time dateTime={comment.createdAt} className=\"tabular-nums\">\n {formatDatetime(comment.createdAt, calendar)}\n </time>\n {comment.editedAt && <span>(معدّل)</span>}\n {comment.own && editing?.id !== comment.id && (\n <span className=\"ms-auto flex gap-1\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n aria-label=\"تعديل التعليق\"\n onClick={() => setEditing({ id: comment.id, body: comment.body })}\n >\n <Pencil size={13} />\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n aria-label=\"حذف التعليق\"\n disabled={busy}\n onClick={() =>\n run(\n () => axios.delete(`${base}/comments/${comment.id}`, json),\n 'تعذر حذف التعليق.'\n )\n }\n >\n <Trash2 size={13} />\n </Button>\n </span>\n )}\n </div>\n {editing?.id === comment.id ? (\n <form\n className=\"space-y-2\"\n onSubmit={async (event) => {\n event.preventDefault()\n if (\n await run(\n () =>\n axios.patch(\n `${base}/comments/${comment.id}`,\n { body: editing.body },\n json\n ),\n 'تعذر تعديل التعليق.'\n )\n )\n setEditing(null)\n }}\n >\n <Textarea\n aria-label=\"نص التعليق المعدّل\"\n value={editing.body}\n onChange={(event) => setEditing({ ...editing, body: event.target.value })}\n />\n <div className=\"flex gap-2\">\n <Button type=\"submit\" size=\"sm\" disabled={busy}>\n حفظ\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => setEditing(null)}\n >\n إلغاء\n </Button>\n </div>\n </form>\n ) : (\n <p className=\"text-sm whitespace-pre-wrap break-words\">{comment.body}</p>\n )}\n {comment.mentions.length > 0 && (\n <p className=\"mt-2 text-xs text-muted-foreground\">\n إشارة إلى: {comment.mentions.map((user) => user.name).join('، ')}\n </p>\n )}\n </li>\n ))}\n </ol>\n {state.canComment && (\n <form\n className=\"space-y-2\"\n onSubmit={async (event) => {\n event.preventDefault()\n if (\n await run(\n () =>\n axios.post(\n `${base}/comments`,\n { body, mentions: mentions.map((user) => user.id) },\n json\n ),\n 'تعذر إضافة التعليق.'\n )\n ) {\n setBody('')\n setMentions([])\n }\n }}\n >\n <Textarea\n aria-label=\"تعليق جديد\"\n placeholder=\"اكتب تعليقاً…\"\n value={body}\n maxLength={5000}\n onChange={(event) => setBody(event.target.value)}\n />\n <div className=\"flex flex-wrap items-center gap-2\">\n <MentionPicker\n base={base}\n onPick={(user) =>\n setMentions((current) =>\n current.some((entry) => entry.id === user.id) ? current : [...current, user]\n )\n }\n />\n {mentions.map((user) => (\n <Badge key={user.id} variant=\"outline\" className=\"gap-1\">\n @{user.name}\n <button\n type=\"button\"\n aria-label={`إزالة الإشارة إلى ${user.name}`}\n onClick={() =>\n setMentions((current) => current.filter((entry) => entry.id !== user.id))\n }\n >\n <X size={12} />\n </button>\n </Badge>\n ))}\n <Button type=\"submit\" size=\"sm\" className=\"ms-auto\" disabled={busy || !body.trim()}>\n إضافة تعليق\n </Button>\n </div>\n </form>\n )}\n </TabsContent>\n <TabsContent value=\"changes\" className=\"pt-4\">\n {state.changes.length === 0 ? (\n <p className=\"text-xs text-muted-foreground\">لا توجد تغييرات مسجلة على الحقول.</p>\n ) : (\n <div className=\"overflow-x-auto\">\n <table className=\"w-full min-w-[520px] text-sm\">\n <thead className=\"bg-muted text-xs text-muted-foreground\">\n <tr>\n <th scope=\"col\" className=\"px-4 py-2 text-start font-medium\">\n الحقل\n </th>\n <th scope=\"col\" className=\"px-4 py-2 text-start font-medium\">\n قبل\n </th>\n <th scope=\"col\" className=\"px-4 py-2 text-start font-medium\">\n بعد\n </th>\n <th scope=\"col\" className=\"px-4 py-2 text-start font-medium\">\n بواسطة\n </th>\n </tr>\n </thead>\n <tbody>\n {state.changes.map((change) => (\n <tr key={change.id} className=\"border-b last:border-0\">\n <td className=\"px-4 py-2 font-medium\">{fieldLabel(change.field)}</td>\n <td className=\"px-4 py-2 text-red-800 line-through\">\n {shown(change.before)}\n </td>\n <td className=\"px-4 py-2 text-emerald-800\">{shown(change.after)}</td>\n <td className=\"px-4 py-2 text-xs text-muted-foreground\">\n {change.actorName ?? '—'} ·{' '}\n <time dateTime={change.createdAt}>\n {formatDatetime(change.createdAt, calendar)}\n </time>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )}\n </TabsContent>\n </Tabs>\n </section>\n )\n}\n\nfunction MentionPicker({\n base,\n onPick,\n}: {\n base: string\n onPick: (user: MentionCandidate) => void\n}) {\n const [open, setOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [users, setUsers] = useState<MentionCandidate[]>([])\n useEffect(() => {\n if (!open) return\n const timer = setTimeout(async () => {\n try {\n const response = await axios.get<{ data: MentionCandidate[] }>(`${base}/mentions`, {\n ...json,\n params: { search },\n })\n setUsers(response.data.data)\n } catch {\n setUsers([])\n }\n }, 200)\n return () => clearTimeout(timer)\n }, [open, search, base])\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button type=\"button\" variant=\"outline\" size=\"sm\">\n <AtSign size={14} />\n إشارة إلى زميل\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-72 p-0\" align=\"start\">\n <Command shouldFilter={false}>\n <CommandInput placeholder=\"ابحث بالاسم…\" value={search} onValueChange={setSearch} />\n <CommandList>\n <CommandEmpty>لا يوجد زملاء يملكون صلاحية عرض السجل.</CommandEmpty>\n {users.map((user) => (\n <CommandItem\n key={user.id}\n value={String(user.id)}\n onSelect={() => {\n onPick(user)\n setOpen(false)\n }}\n >\n {user.name}\n </CommandItem>\n ))}\n </CommandList>\n </Command>\n </PopoverContent>\n </Popover>\n )\n}\n",
|
|
24
|
+
"target": "inertia/components/ui/record-collaboration.tsx"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json"
|
|
28
|
+
}
|