@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.
@@ -1,10 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import { useMemo, useCallback, useState } from "react";
4
- import { Loader2, UserCheck, UserX, KeyRound } from "lucide-react";
5
- import {
6
- Button,
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-red-500">Không thể tải dữ liệu: {error.message}</div>
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
- <Badge variant="default" className="text-xs">
346
- <UserCheck className="h-3 w-3 mr-1" />
347
- Active
348
- </Badge>
353
+ <StatusIndicator status="active" />
349
354
  ) : (
350
- <Badge variant="secondary" className="text-xs">
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 { format } from "date-fns";
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 = "Pick date",
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
- <PopoverTrigger asChild>
46
- <Button
47
- variant="outline"
48
- className={cn("w-full px-3 text-start font-normal", buttonClassName)}
49
- {...buttonOptions}
50
- >
51
- {value ? (
52
- <span>{format(value, formatStr)}</span>
53
- ) : (
54
- <span className="text-muted-foreground">{placeholder}</span>
55
- )}
56
- <CalendarIcon className="shrink-0 h-4 w-4 ms-auto text-muted-foreground" />
57
- </Button>
58
- </PopoverTrigger>
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={onValueChange}
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
  );