@suphark/ui 0.4.3 → 0.5.1

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 (42) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +25 -0
  3. package/package.json +3 -1
  4. package/src/components/app-shell/app-nav.tsx +1 -1
  5. package/src/components/app-shell/app-shell.tsx +31 -5
  6. package/src/components/app-shell/header-breadcrumbs.tsx +2 -1
  7. package/src/components/app-shell/header-crumb.tsx +1 -1
  8. package/src/components/app-shell/index.ts +1 -1
  9. package/src/components/app-shell/sub-nav.tsx +3 -3
  10. package/src/components/app-shell/top-tabs.tsx +1 -1
  11. package/src/components/app-shell/user-menu.tsx +1 -1
  12. package/src/components/data-table/data-table-column-header.tsx +1 -1
  13. package/src/components/data-table/data-table-row-actions.tsx +1 -3
  14. package/src/components/data-table/use-data-table.ts +4 -1
  15. package/src/components/design-system/design-system-showcase.tsx +1 -1
  16. package/src/components/pwa/install-prompt-store.ts +26 -0
  17. package/src/components/pwa/pull-to-refresh.tsx +147 -0
  18. package/src/components/pwa/pwa-install-card.tsx +165 -0
  19. package/src/components/pwa/pwa-service-worker-registration.tsx +63 -0
  20. package/src/components/pwa/service-worker-updates.ts +76 -0
  21. package/src/components/shared/button/confirm-action-button.tsx +15 -1
  22. package/src/components/shared/button/delete-many-button.tsx +7 -3
  23. package/src/components/shared/button/refresh-button.tsx +2 -1
  24. package/src/components/shared/combobox.tsx +2 -1
  25. package/src/components/shared/confirm-dialog.tsx +1 -0
  26. package/src/components/shared/dialogs-provider.tsx +270 -0
  27. package/src/components/shared/form/form-field-combobox.tsx +1 -1
  28. package/src/components/shared/form/form-field-input.tsx +8 -2
  29. package/src/components/shared/form/form-field-radio-cards.tsx +1 -1
  30. package/src/components/shared/form/form-field-select.tsx +9 -5
  31. package/src/components/shared/layout/error-state.tsx +16 -5
  32. package/src/components/shared/link/transition-link.tsx +2 -0
  33. package/src/components/shared/row-actions.tsx +12 -2
  34. package/src/components/shared/ui-text.tsx +24 -0
  35. package/src/components/ui/relative-date.tsx +39 -9
  36. package/src/hooks/use-tracked-state.ts +35 -0
  37. package/src/index.ts +12 -0
  38. package/src/kanban.ts +9 -0
  39. package/src/lib/formatters.ts +5 -3
  40. package/src/lib/kanban-collision.ts +118 -0
  41. package/src/pwa.ts +24 -0
  42. package/src/styles.css +12 -0
@@ -0,0 +1,270 @@
1
+ "use client";
2
+
3
+ import {
4
+ AlertDialog,
5
+ AlertDialogAction,
6
+ AlertDialogCancel,
7
+ AlertDialogContent,
8
+ AlertDialogDescription,
9
+ AlertDialogFooter,
10
+ AlertDialogHeader,
11
+ AlertDialogTitle,
12
+ } from "@suphark/ui/components/ui/alert-dialog";
13
+ import { Checkbox } from "@suphark/ui/components/ui/checkbox";
14
+ import { Input } from "@suphark/ui/components/ui/input";
15
+ import {
16
+ createContext,
17
+ type ReactNode,
18
+ useCallback,
19
+ useContext,
20
+ useMemo,
21
+ useRef,
22
+ useState,
23
+ } from "react";
24
+ import { type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
25
+ import { useUiText } from "./ui-text";
26
+
27
+ type BaseSpec = Exclude<ConfirmSpec, string>;
28
+
29
+ /** `alert()` — ปุ่มเดียว (ตกลง) */
30
+ export type AlertSpec = ConfirmSpec;
31
+ /** `prompt()` — ช่องกรอกข้อความ resolve string หรือ null เมื่อยกเลิก */
32
+ export type PromptSpec =
33
+ | string
34
+ | (BaseSpec & {
35
+ defaultValue?: string;
36
+ placeholder?: string;
37
+ });
38
+ export interface ChoiceOption {
39
+ label: ReactNode;
40
+ value: string;
41
+ variant?: "default" | "destructive" | "outline";
42
+ }
43
+ /** `choose()` — ปุ่มหลายทางเลือก resolve value ของปุ่มที่กด หรือ null เมื่อยกเลิก */
44
+ export interface ChoiceSpec extends Omit<BaseSpec, "confirmLabel" | "destructive"> {
45
+ choices: ChoiceOption[];
46
+ }
47
+ export interface ChecklistOption {
48
+ label: ReactNode;
49
+ value: string;
50
+ description?: ReactNode;
51
+ defaultChecked?: boolean;
52
+ }
53
+ /** `checklist()` — ติ๊กเลือกหลายรายการ resolve value[] ที่เลือก หรือ null เมื่อยกเลิก */
54
+ export interface ChecklistSpec extends BaseSpec {
55
+ options: ChecklistOption[];
56
+ }
57
+
58
+ export interface Dialogs {
59
+ alert: (spec: AlertSpec) => Promise<void>;
60
+ confirm: (spec: ConfirmSpec) => Promise<boolean>;
61
+ prompt: (spec: PromptSpec) => Promise<string | null>;
62
+ choose: (spec: ChoiceSpec) => Promise<string | null>;
63
+ checklist: (spec: ChecklistSpec) => Promise<string[] | null>;
64
+ }
65
+
66
+ type Mode = "alert" | "confirm" | "prompt" | "choice" | "checklist";
67
+ type Request = BaseSpec & {
68
+ mode: Mode;
69
+ placeholder?: string;
70
+ choices?: ChoiceOption[];
71
+ options?: ChecklistOption[];
72
+ };
73
+ type Result = boolean | string | string[] | null;
74
+
75
+ const DialogsContext = createContext<Dialogs | null>(null);
76
+
77
+ /**
78
+ * กล่องโต้ตอบแบบ promise ทั้งแอป (แทน `window.alert/confirm/prompt`) — ครอบครั้งเดียวที่ root แล้วเรียก
79
+ * `useDialogs()` จากที่ไหนก็ได้ โดยไม่ต้อง render อะไรในต้นไม้ของผู้เรียก (ต่างจาก `useConfirm()`)
80
+ *
81
+ * const dialogs = useDialogs();
82
+ * if (!(await dialogs.confirm({ description: "ลบโครงการนี้?", destructive: true }))) return;
83
+ * const name = await dialogs.prompt({ description: "ชื่อโครงการใหม่", defaultValue: project.name });
84
+ * const action = await dialogs.choose({ description: "ทำอย่างไรกับงานข้างใน?", choices: [
85
+ * { label: "ลบทั้งหมด", value: "delete", variant: "destructive" },
86
+ * { label: "ย้ายไป Inbox", value: "inbox" },
87
+ * ] });
88
+ * const picked = await dialogs.checklist({ description: "เลือกคอลัมน์", options: [{ label: "ชื่อ", value: "name", defaultChecked: true }] });
89
+ *
90
+ * เปิดคำขอใหม่ขณะกล่องเปิดอยู่ → คำขอเดิม resolve เป็นค่ายกเลิก (false/null) แล้วแสดงคำขอใหม่แทน
91
+ * ข้อความค่าเริ่มต้น (หัว/ปุ่ม) มาจาก `UiText` — ส่ง `title`/`confirmLabel`/`cancelLabel` เพื่อปรับต่อครั้ง
92
+ */
93
+ export function DialogsProvider({ children }: { children: ReactNode }) {
94
+ const text = useUiText();
95
+ const [request, setRequest] = useState<Request | null>(null);
96
+ const [promptValue, setPromptValue] = useState("");
97
+ const [checked, setChecked] = useState<string[]>([]);
98
+ const resolveRef = useRef<((value: Result) => void) | null>(null);
99
+
100
+ const open = useCallback((next: Request, resolve: (value: Result) => void) => {
101
+ // คำขอที่ค้างอยู่ถือว่ายกเลิก
102
+ resolveRef.current?.(next.mode === "confirm" || next.mode === "alert" ? false : null);
103
+ resolveRef.current = resolve;
104
+ setRequest(next);
105
+ }, []);
106
+
107
+ const settle = useCallback((value: Result) => {
108
+ resolveRef.current?.(value);
109
+ resolveRef.current = null;
110
+ setRequest(null);
111
+ setPromptValue("");
112
+ setChecked([]);
113
+ }, []);
114
+
115
+ const dialogs = useMemo<Dialogs>(
116
+ () => ({
117
+ alert: (spec) =>
118
+ new Promise((resolve) => {
119
+ open({ ...normalizeConfirm(spec), mode: "alert" }, () => resolve());
120
+ }),
121
+ confirm: (spec) =>
122
+ new Promise((resolve) => {
123
+ open({ ...normalizeConfirm(spec), mode: "confirm" }, (value) => resolve(value === true));
124
+ }),
125
+ prompt: (spec) =>
126
+ new Promise((resolve) => {
127
+ const options = typeof spec === "string" ? { description: spec } : spec;
128
+ setPromptValue(options.defaultValue ?? "");
129
+ open({ ...options, mode: "prompt" }, (value) =>
130
+ resolve(typeof value === "string" ? value : null),
131
+ );
132
+ }),
133
+ choose: (spec) =>
134
+ new Promise((resolve) => {
135
+ open({ ...spec, mode: "choice" }, (value) =>
136
+ resolve(typeof value === "string" ? value : null),
137
+ );
138
+ }),
139
+ checklist: (spec) =>
140
+ new Promise((resolve) => {
141
+ setChecked(spec.options.filter((o) => o.defaultChecked).map((o) => o.value));
142
+ open({ ...spec, mode: "checklist" }, (value) =>
143
+ resolve(Array.isArray(value) ? value : null),
144
+ );
145
+ }),
146
+ }),
147
+ [open],
148
+ );
149
+
150
+ const cancelValue: Result = request?.mode === "confirm" ? false : null;
151
+ const titleByMode: Record<Mode, string> = {
152
+ alert: text.alertTitle ?? "แจ้งให้ทราบ",
153
+ confirm: text.confirmTitle ?? "ยืนยันการทำรายการ",
154
+ prompt: text.promptTitle ?? "กรอกข้อมูล",
155
+ choice: text.chooseTitle ?? "เลือกการทำรายการ",
156
+ checklist: text.checklistTitle ?? "เลือกรายการ",
157
+ };
158
+ const confirmLabelByMode: Record<Mode, string> = {
159
+ alert: text.ok ?? "ตกลง",
160
+ confirm: text.confirm ?? "ยืนยัน",
161
+ prompt: text.save,
162
+ choice: "",
163
+ checklist: text.confirm ?? "ยืนยัน",
164
+ };
165
+
166
+ const confirmResult = (): Result => {
167
+ if (!request) return null;
168
+ if (request.mode === "prompt") return promptValue;
169
+ if (request.mode === "checklist") return checked;
170
+ return true;
171
+ };
172
+
173
+ return (
174
+ <DialogsContext.Provider value={dialogs}>
175
+ {children}
176
+ <AlertDialog open={request !== null} onOpenChange={(o) => !o && settle(cancelValue)}>
177
+ {request && (
178
+ <AlertDialogContent>
179
+ <AlertDialogHeader>
180
+ <AlertDialogTitle>{request.title ?? titleByMode[request.mode]}</AlertDialogTitle>
181
+ <AlertDialogDescription>{request.description}</AlertDialogDescription>
182
+ </AlertDialogHeader>
183
+
184
+ {request.mode === "prompt" && (
185
+ <Input
186
+ autoFocus
187
+ value={promptValue}
188
+ placeholder={request.placeholder}
189
+ onChange={(event) => setPromptValue(event.target.value)}
190
+ onKeyDown={(event) => {
191
+ if (event.key === "Enter") {
192
+ event.preventDefault();
193
+ settle(promptValue);
194
+ }
195
+ }}
196
+ />
197
+ )}
198
+
199
+ {request.mode === "checklist" && (
200
+ <div className="flex flex-col gap-2">
201
+ {request.options?.map((option) => {
202
+ const isChecked = checked.includes(option.value);
203
+ return (
204
+ <label
205
+ key={option.value}
206
+ className="flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 transition-colors hover:bg-muted/50"
207
+ >
208
+ <Checkbox
209
+ className="mt-0.5"
210
+ checked={isChecked}
211
+ onCheckedChange={(next) =>
212
+ setChecked((current) =>
213
+ next
214
+ ? Array.from(new Set([...current, option.value]))
215
+ : current.filter((v) => v !== option.value),
216
+ )
217
+ }
218
+ />
219
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5 text-left">
220
+ <span className="text-sm font-medium">{option.label}</span>
221
+ {option.description && (
222
+ <span className="text-xs text-muted-foreground">
223
+ {option.description}
224
+ </span>
225
+ )}
226
+ </span>
227
+ </label>
228
+ );
229
+ })}
230
+ </div>
231
+ )}
232
+
233
+ <AlertDialogFooter>
234
+ {request.mode !== "alert" && (
235
+ <AlertDialogCancel>{request.cancelLabel ?? text.cancel}</AlertDialogCancel>
236
+ )}
237
+ {request.mode === "choice" ? (
238
+ request.choices?.map((choice) => (
239
+ <AlertDialogAction
240
+ key={choice.value}
241
+ variant={choice.variant ?? "default"}
242
+ onClick={() => settle(choice.value)}
243
+ >
244
+ {choice.label}
245
+ </AlertDialogAction>
246
+ ))
247
+ ) : (
248
+ <AlertDialogAction
249
+ variant={request.destructive ? "destructive" : "default"}
250
+ onClick={() => settle(confirmResult())}
251
+ >
252
+ {request.confirmLabel ?? confirmLabelByMode[request.mode]}
253
+ </AlertDialogAction>
254
+ )}
255
+ </AlertDialogFooter>
256
+ </AlertDialogContent>
257
+ )}
258
+ </AlertDialog>
259
+ </DialogsContext.Provider>
260
+ );
261
+ }
262
+
263
+ /** ใช้ได้ทุกที่ใต้ `DialogsProvider` */
264
+ export function useDialogs(): Dialogs {
265
+ const dialogs = useContext(DialogsContext);
266
+ if (!dialogs) {
267
+ throw new Error("useDialogs() ต้องอยู่ใต้ <DialogsProvider> (ครอบที่ root layout)");
268
+ }
269
+ return dialogs;
270
+ }
@@ -131,7 +131,7 @@ export function FormFieldCombobox<T extends FieldValues>({
131
131
  {options.map((option) => (
132
132
  <CommandItem
133
133
  key={option.value}
134
- value={`${option.label} ${option.subLabel || ""}`}
134
+ value={`${option.label} ${option.subLabel || ""} ${option.value}`}
135
135
  onSelect={() => {
136
136
  field.onChange(option.value === field.value ? "" : option.value);
137
137
  onSelect?.(option);
@@ -128,7 +128,7 @@ function AutocompleteInput<T extends FieldValues>({
128
128
  {data.map((option) => (
129
129
  <CommandItem
130
130
  key={option.value}
131
- value={option.label}
131
+ value={`${option.label} ${option.value}`}
132
132
  onSelect={() => {
133
133
  field.onChange(option.value as PathValue<T, Path<T>>);
134
134
  setOpen(false);
@@ -307,7 +307,13 @@ export function FormFieldInput<T extends FieldValues>({
307
307
 
308
308
  const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
309
309
  const rawValue = e.target.value.replace(/,/g, "");
310
- if (rawValue === "" || rawValue === "-" || rawValue.endsWith(".")) {
310
+ // คงเป็น string ระหว่างพิมพ์ทศนิยมที่ยังไม่จบ ("1.", "1.0", "2.50") — แปลงเป็น number จะทำให้ 0 ท้ายหาย
311
+ if (
312
+ rawValue === "" ||
313
+ rawValue === "-" ||
314
+ rawValue.endsWith(".") ||
315
+ /\.\d*0$/.test(rawValue)
316
+ ) {
311
317
  field.onChange(rawValue as PathValue<T, Path<T>>);
312
318
  return;
313
319
  }
@@ -59,7 +59,7 @@ export function FormFieldRadioCards<T extends FieldValues = any>({
59
59
  <Label
60
60
  htmlFor={id}
61
61
  className={cn(
62
- "flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 transition-all hover:border-primary hover:bg-accent peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5",
62
+ "flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 transition-all hover:border-primary hover:bg-accent peer-data-checked:border-primary peer-data-checked:bg-primary/5",
63
63
  size === "sm" && "min-h-[70px] gap-1 p-2",
64
64
  size === "md" && "min-h-[100px] gap-2 p-4",
65
65
  size === "lg" && "min-h-[120px] gap-4 p-6",
@@ -76,6 +76,11 @@ type FormFieldSelectProps<T extends FieldValues> = BaseProps<T> &
76
76
  }
77
77
  );
78
78
 
79
+ /** ค่าให้ cmdk ระบุ/ค้นหารายการ — ต่อ value ท้าย label กัน label ซ้ำแล้วรายการชนกัน */
80
+ function commandValue(option: SelectOption): string {
81
+ return `${option.searchableValue || option.label} ${option.value}`;
82
+ }
83
+
79
84
  // --- Combobox Internal Logic ---
80
85
  function ComboboxContent<T extends FieldValues>({
81
86
  field,
@@ -106,9 +111,7 @@ function ComboboxContent<T extends FieldValues>({
106
111
  const fieldVal = field.value ?? "";
107
112
  const selectedOption = options.find((option) => option.value === fieldVal);
108
113
  const selectedLabel = selectedOption?.label || placeholder;
109
- const selectedCommandValue = selectedOption
110
- ? selectedOption.searchableValue || selectedOption.label
111
- : undefined;
114
+ const selectedCommandValue = selectedOption ? commandValue(selectedOption) : undefined;
112
115
 
113
116
  return (
114
117
  <Popover open={open} onOpenChange={setOpen} modal={modal}>
@@ -139,7 +142,7 @@ function ComboboxContent<T extends FieldValues>({
139
142
  {options.map((option) => (
140
143
  <CommandItem
141
144
  key={option.value}
142
- value={option.searchableValue || option.label}
145
+ value={commandValue(option)}
143
146
  onSelect={() => {
144
147
  field.onChange(
145
148
  (option.value === fieldVal ? "" : option.value) as PathValue<T, Path<T>>,
@@ -279,7 +282,8 @@ export function FormFieldSelect<T extends FieldValues>({
279
282
  onValueChange={(val) => {
280
283
  if (val !== null) {
281
284
  field.onChange(val);
282
- if (props.variant === "select" && props.onValueChange) {
285
+ // variant ไม่ส่ง = select (ค่าเริ่มต้น) — ต้องเรียก callback ด้วย
286
+ if ("onValueChange" in props && props.onValueChange) {
283
287
  props.onValueChange(val as any);
284
288
  }
285
289
  }
@@ -20,6 +20,13 @@ interface ErrorStateProps {
20
20
  description?: string;
21
21
  /** ซ่อนปุ่มกลับหน้าหลัก เช่นในหน้า auth ที่ผู้ใช้ยังไม่ได้ล็อกอิน */
22
22
  hideHomeButton?: boolean;
23
+ /**
24
+ * endpoint ตรวจเซสชัน (คืน 401 หรือ JSON ที่มี `user`) — ส่งมาเมื่อต้องการให้แยก "เซสชันหมดอายุ"
25
+ * ออกจาก error ทั่วไป (supplychain: `/api/auth/get-session`); ไม่ส่ง = ไม่ตรวจ แสดง error ทั่วไปเสมอ
26
+ */
27
+ sessionCheckUrl?: string;
28
+ /** หน้าเข้าสู่ระบบเมื่อเซสชันหมดอายุ (ค่าเริ่มต้น `/sign-in`) — ต่อ `?redirect_url=` ให้ */
29
+ signInHref?: string;
23
30
  className?: string;
24
31
  }
25
32
 
@@ -43,6 +50,8 @@ export function ErrorState({
43
50
  title = "เกิดข้อผิดพลาด",
44
51
  description = "ระบบไม่สามารถแสดงหน้านี้ได้ในขณะนี้ กรุณาลองใหม่อีกครั้ง หากยังพบปัญหาโปรดแจ้งผู้ดูแลระบบ",
45
52
  hideHomeButton = false,
53
+ sessionCheckUrl,
54
+ signInHref = "/sign-in",
46
55
  className,
47
56
  }: ErrorStateProps) {
48
57
  const [sessionExpired, setSessionExpired] = useState(false);
@@ -53,12 +62,14 @@ export function ErrorState({
53
62
  }, [error]);
54
63
 
55
64
  useEffect(() => {
56
- if (!looksLikeBrokenActionResponse(error)) return;
65
+ // ไม่มี endpoint ให้ตรวจ = ไม่เดา (แอปที่ไม่มี route นี้จะได้ 404 แล้วถูกตีความผิดว่าเซสชันหมดอายุ)
66
+ if (!sessionCheckUrl || !looksLikeBrokenActionResponse(error)) return;
57
67
  let cancelled = false;
58
- fetch("/api/auth/get-session", { credentials: "include", cache: "no-store" })
68
+ fetch(sessionCheckUrl, { credentials: "include", cache: "no-store" })
59
69
  .then(async (response) => {
60
70
  if (cancelled) return;
61
71
  if (response.status === 401) return setSessionExpired(true);
72
+ if (!response.ok) return; // 404/500 = ตรวจไม่ได้ ไม่ใช่เซสชันหมดอายุ
62
73
  const body = await response.json().catch(() => null);
63
74
  if (!body || !(body as { user?: unknown }).user) setSessionExpired(true);
64
75
  })
@@ -68,10 +79,10 @@ export function ErrorState({
68
79
  return () => {
69
80
  cancelled = true;
70
81
  };
71
- }, [error]);
82
+ }, [error, sessionCheckUrl]);
72
83
 
73
84
  if (sessionExpired) {
74
- const signInHref = `/sign-in?redirect_url=${encodeURIComponent(
85
+ const signInUrl = `${signInHref}?redirect_url=${encodeURIComponent(
75
86
  typeof window === "undefined" ? "/" : window.location.href,
76
87
  )}`;
77
88
  return (
@@ -98,7 +109,7 @@ export function ErrorState({
98
109
  <div className="flex justify-center pt-2">
99
110
  {/* full page load เพื่อให้ proxy/เซสชันเริ่มใหม่สะอาด ๆ */}
100
111
  {/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
101
- <a href={signInHref} className={cn(buttonVariants())}>
112
+ <a href={signInUrl} className={cn(buttonVariants())}>
102
113
  <LogIn className="mr-2 h-4 w-4" />
103
114
  เข้าสู่ระบบ
104
115
  </a>
@@ -46,6 +46,8 @@ export function TransitionLink({
46
46
  }
47
47
 
48
48
  if (e.defaultPrevented) return;
49
+ // Ctrl/⌘/Shift+click หรือคลิกกลาง = ผู้ใช้ต้องการเปิดแท็บ/หน้าต่างใหม่ → ปล่อยให้ browser จัดการ
50
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
49
51
 
50
52
  e.preventDefault();
51
53
  startTransition(() => {
@@ -9,8 +9,11 @@ import {
9
9
  DropdownMenuTrigger,
10
10
  } from "@suphark/ui/components/ui/dropdown-menu";
11
11
  import { MoreHorizontalIcon } from "lucide-react";
12
+ import Link from "next/link";
12
13
  import { useRouter } from "next/navigation";
13
14
  import { useState, useTransition } from "react";
15
+ import { toast } from "sonner";
16
+ import { errorMessage } from "./button/confirm-action-button";
14
17
  import { ConfirmDialog, type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
15
18
  import { useUiText } from "./ui-text";
16
19
 
@@ -55,7 +58,12 @@ export function RowActions({ items, label }: { items: RowAction[]; label?: strin
55
58
  for (const [k, v] of Object.entries(it.data ?? {})) fd.set(k, v);
56
59
  const act = it.action;
57
60
  start(async () => {
58
- await act(fd);
61
+ try {
62
+ await act(fd);
63
+ } catch (err) {
64
+ toast.error(errorMessage(err, text.actionFailed ?? "ดำเนินการไม่สำเร็จ"));
65
+ return;
66
+ }
59
67
  router.refresh();
60
68
  });
61
69
  }
@@ -87,10 +95,12 @@ export function RowActions({ items, label }: { items: RowAction[]; label?: strin
87
95
  key={it.label}
88
96
  variant={it.destructive ? "destructive" : "default"}
89
97
  disabled={it.disabled}
98
+ // ลิงก์ที่ไม่ต้องยืนยัน render เป็น <a> จริง → Ctrl/⌘+click, คลิกกลาง, เปิดแท็บใหม่ได้
99
+ render={it.href && !it.confirm ? <Link href={it.href} /> : undefined}
90
100
  onClick={() => {
91
101
  setOpen(false);
92
102
  if (it.confirm) setConfirming(it);
93
- else run(it);
103
+ else if (!it.href) run(it);
94
104
  }}
95
105
  >
96
106
  {it.label}
@@ -18,6 +18,18 @@ export interface UiText {
18
18
  confirm?: string;
19
19
  /** ConfirmDialog: หัวกล่อง (optional — ค่าเริ่มต้น "ยืนยันการทำรายการ") */
20
20
  confirmTitle?: string;
21
+ /** RowActions/ConfirmActionButton: action โยน error (optional — ค่าเริ่มต้น "ดำเนินการไม่สำเร็จ") */
22
+ actionFailed?: string;
23
+ /** DialogsProvider: ปุ่มปิดของ alert (optional — ค่าเริ่มต้น "ตกลง") */
24
+ ok?: string;
25
+ /** DialogsProvider: หัวกล่อง alert (optional — ค่าเริ่มต้น "แจ้งให้ทราบ") */
26
+ alertTitle?: string;
27
+ /** DialogsProvider: หัวกล่อง prompt (optional — ค่าเริ่มต้น "กรอกข้อมูล") */
28
+ promptTitle?: string;
29
+ /** DialogsProvider: หัวกล่อง choose (optional — ค่าเริ่มต้น "เลือกการทำรายการ") */
30
+ chooseTitle?: string;
31
+ /** DialogsProvider: หัวกล่อง checklist (optional — ค่าเริ่มต้น "เลือกรายการ") */
32
+ checklistTitle?: string;
21
33
  }
22
34
 
23
35
  export const UI_TEXT_TH: UiText = {
@@ -32,6 +44,12 @@ export const UI_TEXT_TH: UiText = {
32
44
  copyFailed: "คัดลอกอัตโนมัติไม่ได้ — เลือกข้อความให้แล้ว กด Ctrl+C / ⌘C",
33
45
  confirm: "ยืนยัน",
34
46
  confirmTitle: "ยืนยันการทำรายการ",
47
+ actionFailed: "ดำเนินการไม่สำเร็จ",
48
+ ok: "ตกลง",
49
+ alertTitle: "แจ้งให้ทราบ",
50
+ promptTitle: "กรอกข้อมูล",
51
+ chooseTitle: "เลือกการทำรายการ",
52
+ checklistTitle: "เลือกรายการ",
35
53
  };
36
54
 
37
55
  export const UI_TEXT_EN: UiText = {
@@ -46,6 +64,12 @@ export const UI_TEXT_EN: UiText = {
46
64
  copyFailed: "Automatic copy is blocked — text selected, press Ctrl+C / ⌘C",
47
65
  confirm: "Confirm",
48
66
  confirmTitle: "Please confirm",
67
+ actionFailed: "Action failed",
68
+ ok: "OK",
69
+ alertTitle: "Notice",
70
+ promptTitle: "Input required",
71
+ chooseTitle: "Choose an action",
72
+ checklistTitle: "Choose options",
49
73
  };
50
74
 
51
75
  const UiTextContext = createContext<UiText>(UI_TEXT_TH);
@@ -2,39 +2,69 @@
2
2
 
3
3
  import { formatRelativeThaiDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
4
4
  import { Calendar } from "lucide-react";
5
+ import { useSyncExternalStore } from "react";
5
6
 
6
7
  interface RelativeDateProps {
7
8
  date: Date | string | number | null | undefined;
8
9
  showIcon?: boolean;
9
10
  className?: string;
10
11
  iconClassName?: string;
12
+ /** ข้อความนำหน้าใน tooltip (ค่าเริ่มต้น "วันที่") เช่น "วันที่อ้างอิงราคา" */
13
+ titlePrefix?: string;
14
+ /** ข้อความเมื่อไม่มีวันที่ */
15
+ emptyText?: string;
11
16
  }
12
17
 
18
+ // true เฉพาะฝั่ง client หลัง hydrate — server/ตอน hydrate ได้ false เสมอ
19
+ const subscribeNoop = () => () => {};
20
+ const useMounted = () =>
21
+ useSyncExternalStore(
22
+ subscribeNoop,
23
+ () => true,
24
+ () => false,
25
+ );
26
+
27
+ /**
28
+ * วันที่แบบสัมพัทธ์ภาษาไทย ("วันนี้", "เมื่อวาน", "3 วันที่ผ่านมา") + วันที่เต็มใน tooltip
29
+ * คำนวณเฉพาะฝั่ง client หลัง mount: "ตอนนี้" ของ server (UTC, module โหลดไว้ก่อน) กับของ browser
30
+ * ต่างกันได้ทั้งโซนเวลาและข้ามวัน → ถ้า render บน server จะ hydration mismatch (เจอจริงบน Vercel)
31
+ */
13
32
  export function RelativeDate({
14
33
  date,
15
34
  showIcon = true,
16
- className = "flex items-center gap-0.5 text-slate-600 font-medium",
17
- iconClassName = "h-2.5 w-2.5 text-slate-400 shrink-0",
35
+ className = "flex items-center gap-0.5 font-medium text-muted-foreground",
36
+ iconClassName = "h-2.5 w-2.5 shrink-0 text-muted-foreground/60",
37
+ titlePrefix = "วันที่",
38
+ emptyText = "ไม่ได้ระบุ",
18
39
  }: RelativeDateProps) {
40
+ const mounted = useMounted();
41
+
19
42
  if (!date) {
20
43
  return (
21
44
  <span
22
- className="flex items-center gap-0.5 font-normal text-slate-400 text-xs italic"
23
- title="ยังไม่ได้ระบุวันที่อ้างอิงราคา (Price Reference Date)"
45
+ className="flex items-center gap-0.5 font-normal text-muted-foreground text-xs italic"
46
+ title={`${titlePrefix}: ${emptyText}`}
24
47
  >
25
48
  {showIcon && <Calendar className={iconClassName} />}
26
- ไม่ได้ระบุ
49
+ {emptyText}
27
50
  </span>
28
51
  );
29
52
  }
30
53
 
31
- const relativeText = formatRelativeThaiDate(date);
32
- const fullText = formatThaiFullDate(date);
54
+ // ก่อน mount แสดงตัวยึดที่ไม่ขึ้นกับเวลา (กัน layout กระโดดน้อยที่สุด)
55
+ if (!mounted) {
56
+ return (
57
+ <span className={className} aria-hidden="true">
58
+ {showIcon && <Calendar className={iconClassName} />}
59
+ &nbsp;
60
+ </span>
61
+ );
62
+ }
33
63
 
34
64
  return (
35
- <span className={className} title={`วันที่อ้างอิงราคาจริง: ${fullText}`}>
65
+ <span className={className} title={`${titlePrefix}: ${formatThaiFullDate(date)}`}>
36
66
  {showIcon && <Calendar className={iconClassName} />}
37
- {relativeText}
67
+ {formatRelativeThaiDate(date)}
38
68
  </span>
39
69
  );
40
70
  }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { type Dispatch, type SetStateAction, useCallback, useRef, useState } from "react";
4
+
5
+ /**
6
+ * `useState` + getter ที่อัปเดตทันที (synchronous) — สำหรับ optimistic UI
7
+ *
8
+ * callback ที่คำนวณ "รายการถัดไป" จากรายการปัจจุบันห้ามอ่านค่าตอน render:
9
+ * mutation สองครั้งใน tick เดียว (drag-and-drop, automation, bulk action) จะเริ่มจาก
10
+ * รายการเก่าทั้งคู่ และครั้งที่สองทับครั้งแรกเงียบ ๆ — `getLatest()` คืนค่าจาก `set` ครั้งล่าสุดเสมอ
11
+ * แม้ React ยังไม่ commit
12
+ *
13
+ * const [tasks, setTasks, getLatestTasks] = useTrackedState<Task[]>([]);
14
+ * function move(id: string, to: string) {
15
+ * const current = getLatestTasks(); // ไม่ใช่ `tasks`
16
+ * setTasks(current.map((t) => (t.id === id ? { ...t, section: to } : t)));
17
+ * }
18
+ */
19
+ export function useTrackedState<T>(
20
+ initial: T | (() => T),
21
+ ): readonly [T, Dispatch<SetStateAction<T>>, () => T] {
22
+ const [state, setState] = useState(initial);
23
+ const latestRef = useRef(state);
24
+
25
+ const set = useCallback<Dispatch<SetStateAction<T>>>((update) => {
26
+ const next =
27
+ typeof update === "function" ? (update as (previous: T) => T)(latestRef.current) : update;
28
+ latestRef.current = next;
29
+ setState(next);
30
+ }, []);
31
+
32
+ const getLatest = useCallback(() => latestRef.current, []);
33
+
34
+ return [state, set, getLatest] as const;
35
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,17 @@ export {
24
24
  export { DataList, DataRow } from "./components/shared/data-list";
25
25
  export { DateRangePicker } from "./components/shared/date/date-range-picker";
26
26
  export { DescriptionList } from "./components/shared/description-list";
27
+ export {
28
+ type AlertSpec,
29
+ type ChecklistOption,
30
+ type ChecklistSpec,
31
+ type ChoiceOption,
32
+ type ChoiceSpec,
33
+ type Dialogs,
34
+ DialogsProvider,
35
+ type PromptSpec,
36
+ useDialogs,
37
+ } from "./components/shared/dialogs-provider";
27
38
  export { EmptyRow } from "./components/shared/empty-row";
28
39
  export { EmptyState } from "./components/shared/empty-state";
29
40
  export { EnvSnippet, randomSecret } from "./components/shared/env-snippet";
@@ -145,6 +156,7 @@ export * from "./components/ui/tooltip";
145
156
  export { useConfirm } from "./hooks/use-confirm";
146
157
  export { useCopyToClipboard } from "./hooks/use-copy-to-clipboard";
147
158
  export { useIsMobile } from "./hooks/use-mobile";
159
+ export { useTrackedState } from "./hooks/use-tracked-state";
148
160
  export { copyText, selectContents } from "./lib/copy-text";
149
161
  export { formatRelativeThaiDate, formatThaiFullDate } from "./lib/formatters";
150
162
  export {