@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/package.json +1 -2
  3. package/src/components/app-shell/command-palette.tsx +8 -1
  4. package/src/components/data-table/data-table-sortable.tsx +154 -0
  5. package/src/components/data-table/data-table-toolbar.tsx +33 -4
  6. package/src/components/data-table/data-table.tsx +30 -142
  7. package/src/components/data-table/use-data-table.ts +8 -5
  8. package/src/components/design-system/design-system-showcase.tsx +63 -15
  9. package/src/components/pwa/pull-to-refresh.tsx +21 -1
  10. package/src/components/shared/action-menu.tsx +25 -6
  11. package/src/components/shared/auth-parts.tsx +5 -3
  12. package/src/components/shared/button/base/action-button.tsx +7 -14
  13. package/src/components/shared/button/base/link-button.tsx +5 -14
  14. package/src/components/shared/button/confirm-action-button.tsx +17 -3
  15. package/src/components/shared/chat-thread.tsx +7 -0
  16. package/src/components/shared/client-redirect.tsx +16 -1
  17. package/src/components/shared/form/date-time-picker.tsx +150 -0
  18. package/src/components/shared/form/form-field-autocomplete.tsx +93 -0
  19. package/src/components/shared/form/form-field-input.tsx +23 -81
  20. package/src/components/shared/form/number-input.tsx +19 -2
  21. package/src/components/shared/form/tags-input.tsx +7 -2
  22. package/src/components/shared/form/time-picker.tsx +34 -124
  23. package/src/components/shared/month-stepper.tsx +10 -4
  24. package/src/components/shared/row-actions.tsx +9 -3
  25. package/src/components/shared/settings-layout.tsx +17 -6
  26. package/src/components/shared/step-form.tsx +25 -4
  27. package/src/components/shared/time/client-date-formatter.tsx +2 -1
  28. package/src/components/shared/tree-view.tsx +6 -2
  29. package/src/components/shared/ui-text.tsx +3 -0
  30. package/src/components/ui/input.tsx +1 -1
  31. package/src/components/ui/native-select.tsx +1 -1
  32. package/src/components/ui/select.tsx +1 -1
  33. package/src/components/ui/textarea.tsx +1 -1
  34. package/src/hooks/use-table-search-params.ts +10 -2
  35. package/src/hooks/use-ticker.ts +11 -5
  36. package/src/index.ts +1 -1
  37. package/src/lib/formatters.ts +1 -1
  38. 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 { useState } from "react";
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
- value: string;
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
- function AutocompleteInput<T extends FieldValues>({
86
- field,
87
- data,
88
- placeholder,
89
- disabled,
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
- <Command className="overflow-visible bg-transparent">
104
- <div
105
- className={cn(
106
- "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",
107
- disabled && "cursor-not-allowed opacity-50",
108
- required && !field.value && "border-input",
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
- </div>
121
- <div className="relative mt-1">
122
- {open && field.value && (
123
- <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">
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-base md:text-sm" },
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
- setDraft(add(pasted.split(separator)));
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-base outline-none placeholder:text-muted-foreground md:text-sm"
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 { CalendarClockIcon, ClockIcon } from "lucide-react";
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
- function splitTime(value: TimeValue | null | undefined): [number, number] | null {
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
- if (selected == null) return;
76
- const el = listRef.current?.querySelector<HTMLElement>(`[data-value="${selected}"]`);
77
- el?.scrollIntoView?.({ block: "center" });
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
- function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (v: T) => void) {
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
- const now = currentMonthKey();
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>(() => initial ?? currentMonthKey());
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
- await act(fd);
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, text.actionFailed ?? "ดำเนินการไม่สำเร็จ"));
70
+ toast.error(errorMessage(err, fallback));
65
71
  return;
66
72
  }
67
73
  router.refresh();