@goplusvn/core 0.1.21 → 0.1.23
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/CHANGELOG.md +22 -0
- package/package.json +1 -1
- package/src/rbac/components/roles/role-card.tsx +25 -24
- package/src/rbac/components/roles/role-toolbar.tsx +70 -84
- package/src/rbac/index.ts +8 -0
- package/src/rbac/lib/fetch-error.ts +26 -0
- package/src/rbac/pages/action-list-page.tsx +581 -19
- package/src/rbac/pages/index.ts +13 -0
- package/src/rbac/pages/resource-list-page.tsx +1415 -19
- package/src/rbac/pages/role-form-page.tsx +704 -0
- package/src/rbac/pages/role-list-page.tsx +14 -12
- package/src/ui/forms/date-picker.tsx +167 -19
- package/src/ui/forms/multi-select.tsx +475 -153
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useMemo, useCallback, useState } from "react";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { Loader2, UserX, KeyRound } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
Button,
|
|
7
8
|
Badge,
|
|
9
|
+
StatusIndicator,
|
|
8
10
|
AlertDialog,
|
|
9
11
|
AlertDialogAction,
|
|
10
12
|
AlertDialogCancel,
|
|
@@ -59,6 +61,8 @@ export interface RoleListPageProps {
|
|
|
59
61
|
description: string | null;
|
|
60
62
|
isDefault?: boolean;
|
|
61
63
|
}>;
|
|
64
|
+
/** Nút Import app-specific (vd ImportDialog) — render trong toolbar khi có quyền import. */
|
|
65
|
+
importSlot?: ReactNode;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
export function RoleListPage({
|
|
@@ -68,6 +72,7 @@ export function RoleListPage({
|
|
|
68
72
|
initialData,
|
|
69
73
|
resources,
|
|
70
74
|
actions,
|
|
75
|
+
importSlot,
|
|
71
76
|
}: RoleListPageProps) {
|
|
72
77
|
const canCreate = permissions.create;
|
|
73
78
|
const canUpdate = permissions.update;
|
|
@@ -199,7 +204,9 @@ export function RoleListPage({
|
|
|
199
204
|
|
|
200
205
|
if (error) {
|
|
201
206
|
return (
|
|
202
|
-
<div className="text-
|
|
207
|
+
<div className="text-destructive">
|
|
208
|
+
Không thể tải dữ liệu: {error.message}
|
|
209
|
+
</div>
|
|
203
210
|
);
|
|
204
211
|
}
|
|
205
212
|
|
|
@@ -223,6 +230,7 @@ export function RoleListPage({
|
|
|
223
230
|
canCreate={canCreate}
|
|
224
231
|
canImport={canImport}
|
|
225
232
|
canExport={canExport}
|
|
233
|
+
importSlot={importSlot}
|
|
226
234
|
/>
|
|
227
235
|
|
|
228
236
|
{/* Roles Grid */}
|
|
@@ -342,15 +350,9 @@ export function RoleListPage({
|
|
|
342
350
|
</div>
|
|
343
351
|
<div className="flex items-center space-x-2">
|
|
344
352
|
{user.isActive ? (
|
|
345
|
-
<
|
|
346
|
-
<UserCheck className="h-3 w-3 mr-1" />
|
|
347
|
-
Active
|
|
348
|
-
</Badge>
|
|
353
|
+
<StatusIndicator status="active" />
|
|
349
354
|
) : (
|
|
350
|
-
<
|
|
351
|
-
<UserX className="h-3 w-3 mr-1" />
|
|
352
|
-
Inactive
|
|
353
|
-
</Badge>
|
|
355
|
+
<StatusIndicator status="inactive" />
|
|
354
356
|
)}
|
|
355
357
|
</div>
|
|
356
358
|
</div>
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { addDays, format, isValid, parse } from "date-fns";
|
|
4
5
|
import { CalendarIcon } from "lucide-react";
|
|
5
6
|
|
|
6
7
|
import type { ComponentProps } from "react";
|
|
7
8
|
|
|
8
9
|
import { cn } from "../../utils";
|
|
9
10
|
|
|
10
|
-
import { Button } from "../primitives";
|
|
11
|
+
import { Button, Input } from "../primitives";
|
|
11
12
|
import {
|
|
12
13
|
Calendar,
|
|
13
14
|
Popover,
|
|
15
|
+
PopoverAnchor,
|
|
14
16
|
PopoverContent,
|
|
15
17
|
PopoverTrigger,
|
|
16
18
|
} from "../primitives/client";
|
|
@@ -24,11 +26,50 @@ type DatePickerProps = Omit<
|
|
|
24
26
|
formatStr?: string;
|
|
25
27
|
popoverContentClassName?: string;
|
|
26
28
|
popoverContentOptions?: ComponentProps<typeof PopoverContent>;
|
|
29
|
+
/** Class cho ô input (giữ tên cũ để tương thích các consumer hiện có) */
|
|
27
30
|
buttonClassName?: string;
|
|
28
31
|
buttonOptions?: ComponentProps<typeof Button>;
|
|
29
32
|
placeholder?: string;
|
|
30
33
|
};
|
|
31
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Các định dạng chấp nhận khi người dùng GÕ ngày (ngoài formatStr hiển thị).
|
|
37
|
+
* Dạng yy đặt TRƯỚC yyyy-fallback để "09/07/26" ra 2026 (không phải năm 0026);
|
|
38
|
+
* dạng thiếu năm/tháng ("09/07", "9") hiểu theo hôm nay.
|
|
39
|
+
*/
|
|
40
|
+
const TYPING_FORMATS = [
|
|
41
|
+
"dd/MM/yyyy",
|
|
42
|
+
"d/M/yyyy",
|
|
43
|
+
"dd/MM/yy",
|
|
44
|
+
"d/M/yy",
|
|
45
|
+
"dd-MM-yyyy",
|
|
46
|
+
"yyyy-MM-dd",
|
|
47
|
+
"dd/MM",
|
|
48
|
+
"d/M",
|
|
49
|
+
"d",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** Tự chèn dấu phân cách theo formatStr khi người dùng gõ toàn số. */
|
|
53
|
+
function maskDigits(raw: string, formatStr: string): string {
|
|
54
|
+
const digits = raw.replace(/\D/g, "");
|
|
55
|
+
let out = "";
|
|
56
|
+
let di = 0;
|
|
57
|
+
for (let i = 0; i < formatStr.length && di < digits.length; i++) {
|
|
58
|
+
const ch = formatStr[i];
|
|
59
|
+
if (/[a-zA-Z]/.test(ch)) {
|
|
60
|
+
out += digits[di++];
|
|
61
|
+
} else {
|
|
62
|
+
out += ch;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* DatePicker cho phép GÕ ngày trực tiếp (tự chèn dấu phân cách, chấp nhận
|
|
70
|
+
* dd/MM/yyyy, d/M, yy..., ↑/↓ để +/- 1 ngày) hoặc bấm icon lịch để chọn.
|
|
71
|
+
* Gõ sai → viền đỏ, blur trả về giá trị đang có.
|
|
72
|
+
*/
|
|
32
73
|
export function DatePicker({
|
|
33
74
|
value,
|
|
34
75
|
onValueChange,
|
|
@@ -37,25 +78,104 @@ export function DatePicker({
|
|
|
37
78
|
popoverContentOptions,
|
|
38
79
|
buttonClassName,
|
|
39
80
|
buttonOptions,
|
|
40
|
-
placeholder = "
|
|
81
|
+
placeholder = "Chọn ngày",
|
|
41
82
|
...props
|
|
42
83
|
}: DatePickerProps) {
|
|
84
|
+
const [open, setOpen] = useState(false);
|
|
85
|
+
const [text, setText] = useState(value ? format(value, formatStr) : "");
|
|
86
|
+
|
|
87
|
+
// Đồng bộ khi giá trị đổi từ bên ngoài (chọn lịch, reset form...)
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
setText(value ? format(value, formatStr) : "");
|
|
90
|
+
}, [value, formatStr]);
|
|
91
|
+
|
|
92
|
+
const parseTyped = (raw: string): Date | null => {
|
|
93
|
+
for (const fmt of [formatStr, ...TYPING_FORMATS]) {
|
|
94
|
+
const parsed = parse(raw, fmt, new Date());
|
|
95
|
+
// Chặn năm rác kiểu 0026 khi yyyy khớp nhầm chuỗi 2 chữ số
|
|
96
|
+
if (isValid(parsed) && parsed.getFullYear() >= 1000) return parsed;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const commitText = () => {
|
|
102
|
+
const raw = text.trim();
|
|
103
|
+
if (!raw) {
|
|
104
|
+
if (value) onValueChange?.(undefined);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const parsed = parseTyped(raw);
|
|
108
|
+
if (parsed) {
|
|
109
|
+
onValueChange?.(parsed);
|
|
110
|
+
setText(format(parsed, formatStr));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Không parse được → trả về giá trị đang có
|
|
114
|
+
setText(value ? format(value, formatStr) : "");
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Báo đỏ khi đã gõ đủ độ dài mà vẫn không ra ngày hợp lệ
|
|
118
|
+
const slotCount = formatStr.replace(/[^a-zA-Z]/g, "").length;
|
|
119
|
+
const isComplete = text.replace(/\D/g, "").length >= slotCount;
|
|
120
|
+
const invalid = isComplete && !parseTyped(text.trim());
|
|
121
|
+
|
|
122
|
+
const stepDay = (delta: number) => {
|
|
123
|
+
onValueChange?.(addDays(value ?? new Date(), delta));
|
|
124
|
+
};
|
|
125
|
+
|
|
43
126
|
return (
|
|
44
|
-
<Popover modal>
|
|
45
|
-
<
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
127
|
+
<Popover modal open={open} onOpenChange={setOpen}>
|
|
128
|
+
<PopoverAnchor asChild>
|
|
129
|
+
<div className="relative w-full">
|
|
130
|
+
<Input
|
|
131
|
+
value={text}
|
|
132
|
+
onChange={(e) => {
|
|
133
|
+
const v = e.target.value;
|
|
134
|
+
// Chỉ mask khi đang gõ thêm (không phá thao tác xoá/sửa)
|
|
135
|
+
if (v.length > text.length && /^[\d/\-.]*$/.test(v)) {
|
|
136
|
+
setText(maskDigits(v, formatStr));
|
|
137
|
+
} else {
|
|
138
|
+
setText(v);
|
|
139
|
+
}
|
|
140
|
+
}}
|
|
141
|
+
onBlur={commitText}
|
|
142
|
+
onKeyDown={(e) => {
|
|
143
|
+
if (e.key === "Enter") {
|
|
144
|
+
commitText();
|
|
145
|
+
(e.target as HTMLInputElement).blur();
|
|
146
|
+
} else if (e.key === "Escape") {
|
|
147
|
+
setOpen(false);
|
|
148
|
+
} else if (e.key === "ArrowUp") {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
stepDay(1);
|
|
151
|
+
} else if (e.key === "ArrowDown") {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
stepDay(-1);
|
|
154
|
+
}
|
|
155
|
+
}}
|
|
156
|
+
placeholder={placeholder}
|
|
157
|
+
inputMode="numeric"
|
|
158
|
+
aria-invalid={invalid || undefined}
|
|
159
|
+
className={cn(
|
|
160
|
+
"w-full pe-9",
|
|
161
|
+
invalid &&
|
|
162
|
+
"border-destructive focus-visible:ring-destructive/40",
|
|
163
|
+
buttonClassName,
|
|
164
|
+
)}
|
|
165
|
+
/>
|
|
166
|
+
<PopoverTrigger asChild>
|
|
167
|
+
<Button
|
|
168
|
+
type="button"
|
|
169
|
+
variant="ghost"
|
|
170
|
+
aria-label="Mở lịch"
|
|
171
|
+
className="absolute end-0 top-0 h-full w-9 p-0 text-muted-foreground hover:text-foreground"
|
|
172
|
+
{...buttonOptions}
|
|
173
|
+
>
|
|
174
|
+
<CalendarIcon className="h-4 w-4 shrink-0" />
|
|
175
|
+
</Button>
|
|
176
|
+
</PopoverTrigger>
|
|
177
|
+
</div>
|
|
178
|
+
</PopoverAnchor>
|
|
59
179
|
<PopoverContent
|
|
60
180
|
className={cn("w-auto p-0", popoverContentClassName)}
|
|
61
181
|
align="start"
|
|
@@ -64,9 +184,37 @@ export function DatePicker({
|
|
|
64
184
|
<Calendar
|
|
65
185
|
mode="single"
|
|
66
186
|
selected={value}
|
|
67
|
-
onSelect={
|
|
187
|
+
onSelect={(date) => {
|
|
188
|
+
onValueChange?.(date ?? undefined);
|
|
189
|
+
setOpen(false);
|
|
190
|
+
}}
|
|
191
|
+
defaultMonth={value}
|
|
68
192
|
{...props}
|
|
69
193
|
/>
|
|
194
|
+
<div className="flex items-center justify-between border-t p-2">
|
|
195
|
+
<Button
|
|
196
|
+
type="button"
|
|
197
|
+
variant="ghost"
|
|
198
|
+
className="h-7 px-2 text-xs"
|
|
199
|
+
onClick={() => {
|
|
200
|
+
onValueChange?.(new Date());
|
|
201
|
+
setOpen(false);
|
|
202
|
+
}}
|
|
203
|
+
>
|
|
204
|
+
Hôm nay
|
|
205
|
+
</Button>
|
|
206
|
+
<Button
|
|
207
|
+
type="button"
|
|
208
|
+
variant="ghost"
|
|
209
|
+
className="h-7 px-2 text-xs text-muted-foreground"
|
|
210
|
+
onClick={() => {
|
|
211
|
+
onValueChange?.(undefined);
|
|
212
|
+
setOpen(false);
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
Xoá
|
|
216
|
+
</Button>
|
|
217
|
+
</div>
|
|
70
218
|
</PopoverContent>
|
|
71
219
|
</Popover>
|
|
72
220
|
);
|