@adula/ui 0.2.0-alpha.4 → 1.0.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 +1 -1
- package/build/manifest.json +63 -43
- package/build/record-assignments.json +26 -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-import.json +24 -0
- package/build/resource-page.json +3 -2
- package/build/resource-show.json +5 -2
- 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
|
|
package/build/manifest.json
CHANGED
|
@@ -1,195 +1,215 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"kitCompatibility": "0
|
|
2
|
+
"version": "1.0.0",
|
|
3
|
+
"kitCompatibility": "1.0",
|
|
4
4
|
"items": [
|
|
5
5
|
{
|
|
6
6
|
"name": "button",
|
|
7
|
-
"version": "0.
|
|
7
|
+
"version": "1.0.0",
|
|
8
8
|
"hash": "cf1a045e41f5029fe9a8e3f3513a142dbe6550d6ffbe560b652a0aed1bb76f94"
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"name": "input",
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "1.0.0",
|
|
13
13
|
"hash": "5a398efe048aff90155d858e63290dee2b7ad31c060936ac5bfce5ecf691e022"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"name": "textarea",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "1.0.0",
|
|
18
18
|
"hash": "f0f615a9112e64ccb0d580d9325558c97750c24bcce933211acd2fa01c24b008"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"name": "select",
|
|
22
|
-
"version": "0.
|
|
22
|
+
"version": "1.0.0",
|
|
23
23
|
"hash": "25524a9856101efd64d4fa9922cd3c3ab36da05d84d75f06aec102f9fbc3b554"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"name": "checkbox",
|
|
27
|
-
"version": "0.
|
|
27
|
+
"version": "1.0.0",
|
|
28
28
|
"hash": "631d1bacffcb6856a210bb2a40301f589cd0a74dfe28363e6fe3393e6c658ffa"
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"name": "switch",
|
|
32
|
-
"version": "0.
|
|
32
|
+
"version": "1.0.0",
|
|
33
33
|
"hash": "f41dc7b8d0b3d36a5b74460b651f20046c70a825321ea5ba8e32b575e87e9fb4"
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"name": "radio-group",
|
|
37
|
-
"version": "0.
|
|
37
|
+
"version": "1.0.0",
|
|
38
38
|
"hash": "2f6e9ad7d314703388e20cbea51d3f3bae35e0aedb239fe7e58f71dc892110e0"
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
"name": "table",
|
|
42
|
-
"version": "0.
|
|
42
|
+
"version": "1.0.0",
|
|
43
43
|
"hash": "2a76f50a6672357e167a2ebc1ea82535c9298629d94f5aa9ee57108e5c535f66"
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
"name": "form",
|
|
47
|
-
"version": "0.
|
|
47
|
+
"version": "1.0.0",
|
|
48
48
|
"hash": "2b164e3985524723c3997a334fe04b3232a52a4a37ac81c1acac25968f709628"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"name": "label",
|
|
52
|
-
"version": "0.
|
|
52
|
+
"version": "1.0.0",
|
|
53
53
|
"hash": "adb3d62721dfe74a6e812cee5ac8b2a0f5ad8821140a0d017e4bdabd2fd7b966"
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
56
|
"name": "dialog",
|
|
57
|
-
"version": "0.
|
|
57
|
+
"version": "1.0.0",
|
|
58
58
|
"hash": "04b47b3d9de9d4998eb610a6753c115cf3aa5db88e6889caac337f2d95f708bc"
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"name": "sheet",
|
|
62
|
-
"version": "0.
|
|
62
|
+
"version": "1.0.0",
|
|
63
63
|
"hash": "8b511eba90dbab3ea69faafa384e6b27d5aa1ba4742b47dc526238b2babcf97a"
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"name": "dropdown-menu",
|
|
67
|
-
"version": "0.
|
|
67
|
+
"version": "1.0.0",
|
|
68
68
|
"hash": "622b2ba53ab5d9ebf28de16078a0051adb5a1035d63f5febeb3992fc1c094ff9"
|
|
69
69
|
},
|
|
70
70
|
{
|
|
71
71
|
"name": "popover",
|
|
72
|
-
"version": "0.
|
|
72
|
+
"version": "1.0.0",
|
|
73
73
|
"hash": "288f01bbff64e03473dfb7c0b680f0ca77d610f646653b29364ae245d8491e03"
|
|
74
74
|
},
|
|
75
75
|
{
|
|
76
76
|
"name": "command",
|
|
77
|
-
"version": "0.
|
|
77
|
+
"version": "1.0.0",
|
|
78
78
|
"hash": "47fc8607025e87f3a35f974a55a946d9fee2d0eebd9a681276028e01731d1845"
|
|
79
79
|
},
|
|
80
80
|
{
|
|
81
81
|
"name": "calendar",
|
|
82
|
-
"version": "0.
|
|
82
|
+
"version": "1.0.0",
|
|
83
83
|
"hash": "a536d790c69a67daf9aad8e76901dd668e02520d38677a3f27e1eadcf6a90ad7"
|
|
84
84
|
},
|
|
85
85
|
{
|
|
86
86
|
"name": "badge",
|
|
87
|
-
"version": "0.
|
|
87
|
+
"version": "1.0.0",
|
|
88
88
|
"hash": "08a668f461549c6edf7da629899d59373e7cae5686d1533fd5d451fbf7c2e33e"
|
|
89
89
|
},
|
|
90
90
|
{
|
|
91
91
|
"name": "card",
|
|
92
|
-
"version": "0.
|
|
92
|
+
"version": "1.0.0",
|
|
93
93
|
"hash": "0c7ac44916cc64285da5d8734fd34896a72cabecada2119b68f7f04ac6995b9a"
|
|
94
94
|
},
|
|
95
95
|
{
|
|
96
96
|
"name": "tabs",
|
|
97
|
-
"version": "0.
|
|
97
|
+
"version": "1.0.0",
|
|
98
98
|
"hash": "ceac90f0124b6c52d5f0268226fc36ec1689feb8540664414158aa8fdebac0f7"
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
"name": "sonner",
|
|
102
|
-
"version": "0.
|
|
102
|
+
"version": "1.0.0",
|
|
103
103
|
"hash": "433f224e2266ddfa0f42fe7f81a87a55cb4e6284dce8187b97256154409fe10d"
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
106
|
"name": "skeleton",
|
|
107
|
-
"version": "0.
|
|
107
|
+
"version": "1.0.0",
|
|
108
108
|
"hash": "8dd2b789d3a4b70fb62a99922ef48ee7a0d23a7cda09b00cc1df49c8c29fac90"
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
"name": "pagination",
|
|
112
|
-
"version": "0.
|
|
112
|
+
"version": "1.0.0",
|
|
113
113
|
"hash": "d1119e2d1e6e80c2556b510c715370c4b9e039e8851461aa28e71c3c1d9d1329"
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
"name": "tooltip",
|
|
117
|
-
"version": "0.
|
|
117
|
+
"version": "1.0.0",
|
|
118
118
|
"hash": "616d81f4386a0fb6b714e74515fd77328db0314ddb573003adfaf4693f100fe6"
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
"name": "separator",
|
|
122
|
-
"version": "0.
|
|
122
|
+
"version": "1.0.0",
|
|
123
123
|
"hash": "deb8eb9daf72092fd11d69af65891b721f154fd1bb8d3c7fabb46f33cf612c87"
|
|
124
124
|
},
|
|
125
125
|
{
|
|
126
126
|
"name": "alert",
|
|
127
|
-
"version": "0.
|
|
127
|
+
"version": "1.0.0",
|
|
128
128
|
"hash": "17940923e71122bb30d830c569e36e3886109cbfdfb405fd3954774fed1f0ead"
|
|
129
129
|
},
|
|
130
130
|
{
|
|
131
131
|
"name": "can",
|
|
132
|
-
"version": "0.
|
|
132
|
+
"version": "1.0.0",
|
|
133
133
|
"hash": "3c88f2d7fe666e9ba919776177e5aecb388ff5cb77ecb5726ebde7d07d82007b"
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
136
|
"name": "resource-value",
|
|
137
|
-
"version": "0.
|
|
137
|
+
"version": "1.0.0",
|
|
138
138
|
"hash": "a173edb91f7f8244dbe262647cf3c1aa874cdf7f61887643a846ef86a2bfa018"
|
|
139
139
|
},
|
|
140
140
|
{
|
|
141
141
|
"name": "resource-field",
|
|
142
|
-
"version": "0.
|
|
142
|
+
"version": "1.0.0",
|
|
143
143
|
"hash": "292f0b43e5f6c53c763801c9fb6f150ccb8b3943a18b4e0ae5425bfc359e32cd"
|
|
144
144
|
},
|
|
145
145
|
{
|
|
146
146
|
"name": "data-table",
|
|
147
|
-
"version": "0.
|
|
147
|
+
"version": "1.0.0",
|
|
148
148
|
"hash": "0eea3ca16eeb860882fe7bc0fbfc7afd44b6bd14ca9e2c99cd6a80792220d1af"
|
|
149
149
|
},
|
|
150
150
|
{
|
|
151
151
|
"name": "resource-form",
|
|
152
|
-
"version": "0.
|
|
152
|
+
"version": "1.0.0",
|
|
153
153
|
"hash": "24821f2e5d6904f7e0d8fb30ef3cfb5a543801643d3c0bceeeb25d8642d92ded"
|
|
154
154
|
},
|
|
155
|
+
{
|
|
156
|
+
"name": "record-collaboration",
|
|
157
|
+
"version": "1.0.0",
|
|
158
|
+
"hash": "9cbbfc35065c65e2b9bfe17a5fb7a743cb8ce21dd2eaf32a736cedc60aa2df76"
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"name": "record-assignments",
|
|
162
|
+
"version": "1.0.0",
|
|
163
|
+
"hash": "5ece4d7107316edd4ea6cfaf139e623e09eeb77d526aa1fb8f05c8b0141049a8"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"name": "record-workflows",
|
|
167
|
+
"version": "1.0.0",
|
|
168
|
+
"hash": "a83475958e9964bc2d759d11e50cc6b6deba23571c82ad3d03548f06395ea03a"
|
|
169
|
+
},
|
|
155
170
|
{
|
|
156
171
|
"name": "resource-show",
|
|
157
|
-
"version": "0.
|
|
158
|
-
"hash": "
|
|
172
|
+
"version": "1.0.0",
|
|
173
|
+
"hash": "2ad16c051c7805f60762d19827801b51d2d7c3932d590d992e307eef6a5b1454"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"name": "resource-import",
|
|
177
|
+
"version": "1.0.0",
|
|
178
|
+
"hash": "05561c1ff02de8e75ca4aab1f7adb076ec114dfe271058585292ab17a509bd17"
|
|
159
179
|
},
|
|
160
180
|
{
|
|
161
181
|
"name": "resource-page",
|
|
162
|
-
"version": "0.
|
|
163
|
-
"hash": "
|
|
182
|
+
"version": "1.0.0",
|
|
183
|
+
"hash": "4efb15680b2316249ba6a80db483adf113293371c057c166450a210ab5d37444"
|
|
164
184
|
},
|
|
165
185
|
{
|
|
166
186
|
"name": "resource-actions",
|
|
167
|
-
"version": "0.
|
|
168
|
-
"hash": "
|
|
187
|
+
"version": "1.0.0",
|
|
188
|
+
"hash": "8a96d1319b894fdb46061cc259fe6dadc092b84a8567f94a3088cde1bdd1da12"
|
|
169
189
|
},
|
|
170
190
|
{
|
|
171
191
|
"name": "resource-surface",
|
|
172
|
-
"version": "0.
|
|
192
|
+
"version": "1.0.0",
|
|
173
193
|
"hash": "c8f783890cdb918ba326dd07ce845a81931b111b4341809e54dbb042c219edef"
|
|
174
194
|
},
|
|
175
195
|
{
|
|
176
196
|
"name": "ui-preferences",
|
|
177
|
-
"version": "0.
|
|
197
|
+
"version": "1.0.0",
|
|
178
198
|
"hash": "999e93b4c4814eef4528dca532c76504060f524f6cc00de2a8f967291855ff03"
|
|
179
199
|
},
|
|
180
200
|
{
|
|
181
201
|
"name": "calendar-date",
|
|
182
|
-
"version": "0.
|
|
202
|
+
"version": "1.0.0",
|
|
183
203
|
"hash": "d74998096825b361135b75e6d90d5f3951732bf90919b33ecf15bfc015c15a58"
|
|
184
204
|
},
|
|
185
205
|
{
|
|
186
206
|
"name": "hijri-calendar",
|
|
187
|
-
"version": "0.
|
|
207
|
+
"version": "1.0.0",
|
|
188
208
|
"hash": "39ca5de9c1df32e65e434241f3cbbae629463c9f21da93205274c177fc8b85f3"
|
|
189
209
|
},
|
|
190
210
|
{
|
|
191
211
|
"name": "confirm-action",
|
|
192
|
-
"version": "0.
|
|
212
|
+
"version": "1.0.0",
|
|
193
213
|
"hash": "7710115860f595324ab94f752abb1de09b58acc9256aadf56ea0113b3dd4bb09"
|
|
194
214
|
}
|
|
195
215
|
],
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
],
|
|
13
|
+
"dependencies": [
|
|
14
|
+
"lucide-react@1.47.0",
|
|
15
|
+
"axios@1.19.0"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "registry/ui/record-assignments.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"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'\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 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\">الاستحقاق {row.dueOn}</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",
|
|
22
|
+
"target": "inertia/components/ui/record-assignments.tsx"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json"
|
|
26
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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 done: 'نُفذت الخطوة',\n delay_started: 'بدأ الانتظار',\n retry_scheduled: 'جدولة إعادة المحاولة',\n failed: 'فشل',\n retried: 'إعادة تشغيل',\n migrated: 'ترحيل إلى إصدار أحدث',\n completed: 'اكتمل',\n cancelled: 'أُلغي',\n}\n\n/** Approve or reject with an optional comment, in a confirmation dialog. */\nexport function WorkflowDecision({\n run,\n onDecided,\n}: {\n run: WorkflowRun\n onDecided: (run: WorkflowRun) => void\n}) {\n const [decision, setDecision] = useState<'approve' | 'reject' | 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 submit = async () => {\n if (!decision) return\n setBusy(true)\n setError('')\n try {\n const response = await axios.post<{ data: WorkflowRun }>(\n `/workflows/${run.id}/decide`,\n { decision, comment },\n json\n )\n setDecision(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 return (\n <>\n <div className=\"flex gap-2\">\n <Button size=\"sm\" onClick={() => setDecision('approve')}>\n <CheckCircle2 size={15} />\n موافقة\n </Button>\n <Button\n size=\"sm\"\n variant=\"outline\"\n className=\"text-destructive\"\n onClick={() => setDecision('reject')}\n >\n <XCircle size={15} />\n رفض\n </Button>\n </div>\n <Dialog open={decision !== null} onOpenChange={(open) => !open && !busy && setDecision(null)}>\n <DialogContent dir=\"rtl\">\n <DialogHeader>\n <DialogTitle>{decision === 'approve' ? 'تأكيد الموافقة' : 'تأكيد الرفض'}</DialogTitle>\n <DialogDescription>\n {run.myApproval.title} — {run.resourceLabel} #{run.recordId}\n </DialogDescription>\n </DialogHeader>\n <div className=\"space-y-2\">\n <Label htmlFor={`decision-comment-${run.id}`}>ملاحظة (اختيارية)</Label>\n <Textarea\n id={`decision-comment-${run.id}`}\n value={comment}\n maxLength={1000}\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\n type=\"button\"\n variant=\"outline\"\n disabled={busy}\n onClick={() => setDecision(null)}\n >\n تراجع\n </Button>\n <Button\n type=\"button\"\n variant={decision === 'reject' ? 'destructive' : 'default'}\n disabled={busy}\n onClick={() => void submit()}\n >\n {decision === 'approve' ? 'موافقة' : 'رفض'}\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
|
],
|
|
@@ -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
|
+
}
|
package/build/resource-page.json
CHANGED
|
@@ -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
|
|
19
|
+
"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 { ResourceImport } from '~/components/ui/resource-import'\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 <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 إضافة سجل\n </Link>\n </Button>\n </div>\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
20
|
"target": "inertia/components/ui/resource-page.tsx"
|
|
20
21
|
}
|
|
21
22
|
],
|
package/build/resource-show.json
CHANGED
|
@@ -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}\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 <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 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 {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
|
],
|