@goplusvn/core 0.1.20 → 0.1.22

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.20",
4
+ "version": "0.1.22",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -480,7 +480,7 @@ export function DataTable<TData extends Record<string, unknown>>({
480
480
 
481
481
  {/* Pagination Section */}
482
482
  {pagination && (
483
- <div className="border-t border-border dark:border-slate-800 bg-card px-4 sm:px-6 py-2 shrink-0">
483
+ <div className="border-t border-border dark:border-slate-800 bg-card px-2 sm:px-4 py-1 shrink-0">
484
484
  <MemoizedPagination
485
485
  table={table}
486
486
  currentPage={pagination.page}
@@ -65,21 +65,21 @@ export function DataTablePagination<TData>({
65
65
  };
66
66
 
67
67
  return (
68
- <div className="flex flex-col sm:flex-row items-center justify-between gap-1 sm:gap-2 py-0.5 px-1 sm:px-2">
69
- <div className="hidden sm:block flex-1 text-xs text-left text-muted-foreground w-full">
68
+ <div className="flex flex-col sm:flex-row items-center justify-between gap-1 px-1 sm:px-2">
69
+ <div className="hidden sm:block flex-1 text-[11px] text-left text-muted-foreground w-full">
70
70
  Hiển thị {startItem} - {endItem} trong tổng số {total} mục
71
71
  </div>
72
72
 
73
- <div className="flex items-center justify-center gap-2 w-full sm:w-auto overflow-x-auto pb-1 sm:pb-0">
74
- <div className="flex items-center gap-1 sm:gap-2 px-1 sm:px-2 border-r pr-1 sm:pr-2">
75
- <p className="hidden sm:block text-xs font-medium whitespace-nowrap">
73
+ <div className="flex items-center justify-center gap-2 w-full sm:w-auto overflow-x-auto">
74
+ <div className="flex items-center gap-1 gap-1 px-1 border-r pr-1 sm:pr-1.5">
75
+ <p className="hidden sm:block text-[11px] font-medium whitespace-nowrap">
76
76
  Số dòng
77
77
  </p>
78
78
  <Select
79
79
  value={`${effPageSize}`}
80
80
  onValueChange={(value) => table.setPageSize(Number(value))}
81
81
  >
82
- <SelectTrigger className="h-6 w-[65px] sm:w-[75px] text-xs focus:ring-inset focus:ring-offset-0">
82
+ <SelectTrigger className="!h-6 !min-h-0 py-0 w-[60px] sm:w-[65px] text-[11px] px-1.5 focus:ring-inset focus:ring-offset-0">
83
83
  <SelectValue placeholder={effPageSize} />
84
84
  </SelectTrigger>
85
85
  <SelectContent side="top">
@@ -113,11 +113,11 @@ export function DataTablePagination<TData>({
113
113
  </Button>
114
114
 
115
115
  <div className="flex items-center gap-1 sm:gap-2 px-1">
116
- <span className="hidden sm:inline text-xs font-medium whitespace-nowrap">
116
+ <span className="hidden sm:inline text-[11px] font-medium whitespace-nowrap">
117
117
  Trang
118
118
  </span>
119
119
  <input
120
- className="h-6 w-10 sm:w-12 rounded-md border border-input bg-background px-1 text-xs text-center focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
120
+ className="h-6 w-10 sm:w-12 rounded-md border border-input bg-background px-1 text-[11px] text-center focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
121
121
  value={inputPage}
122
122
  onChange={(e) => setInputPage(e.target.value)}
123
123
  onBlur={handlePageInputBlur}
@@ -126,7 +126,7 @@ export function DataTablePagination<TData>({
126
126
  }}
127
127
  disabled={total === 0}
128
128
  />
129
- <span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
129
+ <span className="text-[11px] font-medium text-muted-foreground whitespace-nowrap">
130
130
  / {pageCount}
131
131
  </span>
132
132
  </div>
@@ -0,0 +1,178 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
5
+ import {
6
+ Button,
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from "../primitives"
13
+ import {
14
+ ChevronLeft,
15
+ ChevronRight,
16
+ ChevronsLeft,
17
+ ChevronsRight,
18
+ } from "lucide-react"
19
+
20
+ interface PaginationProps {
21
+ totalItems: number
22
+ currentPage: number
23
+ pageSize: number
24
+ disabled?: boolean
25
+ }
26
+
27
+ export function UrlPagination({
28
+ totalItems,
29
+ currentPage,
30
+ pageSize,
31
+ disabled,
32
+ }: PaginationProps) {
33
+ const router = useRouter()
34
+ const pathname = usePathname()
35
+ const searchParams = useSearchParams()
36
+
37
+ const pageCount = Math.ceil(totalItems / pageSize)
38
+ const startItem = (currentPage - 1) * pageSize + 1
39
+ const endItem = Math.min(currentPage * pageSize, totalItems)
40
+
41
+ // Create a new URLSearchParams instance to update params
42
+ const createQueryString = React.useCallback(
43
+ (params: Record<string, string | number | null>) => {
44
+ const newSearchParams = new URLSearchParams(searchParams?.toString())
45
+
46
+ for (const [key, value] of Object.entries(params)) {
47
+ if (value === null) {
48
+ newSearchParams.delete(key)
49
+ } else {
50
+ newSearchParams.set(key, String(value))
51
+ }
52
+ }
53
+
54
+ return newSearchParams.toString()
55
+ },
56
+ [searchParams]
57
+ )
58
+
59
+ const handlePageChange = (page: number) => {
60
+ if (page < 1 || page > Math.max(1, pageCount)) return
61
+ router.push(`${pathname}?${createQueryString({ page })}`)
62
+ }
63
+
64
+ const handlePageSizeChange = (value: string) => {
65
+ const newPageSize = Number(value)
66
+ // When changing page size, reset to page 1 to avoid out of bounds
67
+ router.push(
68
+ `${pathname}?${createQueryString({ pageSize: newPageSize, page: 1 })}`
69
+ )
70
+ }
71
+
72
+ const [inputPage, setInputPage] = React.useState(String(currentPage))
73
+
74
+ React.useEffect(() => {
75
+ setInputPage(String(currentPage))
76
+ }, [currentPage])
77
+
78
+ const handlePageInputBlur = () => {
79
+ const pageNum = Number(inputPage)
80
+ if (isNaN(pageNum) || pageNum < 1 || pageNum > pageCount) {
81
+ setInputPage(String(currentPage))
82
+ } else if (pageNum !== currentPage) {
83
+ handlePageChange(pageNum)
84
+ }
85
+ }
86
+
87
+ const handlePageInputKeyDown = (e: React.KeyboardEvent) => {
88
+ if (e.key === "Enter") {
89
+ handlePageInputBlur()
90
+ }
91
+ }
92
+
93
+ return (
94
+ <div className="flex flex-col sm:flex-row items-center justify-between gap-1 sm:gap-2 px-1 sm:px-2">
95
+ {/* Ẩn text "Hiển thị" trên mobile, chỉ hiện trên màn hình sm trở lên */}
96
+ <div className="hidden sm:block flex-1 text-[11px] text-left text-muted-foreground w-full">
97
+ Hiển thị {totalItems > 0 ? startItem : 0} -{" "}
98
+ {totalItems > 0 ? endItem : 0} trong tổng số {totalItems} mục
99
+ </div>
100
+
101
+ <div className="flex items-center justify-center gap-2 w-full sm:w-auto overflow-x-auto">
102
+ <div className="flex items-center gap-1 gap-1 px-1 border-r pr-1 sm:pr-1.5">
103
+ <p className="hidden sm:block text-[11px] font-medium whitespace-nowrap">Số dòng</p>
104
+ <Select
105
+ value={`${pageSize}`}
106
+ onValueChange={handlePageSizeChange}
107
+ disabled={disabled}
108
+ >
109
+ <SelectTrigger className="!h-6 !min-h-0 py-0 w-[60px] sm:w-[65px] text-[11px] px-1.5 focus:ring-inset focus:ring-offset-0">
110
+ <SelectValue placeholder={pageSize} />
111
+ </SelectTrigger>
112
+ <SelectContent side="top">
113
+ {[10, 20, 50, 100, 500, 1000].map((size) => (
114
+ <SelectItem key={size} value={`${size}`}>
115
+ {size}
116
+ </SelectItem>
117
+ ))}
118
+ </SelectContent>
119
+ </Select>
120
+ </div>
121
+ <div className="flex items-center gap-1 pl-1">
122
+ <Button
123
+ variant="outline"
124
+ className="hidden h-6 w-6 p-0 lg:flex"
125
+ onClick={() => handlePageChange(1)}
126
+ disabled={currentPage <= 1 || disabled || totalItems === 0}
127
+ >
128
+ <span className="sr-only">Trang đầu</span>
129
+ <ChevronsLeft className="h-3 w-3 sm:h-3 sm:w-3" />
130
+ </Button>
131
+ <Button
132
+ variant="outline"
133
+ className="h-6 w-6 p-0"
134
+ onClick={() => handlePageChange(currentPage - 1)}
135
+ disabled={currentPage <= 1 || disabled || totalItems === 0}
136
+ >
137
+ <span className="sr-only">Trang trước</span>
138
+ <ChevronLeft className="h-3 w-3 sm:h-3 sm:w-3" />
139
+ </Button>
140
+
141
+ <div className="flex items-center gap-1 sm:gap-2 px-1">
142
+ <span className="hidden sm:inline text-[11px] font-medium whitespace-nowrap">Trang</span>
143
+ <input
144
+ className="h-6 w-10 sm:w-12 rounded-md border border-input bg-background px-1 text-[11px] text-center focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
145
+ value={inputPage}
146
+ onChange={(e) => setInputPage(e.target.value)}
147
+ onBlur={handlePageInputBlur}
148
+ onKeyDown={handlePageInputKeyDown}
149
+ disabled={disabled || totalItems === 0}
150
+ />
151
+ <span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
152
+ / {Math.max(1, pageCount)}
153
+ </span>
154
+ </div>
155
+
156
+ <Button
157
+ variant="outline"
158
+ className="h-6 w-6 p-0"
159
+ onClick={() => handlePageChange(currentPage + 1)}
160
+ disabled={currentPage >= pageCount || disabled || totalItems === 0}
161
+ >
162
+ <span className="sr-only">Trang sau</span>
163
+ <ChevronRight className="h-3 w-3 sm:h-3 sm:w-3" />
164
+ </Button>
165
+ <Button
166
+ variant="outline"
167
+ className="hidden h-6 w-6 p-0 lg:flex"
168
+ onClick={() => handlePageChange(pageCount)}
169
+ disabled={currentPage >= pageCount || disabled || totalItems === 0}
170
+ >
171
+ <span className="sr-only">Trang cuối</span>
172
+ <ChevronsRight className="h-3 w-3 sm:h-3 sm:w-3" />
173
+ </Button>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ )
178
+ }
@@ -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
  );