@suphark/ui 0.25.0 → 0.26.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/CHANGELOG.md +60 -0
- package/package.json +1 -2
- package/src/components/app-shell/command-palette.tsx +8 -1
- package/src/components/data-table/data-table-sortable.tsx +154 -0
- package/src/components/data-table/data-table-toolbar.tsx +33 -4
- package/src/components/data-table/data-table.tsx +30 -142
- package/src/components/data-table/use-data-table.ts +8 -5
- package/src/components/design-system/design-system-showcase.tsx +63 -15
- package/src/components/pwa/pull-to-refresh.tsx +21 -1
- package/src/components/shared/action-menu.tsx +25 -6
- package/src/components/shared/auth-parts.tsx +5 -3
- package/src/components/shared/button/base/action-button.tsx +7 -14
- package/src/components/shared/button/base/link-button.tsx +5 -14
- package/src/components/shared/button/confirm-action-button.tsx +17 -3
- package/src/components/shared/chat-thread.tsx +7 -0
- package/src/components/shared/client-redirect.tsx +16 -1
- package/src/components/shared/form/date-time-picker.tsx +150 -0
- package/src/components/shared/form/form-field-autocomplete.tsx +93 -0
- package/src/components/shared/form/form-field-input.tsx +23 -81
- package/src/components/shared/form/number-input.tsx +19 -2
- package/src/components/shared/form/tags-input.tsx +7 -2
- package/src/components/shared/form/time-picker.tsx +34 -124
- package/src/components/shared/month-stepper.tsx +10 -4
- package/src/components/shared/row-actions.tsx +9 -3
- package/src/components/shared/settings-layout.tsx +17 -6
- package/src/components/shared/step-form.tsx +25 -4
- package/src/components/shared/time/client-date-formatter.tsx +2 -1
- package/src/components/shared/tree-view.tsx +6 -2
- package/src/components/shared/ui-text.tsx +3 -0
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/native-select.tsx +1 -1
- package/src/components/ui/select.tsx +1 -1
- package/src/components/ui/textarea.tsx +1 -1
- package/src/hooks/use-table-search-params.ts +10 -2
- package/src/hooks/use-ticker.ts +11 -5
- package/src/index.ts +1 -1
- package/src/lib/formatters.ts +1 -1
- package/src/lib/month.ts +14 -2
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/** variant "autocomplete" ของ FormFieldInput — แยกไฟล์เพราะ cmdk หนัก โหลดเฉพาะเมื่อใช้ (React.lazy) */
|
|
4
|
+
import {
|
|
5
|
+
Command,
|
|
6
|
+
CommandEmpty,
|
|
7
|
+
CommandGroup,
|
|
8
|
+
CommandItem,
|
|
9
|
+
CommandList,
|
|
10
|
+
} from "@suphark/ui/components/ui/command";
|
|
11
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
12
|
+
import { Command as CommandPrimitive } from "cmdk";
|
|
13
|
+
import { Check } from "lucide-react";
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import type { ControllerRenderProps, FieldValues, Path, PathValue } from "react-hook-form";
|
|
16
|
+
|
|
17
|
+
export type AutocompleteOption = {
|
|
18
|
+
value: string;
|
|
19
|
+
label: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function AutocompleteInput<T extends FieldValues>({
|
|
23
|
+
field,
|
|
24
|
+
data,
|
|
25
|
+
placeholder,
|
|
26
|
+
disabled,
|
|
27
|
+
emptyMessage = "No results found.",
|
|
28
|
+
required,
|
|
29
|
+
}: {
|
|
30
|
+
field: ControllerRenderProps<T, Path<T>>;
|
|
31
|
+
data: AutocompleteOption[];
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
emptyMessage?: string;
|
|
35
|
+
required?: boolean;
|
|
36
|
+
}) {
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Command className="overflow-visible bg-transparent">
|
|
41
|
+
<div
|
|
42
|
+
className={cn(
|
|
43
|
+
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm ring-offset-background focus-within:border-ring focus-within:ring-1 focus-within:ring-ring",
|
|
44
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
45
|
+
required && !field.value && "border-input",
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<CommandPrimitive.Input
|
|
49
|
+
value={field.value as string}
|
|
50
|
+
onValueChange={field.onChange}
|
|
51
|
+
onFocus={() => setOpen(true)}
|
|
52
|
+
onBlur={() => setOpen(false)}
|
|
53
|
+
placeholder={placeholder}
|
|
54
|
+
disabled={disabled}
|
|
55
|
+
className="w-full bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
<div className="relative mt-1">
|
|
59
|
+
{open && field.value && (
|
|
60
|
+
<CommandList className="fade-in-0 zoom-in-95 absolute top-0 z-50 w-full animate-in rounded-md border bg-popover text-popover-foreground shadow-md outline-none">
|
|
61
|
+
<CommandEmpty className="py-2 text-center text-muted-foreground text-sm">
|
|
62
|
+
{emptyMessage}
|
|
63
|
+
</CommandEmpty>
|
|
64
|
+
<CommandGroup>
|
|
65
|
+
{data.map((option) => (
|
|
66
|
+
<CommandItem
|
|
67
|
+
key={option.value}
|
|
68
|
+
value={`${option.label} ${option.value}`}
|
|
69
|
+
onSelect={() => {
|
|
70
|
+
field.onChange(option.value as PathValue<T, Path<T>>);
|
|
71
|
+
setOpen(false);
|
|
72
|
+
}}
|
|
73
|
+
onMouseDown={(e) => {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<Check
|
|
79
|
+
className={cn(
|
|
80
|
+
"mr-2 h-4 w-4",
|
|
81
|
+
field.value === option.value ? "opacity-100" : "opacity-0",
|
|
82
|
+
)}
|
|
83
|
+
/>
|
|
84
|
+
{option.label}
|
|
85
|
+
</CommandItem>
|
|
86
|
+
))}
|
|
87
|
+
</CommandGroup>
|
|
88
|
+
</CommandList>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
</Command>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
Command,
|
|
5
|
-
CommandEmpty,
|
|
6
|
-
CommandGroup,
|
|
7
|
-
CommandItem,
|
|
8
|
-
CommandList,
|
|
9
|
-
} from "@suphark/ui/components/ui/command";
|
|
10
3
|
import {
|
|
11
4
|
FormControl,
|
|
12
5
|
FormField,
|
|
@@ -24,10 +17,9 @@ import {
|
|
|
24
17
|
import { Textarea } from "@suphark/ui/components/ui/textarea";
|
|
25
18
|
import { useCopyToClipboard } from "@suphark/ui/hooks/use-copy-to-clipboard";
|
|
26
19
|
import { cn } from "@suphark/ui/lib/utils";
|
|
27
|
-
import { Command as CommandPrimitive } from "cmdk";
|
|
28
20
|
import { Check, CircleAlert, Copy } from "lucide-react";
|
|
29
21
|
import type { ComponentProps } from "react";
|
|
30
|
-
import {
|
|
22
|
+
import { lazy, Suspense } from "react";
|
|
31
23
|
import type { ControllerRenderProps, FieldValues, Path, PathValue } from "react-hook-form";
|
|
32
24
|
|
|
33
25
|
// --- Types ---
|
|
@@ -49,13 +41,11 @@ type StandardInputProps<T extends FieldValues> = BaseProps<T> & {
|
|
|
49
41
|
} & Omit<ComponentProps<typeof Input>, "required" | "form" | "name" | "value" | "onChange">;
|
|
50
42
|
|
|
51
43
|
// Variant 2: Autocomplete
|
|
52
|
-
export type AutocompleteOption
|
|
53
|
-
|
|
54
|
-
label: string;
|
|
55
|
-
};
|
|
44
|
+
export type { AutocompleteOption } from "./form-field-autocomplete";
|
|
45
|
+
|
|
56
46
|
type AutocompleteProps<T extends FieldValues> = BaseProps<T> & {
|
|
57
47
|
variant: "autocomplete";
|
|
58
|
-
data: AutocompleteOption[];
|
|
48
|
+
data: import("./form-field-autocomplete").AutocompleteOption[];
|
|
59
49
|
emptyMessage?: string;
|
|
60
50
|
placeholder?: string;
|
|
61
51
|
disabled?: boolean;
|
|
@@ -82,76 +72,28 @@ export type FormFieldInputProps<T extends FieldValues> =
|
|
|
82
72
|
|
|
83
73
|
// --- Helper Components ---
|
|
84
74
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
emptyMessage = "No results found.",
|
|
91
|
-
required,
|
|
92
|
-
}: {
|
|
93
|
-
field: ControllerRenderProps<T, Path<T>>;
|
|
94
|
-
data: AutocompleteOption[];
|
|
95
|
-
placeholder?: string;
|
|
96
|
-
disabled?: boolean;
|
|
97
|
-
emptyMessage?: string;
|
|
98
|
-
required?: boolean;
|
|
99
|
-
}) {
|
|
100
|
-
const [open, setOpen] = useState(false);
|
|
75
|
+
// variant autocomplete ใช้ cmdk (+ Radix Dialog ที่ cmdk พ่วงมา) — แยกไฟล์และโหลดแบบ lazy
|
|
76
|
+
// ไม่งั้นทุกฟอร์มที่ใช้ FormFieldInput ธรรมดาต้องพก cmdk ไปด้วย
|
|
77
|
+
const LazyAutocompleteInput = lazy(() =>
|
|
78
|
+
import("./form-field-autocomplete").then((m) => ({ default: m.AutocompleteInput })),
|
|
79
|
+
) as unknown as typeof import("./form-field-autocomplete").AutocompleteInput;
|
|
101
80
|
|
|
81
|
+
function AutocompleteInput<T extends FieldValues>(
|
|
82
|
+
props: ComponentProps<typeof import("./form-field-autocomplete").AutocompleteInput<T>>,
|
|
83
|
+
) {
|
|
102
84
|
return (
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
>
|
|
111
|
-
<CommandPrimitive.Input
|
|
112
|
-
value={field.value as string}
|
|
113
|
-
onValueChange={field.onChange}
|
|
114
|
-
onFocus={() => setOpen(true)}
|
|
115
|
-
onBlur={() => setOpen(false)}
|
|
116
|
-
placeholder={placeholder}
|
|
117
|
-
disabled={disabled}
|
|
118
|
-
className="w-full bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
|
85
|
+
<Suspense
|
|
86
|
+
fallback={
|
|
87
|
+
<Input
|
|
88
|
+
value={(props.field.value as string) ?? ""}
|
|
89
|
+
placeholder={props.placeholder}
|
|
90
|
+
disabled
|
|
91
|
+
readOnly
|
|
119
92
|
/>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<CommandEmpty className="py-2 text-center text-muted-foreground text-sm">
|
|
125
|
-
{emptyMessage}
|
|
126
|
-
</CommandEmpty>
|
|
127
|
-
<CommandGroup>
|
|
128
|
-
{data.map((option) => (
|
|
129
|
-
<CommandItem
|
|
130
|
-
key={option.value}
|
|
131
|
-
value={`${option.label} ${option.value}`}
|
|
132
|
-
onSelect={() => {
|
|
133
|
-
field.onChange(option.value as PathValue<T, Path<T>>);
|
|
134
|
-
setOpen(false);
|
|
135
|
-
}}
|
|
136
|
-
onMouseDown={(e) => {
|
|
137
|
-
e.preventDefault();
|
|
138
|
-
e.stopPropagation();
|
|
139
|
-
}}
|
|
140
|
-
>
|
|
141
|
-
<Check
|
|
142
|
-
className={cn(
|
|
143
|
-
"mr-2 h-4 w-4",
|
|
144
|
-
field.value === option.value ? "opacity-100" : "opacity-0",
|
|
145
|
-
)}
|
|
146
|
-
/>
|
|
147
|
-
{option.label}
|
|
148
|
-
</CommandItem>
|
|
149
|
-
))}
|
|
150
|
-
</CommandGroup>
|
|
151
|
-
</CommandList>
|
|
152
|
-
)}
|
|
153
|
-
</div>
|
|
154
|
-
</Command>
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
<LazyAutocompleteInput {...props} />
|
|
96
|
+
</Suspense>
|
|
155
97
|
);
|
|
156
98
|
}
|
|
157
99
|
|
|
@@ -3,12 +3,21 @@
|
|
|
3
3
|
import { NumberField } from "@base-ui/react/number-field";
|
|
4
4
|
import { cn } from "@suphark/ui/lib/utils";
|
|
5
5
|
import { MinusIcon, PlusIcon } from "lucide-react";
|
|
6
|
-
import type { ReactNode } from "react";
|
|
6
|
+
import type { ReactNode, Ref } from "react";
|
|
7
7
|
import { useUiText } from "../ui-text";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* input ซ่อนของ Base UI (ที่ส่งค่าใน FormData) ได้ attribute step ไปด้วย → browser ปฏิเสธค่าที่ไม่ลง step นับจาก min
|
|
11
|
+
* (เช่น 12.50 เมื่อ step=1, 150 เมื่อ min=0 step=100) ทั้งที่ step มีไว้แค่ให้ปุ่ม −/+ เลื่อน — ตั้ง step="any" ให้ input ซ่อน
|
|
12
|
+
* (React ไม่เขียน attribute ทับเพราะ prop step ไม่เปลี่ยน) ส่วน min/max ยังตรวจตามเดิม
|
|
13
|
+
*/
|
|
14
|
+
function relaxHiddenStep(el: HTMLInputElement | null) {
|
|
15
|
+
if (el) el.step = "any";
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
const SIZE = {
|
|
10
19
|
sm: { group: "h-7", button: "w-7 [&_svg]:size-3.5", text: "text-sm" },
|
|
11
|
-
default: { group: "h-8", button: "w-8 [&_svg]:size-4", text: "text-
|
|
20
|
+
default: { group: "h-8", button: "w-8 [&_svg]:size-4", text: "text-body" },
|
|
12
21
|
} as const;
|
|
13
22
|
|
|
14
23
|
/**
|
|
@@ -44,6 +53,8 @@ export function NumberInput({
|
|
|
44
53
|
"aria-label": ariaLabel,
|
|
45
54
|
"aria-describedby": ariaDescribedBy,
|
|
46
55
|
"aria-invalid": ariaInvalid,
|
|
56
|
+
inputRef,
|
|
57
|
+
autoFocus,
|
|
47
58
|
}: {
|
|
48
59
|
value?: number | null;
|
|
49
60
|
defaultValue?: number;
|
|
@@ -75,6 +86,9 @@ export function NumberInput({
|
|
|
75
86
|
"aria-label"?: string;
|
|
76
87
|
"aria-describedby"?: string;
|
|
77
88
|
"aria-invalid"?: boolean | "true" | "false";
|
|
89
|
+
/** ref ของช่องที่ผู้ใช้พิมพ์ (เช่น focus() เอง) */
|
|
90
|
+
inputRef?: Ref<HTMLInputElement>;
|
|
91
|
+
autoFocus?: boolean;
|
|
78
92
|
}) {
|
|
79
93
|
const text = useUiText().number;
|
|
80
94
|
const s = SIZE[size];
|
|
@@ -99,6 +113,7 @@ export function NumberInput({
|
|
|
99
113
|
required={required}
|
|
100
114
|
disabled={disabled}
|
|
101
115
|
readOnly={readOnly}
|
|
116
|
+
inputRef={relaxHiddenStep}
|
|
102
117
|
className={cn("w-full", className)}
|
|
103
118
|
>
|
|
104
119
|
<NumberField.Group
|
|
@@ -118,6 +133,8 @@ export function NumberInput({
|
|
|
118
133
|
)}
|
|
119
134
|
{prefix && <span className={cn(affix, !hideButtons && "pr-0")}>{prefix}</span>}
|
|
120
135
|
<NumberField.Input
|
|
136
|
+
ref={inputRef}
|
|
137
|
+
autoFocus={autoFocus}
|
|
121
138
|
placeholder={placeholder}
|
|
122
139
|
aria-label={ariaLabel}
|
|
123
140
|
aria-describedby={ariaDescribedBy}
|
|
@@ -116,7 +116,12 @@ export function TagsInput({
|
|
|
116
116
|
const pasted = e.clipboardData.getData("text");
|
|
117
117
|
if (!separator.test(pasted)) return;
|
|
118
118
|
e.preventDefault();
|
|
119
|
-
|
|
119
|
+
// แทรกที่ตำแหน่ง cursor ของข้อความที่พิมพ์ค้างไว้ก่อนแยก (เช่น พิมพ์ "https://" แล้ววาง "a.com,b.com")
|
|
120
|
+
const el = e.currentTarget;
|
|
121
|
+
const start = el.selectionStart ?? draft.length;
|
|
122
|
+
const end = el.selectionEnd ?? draft.length;
|
|
123
|
+
const combined = draft.slice(0, start) + pasted + draft.slice(end);
|
|
124
|
+
setDraft(add(combined.split(separator)));
|
|
120
125
|
};
|
|
121
126
|
|
|
122
127
|
const remove = (index: number) => {
|
|
@@ -179,7 +184,7 @@ export function TagsInput({
|
|
|
179
184
|
onBlur={() => {
|
|
180
185
|
if (draft.trim()) setDraft(add([draft]));
|
|
181
186
|
}}
|
|
182
|
-
className="h-6 min-w-24 flex-1 bg-transparent px-1 text-
|
|
187
|
+
className="h-6 min-w-24 flex-1 bg-transparent px-1 text-body outline-none placeholder:text-muted-foreground"
|
|
183
188
|
/>
|
|
184
189
|
</div>
|
|
185
190
|
{error && (
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
-
import { Calendar } from "@suphark/ui/components/ui/calendar";
|
|
5
4
|
import {
|
|
6
5
|
InputGroup,
|
|
7
6
|
InputGroupAddon,
|
|
@@ -10,15 +9,14 @@ import {
|
|
|
10
9
|
} from "@suphark/ui/components/ui/input-group";
|
|
11
10
|
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
12
11
|
import { cn } from "@suphark/ui/lib/utils";
|
|
13
|
-
import {
|
|
12
|
+
import { ClockIcon } from "lucide-react";
|
|
14
13
|
import { type KeyboardEvent, useEffect, useId, useRef, useState } from "react";
|
|
15
|
-
import { th } from "react-day-picker/locale";
|
|
16
14
|
import { useUiText } from "../ui-text";
|
|
17
15
|
|
|
18
16
|
/** "HH:mm" 24 ชั่วโมง */
|
|
19
17
|
export type TimeValue = string;
|
|
20
18
|
|
|
21
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
19
|
+
export const pad = (n: number) => String(n).padStart(2, "0");
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
22
|
* แปลงข้อความที่พิมพ์เป็น "HH:mm": "14:30" "14.30" "1430" "930" "9" "9:5" → null ถ้าไม่ใช่เวลาที่ถูกต้อง
|
|
@@ -47,7 +45,14 @@ export function parseTimeInput(input: string): TimeValue | null {
|
|
|
47
45
|
return `${pad(h)}:${pad(m)}`;
|
|
48
46
|
}
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
/** ปัดนาทีลงให้ลงตัวกับ step (step 1 = ไม่เปลี่ยน) */
|
|
49
|
+
function roundTime(value: TimeValue | null, step: number): TimeValue | null {
|
|
50
|
+
if (!value || step <= 1) return value;
|
|
51
|
+
const [h, m] = value.split(":").map(Number);
|
|
52
|
+
return `${pad(h ?? 0)}:${pad(Math.floor((m ?? 0) / step) * step)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function splitTime(value: TimeValue | null | undefined): [number, number] | null {
|
|
51
56
|
const parsed = value ? parseTimeInput(value) : null;
|
|
52
57
|
if (!parsed) return null;
|
|
53
58
|
const [h, m] = parsed.split(":").map(Number);
|
|
@@ -70,11 +75,12 @@ function TimeColumn({
|
|
|
70
75
|
const listRef = useRef<HTMLDivElement>(null);
|
|
71
76
|
const optionId = (v: number) => `${baseId}-${v}`;
|
|
72
77
|
|
|
73
|
-
//
|
|
78
|
+
// เลื่อนเฉพาะในคอลัมน์ให้ค่าที่เลือกอยู่กลาง — ไม่ใช้ scrollIntoView เพราะจะเลื่อนทั้งหน้าไปด้วย
|
|
74
79
|
useEffect(() => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
el
|
|
80
|
+
const list = listRef.current;
|
|
81
|
+
if (selected == null || !list) return;
|
|
82
|
+
const el = list.querySelector<HTMLElement>(`[data-value="${selected}"]`);
|
|
83
|
+
if (el) list.scrollTop = el.offsetTop - (list.clientHeight - el.offsetHeight) / 2;
|
|
78
84
|
}, [selected]);
|
|
79
85
|
|
|
80
86
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
@@ -98,7 +104,7 @@ function TimeColumn({
|
|
|
98
104
|
aria-label={label}
|
|
99
105
|
aria-activedescendant={selected == null ? undefined : optionId(selected)}
|
|
100
106
|
onKeyDown={onKeyDown}
|
|
101
|
-
className="no-scrollbar flex max-h-56 w-14 flex-col gap-0.5 overflow-y-auto rounded-md p-0.5 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
107
|
+
className="no-scrollbar relative flex max-h-56 w-14 flex-col gap-0.5 overflow-y-auto rounded-md p-0.5 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
102
108
|
>
|
|
103
109
|
{options.map((v) => (
|
|
104
110
|
<div
|
|
@@ -161,7 +167,12 @@ export function TimeColumns({
|
|
|
161
167
|
);
|
|
162
168
|
}
|
|
163
169
|
|
|
164
|
-
|
|
170
|
+
/** state แบบ controlled/uncontrolled (ใช้ร่วมกับ DateTimePicker) */
|
|
171
|
+
export function useControllable<T>(
|
|
172
|
+
value: T | undefined,
|
|
173
|
+
defaultValue: T,
|
|
174
|
+
onChange?: (v: T) => void,
|
|
175
|
+
) {
|
|
165
176
|
const [inner, setInner] = useState(defaultValue);
|
|
166
177
|
const controlled = value !== undefined;
|
|
167
178
|
const current = controlled ? value : inner;
|
|
@@ -188,6 +199,7 @@ export function TimePicker({
|
|
|
188
199
|
defaultValue = null,
|
|
189
200
|
onValueChange,
|
|
190
201
|
minuteStep = 5,
|
|
202
|
+
roundToStep = false,
|
|
191
203
|
name,
|
|
192
204
|
id,
|
|
193
205
|
required,
|
|
@@ -202,6 +214,8 @@ export function TimePicker({
|
|
|
202
214
|
defaultValue?: TimeValue | null;
|
|
203
215
|
onValueChange?: (value: TimeValue | null) => void;
|
|
204
216
|
minuteStep?: number;
|
|
217
|
+
/** ปัดนาทีที่พิมพ์ลงตาม minuteStep (เช่น step 60 + พิมพ์ 14:30 → 14:00) — ค่าเริ่มต้นเก็บตามที่พิมพ์ */
|
|
218
|
+
roundToStep?: boolean;
|
|
205
219
|
name?: string;
|
|
206
220
|
id?: string;
|
|
207
221
|
required?: boolean;
|
|
@@ -219,6 +233,13 @@ export function TimePicker({
|
|
|
219
233
|
const [draft, setDraft] = useState<string | null>(null);
|
|
220
234
|
const [open, setOpen] = useState(false);
|
|
221
235
|
const draftInvalid = draft != null && draft.trim() !== "" && parseTimeInput(draft) == null;
|
|
236
|
+
const invalidMessage = text?.invalid ?? "รูปแบบเวลาไม่ถูกต้อง (เช่น 14:30)";
|
|
237
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
238
|
+
|
|
239
|
+
// ข้อความที่พิมพ์ผิด = ช่องนี้ invalid จริง → ฟอร์มไม่ส่ง (ไม่งั้น input ซ่อนจะส่งค่าเดิมไปเงียบ ๆ)
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
inputRef.current?.setCustomValidity(draftInvalid ? invalidMessage : "");
|
|
242
|
+
}, [draftInvalid, invalidMessage]);
|
|
222
243
|
|
|
223
244
|
const commitDraft = () => {
|
|
224
245
|
if (draft == null) return;
|
|
@@ -227,7 +248,7 @@ export function TimePicker({
|
|
|
227
248
|
setDraft(null);
|
|
228
249
|
return;
|
|
229
250
|
}
|
|
230
|
-
const parsed = parseTimeInput(draft);
|
|
251
|
+
const parsed = roundTime(parseTimeInput(draft), roundToStep ? minuteStep : 1);
|
|
231
252
|
if (parsed) {
|
|
232
253
|
setTime(parsed);
|
|
233
254
|
setDraft(null);
|
|
@@ -237,6 +258,7 @@ export function TimePicker({
|
|
|
237
258
|
return (
|
|
238
259
|
<InputGroup data-slot="time-picker" className={cn("w-32", className)}>
|
|
239
260
|
<InputGroupInput
|
|
261
|
+
ref={inputRef}
|
|
240
262
|
id={id}
|
|
241
263
|
inputMode="numeric"
|
|
242
264
|
autoComplete="off"
|
|
@@ -307,115 +329,3 @@ export function TimePicker({
|
|
|
307
329
|
export function toLocalDateTimeString(date: Date): string {
|
|
308
330
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
309
331
|
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* เลือกวัน + เวลาในกล่องเดียว (ปฏิทิน + คอลัมน์ชั่วโมง/นาที) — นัดหมาย, กำหนดส่ง, เวลาเตือน
|
|
313
|
-
* ค่าเป็น `Date | null`; เลือกวันก่อนเวลา = ใช้ `defaultTime`; `name` ส่ง "YYYY-MM-DDTHH:mm" (เวลาท้องถิ่น) ใน FormData
|
|
314
|
-
*
|
|
315
|
-
* <DateTimePicker value={dueAt} onValueChange={setDueAt} minuteStep={15} />
|
|
316
|
-
*/
|
|
317
|
-
export function DateTimePicker({
|
|
318
|
-
value,
|
|
319
|
-
defaultValue = null,
|
|
320
|
-
onValueChange,
|
|
321
|
-
minuteStep = 5,
|
|
322
|
-
defaultTime = "09:00",
|
|
323
|
-
name,
|
|
324
|
-
id,
|
|
325
|
-
disabled,
|
|
326
|
-
placeholder,
|
|
327
|
-
className,
|
|
328
|
-
"aria-label": ariaLabel,
|
|
329
|
-
"aria-invalid": ariaInvalid,
|
|
330
|
-
}: {
|
|
331
|
-
value?: Date | null;
|
|
332
|
-
defaultValue?: Date | null;
|
|
333
|
-
onValueChange?: (value: Date | null) => void;
|
|
334
|
-
minuteStep?: number;
|
|
335
|
-
/** เวลาที่ใช้เมื่อเลือกวันครั้งแรก */
|
|
336
|
-
defaultTime?: TimeValue;
|
|
337
|
-
name?: string;
|
|
338
|
-
id?: string;
|
|
339
|
-
disabled?: boolean;
|
|
340
|
-
placeholder?: string;
|
|
341
|
-
className?: string;
|
|
342
|
-
"aria-label"?: string;
|
|
343
|
-
"aria-invalid"?: boolean;
|
|
344
|
-
}) {
|
|
345
|
-
const ui = useUiText();
|
|
346
|
-
const text = ui.time;
|
|
347
|
-
const [date, setDate] = useControllable<Date | null>(value, defaultValue, onValueChange);
|
|
348
|
-
const [open, setOpen] = useState(false);
|
|
349
|
-
const time = date ? `${pad(date.getHours())}:${pad(date.getMinutes())}` : null;
|
|
350
|
-
|
|
351
|
-
const withTime = (day: Date, t: TimeValue) => {
|
|
352
|
-
const [h, m] = splitTime(t) ?? [9, 0];
|
|
353
|
-
const d = new Date(day);
|
|
354
|
-
d.setHours(h, m, 0, 0);
|
|
355
|
-
return d;
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
const label = date
|
|
359
|
-
? date.toLocaleString("th-TH", { dateStyle: "medium", timeStyle: "short" })
|
|
360
|
-
: (placeholder ?? text?.dateTimePlaceholder ?? "เลือกวันและเวลา");
|
|
361
|
-
|
|
362
|
-
return (
|
|
363
|
-
<Popover open={open} onOpenChange={setOpen}>
|
|
364
|
-
<PopoverTrigger
|
|
365
|
-
render={
|
|
366
|
-
<Button
|
|
367
|
-
id={id}
|
|
368
|
-
type="button"
|
|
369
|
-
variant="outline"
|
|
370
|
-
disabled={disabled}
|
|
371
|
-
aria-label={ariaLabel}
|
|
372
|
-
aria-invalid={ariaInvalid || undefined}
|
|
373
|
-
data-slot="date-time-picker"
|
|
374
|
-
className={cn(
|
|
375
|
-
"w-full justify-start gap-2 font-normal",
|
|
376
|
-
!date && "text-muted-foreground",
|
|
377
|
-
className,
|
|
378
|
-
)}
|
|
379
|
-
/>
|
|
380
|
-
}
|
|
381
|
-
>
|
|
382
|
-
<CalendarClockIcon className="text-muted-foreground" />
|
|
383
|
-
<span className="truncate">{label}</span>
|
|
384
|
-
</PopoverTrigger>
|
|
385
|
-
{name && <input type="hidden" name={name} value={date ? toLocalDateTimeString(date) : ""} />}
|
|
386
|
-
<PopoverContent align="start" className="w-auto gap-0 p-0">
|
|
387
|
-
<div className="flex flex-col sm:flex-row">
|
|
388
|
-
<Calendar
|
|
389
|
-
mode="single"
|
|
390
|
-
locale={th}
|
|
391
|
-
selected={date ?? undefined}
|
|
392
|
-
defaultMonth={date ?? undefined}
|
|
393
|
-
onSelect={(day) => day && setDate(withTime(day, time ?? defaultTime))}
|
|
394
|
-
className="p-3"
|
|
395
|
-
/>
|
|
396
|
-
<div className="flex justify-center border-t p-2 sm:border-t-0 sm:border-l">
|
|
397
|
-
<TimeColumns
|
|
398
|
-
value={time}
|
|
399
|
-
minuteStep={minuteStep}
|
|
400
|
-
onValueChange={(t) => setDate(withTime(date ?? new Date(), t))}
|
|
401
|
-
/>
|
|
402
|
-
</div>
|
|
403
|
-
</div>
|
|
404
|
-
<div className="flex justify-between gap-2 border-t p-2">
|
|
405
|
-
<Button
|
|
406
|
-
type="button"
|
|
407
|
-
size="xs"
|
|
408
|
-
variant="ghost"
|
|
409
|
-
disabled={!date}
|
|
410
|
-
onClick={() => setDate(null)}
|
|
411
|
-
>
|
|
412
|
-
{text?.clear ?? "ล้าง"}
|
|
413
|
-
</Button>
|
|
414
|
-
<Button type="button" size="xs" onClick={() => setOpen(false)}>
|
|
415
|
-
{ui.done}
|
|
416
|
-
</Button>
|
|
417
|
-
</div>
|
|
418
|
-
</PopoverContent>
|
|
419
|
-
</Popover>
|
|
420
|
-
);
|
|
421
|
-
}
|
|
@@ -38,6 +38,9 @@ export interface MonthStepperProps {
|
|
|
38
38
|
* const { month, setMonth } = useMonthCursor();
|
|
39
39
|
* <MonthStepper value={month} onValueChange={setMonth} />
|
|
40
40
|
*/
|
|
41
|
+
/** "เดือนนี้" ของ MonthStepper/useMonthCursor คิดตามเวลาไทย — แอปของเราใช้ในไทย และต้องได้ค่าเดียวกันทั้ง server และ browser */
|
|
42
|
+
const APP_TIME_ZONE = "Asia/Bangkok";
|
|
43
|
+
|
|
41
44
|
export function MonthStepper({
|
|
42
45
|
value,
|
|
43
46
|
onValueChange,
|
|
@@ -54,7 +57,8 @@ export function MonthStepper({
|
|
|
54
57
|
const next = shiftMonth(value, 1);
|
|
55
58
|
const canPrev = !min || compareMonth(prev, min) >= 0;
|
|
56
59
|
const canNext = !max || compareMonth(next, max) <= 0;
|
|
57
|
-
|
|
60
|
+
// โซนเวลาคงที่: server (UTC) กับ browser ได้เดือนเดียวกัน — ไม่งั้นช่วง 00–07 น. วันที่ 1 จะ hydration mismatch
|
|
61
|
+
const now = currentMonthKey(undefined, APP_TIME_ZONE);
|
|
58
62
|
const nowInRange = (!min || compareMonth(now, min) >= 0) && (!max || compareMonth(now, max) <= 0);
|
|
59
63
|
const label = formatLabel ? formatLabel(value) : formatMonthKey(value, locale, format);
|
|
60
64
|
const isCurrent = value === now;
|
|
@@ -112,13 +116,15 @@ export function MonthStepper({
|
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
/**
|
|
115
|
-
* state เดือนปัจจุบัน + ตัวเลื่อน — `initial` ไม่ส่ง = เดือนนี้
|
|
119
|
+
* state เดือนปัจจุบัน + ตัวเลื่อน — `initial` ไม่ส่ง = เดือนนี้ (ตามเวลาไทย เพื่อให้ server/browser ตรงกัน)
|
|
116
120
|
* ต้องการเก็บใน URL ให้ใช้ `useTableSearchParams`/searchParams ของแอปแทน แล้วส่ง value/onValueChange เอง
|
|
117
121
|
*/
|
|
118
122
|
export function useMonthCursor(initial?: MonthKey) {
|
|
119
|
-
const [month, setMonth] = useState<MonthKey>(
|
|
123
|
+
const [month, setMonth] = useState<MonthKey>(
|
|
124
|
+
() => initial ?? currentMonthKey(undefined, APP_TIME_ZONE),
|
|
125
|
+
);
|
|
120
126
|
const previous = useCallback(() => setMonth((m) => shiftMonth(m, -1)), []);
|
|
121
127
|
const next = useCallback(() => setMonth((m) => shiftMonth(m, 1)), []);
|
|
122
|
-
const reset = useCallback(() => setMonth(currentMonthKey()), []);
|
|
128
|
+
const reset = useCallback(() => setMonth(currentMonthKey(undefined, APP_TIME_ZONE)), []);
|
|
123
129
|
return { month, setMonth, previous, next, reset };
|
|
124
130
|
}
|
|
@@ -13,7 +13,7 @@ import Link from "next/link";
|
|
|
13
13
|
import { useRouter } from "next/navigation";
|
|
14
14
|
import { useState, useTransition } from "react";
|
|
15
15
|
import { toast } from "sonner";
|
|
16
|
-
import { errorMessage } from "./button/confirm-action-button";
|
|
16
|
+
import { errorMessage, failedResult } from "./button/confirm-action-button";
|
|
17
17
|
import { ConfirmDialog, type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
|
|
18
18
|
import { useUiText } from "./ui-text";
|
|
19
19
|
|
|
@@ -58,10 +58,16 @@ export function RowActions({ items, label }: { items: RowAction[]; label?: strin
|
|
|
58
58
|
for (const [k, v] of Object.entries(it.data ?? {})) fd.set(k, v);
|
|
59
59
|
const act = it.action;
|
|
60
60
|
start(async () => {
|
|
61
|
+
const fallback = text.actionFailed ?? "ดำเนินการไม่สำเร็จ";
|
|
61
62
|
try {
|
|
62
|
-
|
|
63
|
+
// คืน { ok: false, error } (FormState) ก็ถือว่าล้มเหลวเหมือน throw
|
|
64
|
+
const failed = failedResult(await act(fd), fallback);
|
|
65
|
+
if (failed) {
|
|
66
|
+
toast.error(failed);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
63
69
|
} catch (err) {
|
|
64
|
-
toast.error(errorMessage(err,
|
|
70
|
+
toast.error(errorMessage(err, fallback));
|
|
65
71
|
return;
|
|
66
72
|
}
|
|
67
73
|
router.refresh();
|