@suphark/ui 0.4.3 → 0.5.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 +38 -0
- package/README.md +25 -0
- package/package.json +3 -1
- package/src/components/app-shell/app-nav.tsx +1 -1
- package/src/components/app-shell/app-shell.tsx +31 -5
- package/src/components/app-shell/header-breadcrumbs.tsx +2 -1
- package/src/components/app-shell/header-crumb.tsx +1 -1
- package/src/components/app-shell/index.ts +1 -1
- package/src/components/app-shell/sub-nav.tsx +3 -3
- package/src/components/app-shell/top-tabs.tsx +1 -1
- package/src/components/app-shell/user-menu.tsx +1 -1
- package/src/components/data-table/data-table-column-header.tsx +1 -1
- package/src/components/data-table/data-table-row-actions.tsx +1 -3
- package/src/components/data-table/use-data-table.ts +4 -1
- package/src/components/design-system/design-system-showcase.tsx +1 -1
- package/src/components/pwa/install-prompt-store.ts +26 -0
- package/src/components/pwa/pull-to-refresh.tsx +147 -0
- package/src/components/pwa/pwa-install-card.tsx +165 -0
- package/src/components/pwa/pwa-service-worker-registration.tsx +63 -0
- package/src/components/pwa/service-worker-updates.ts +76 -0
- package/src/components/shared/button/confirm-action-button.tsx +15 -1
- package/src/components/shared/button/delete-many-button.tsx +7 -3
- package/src/components/shared/button/refresh-button.tsx +2 -1
- package/src/components/shared/combobox.tsx +2 -1
- package/src/components/shared/confirm-dialog.tsx +1 -0
- package/src/components/shared/dialogs-provider.tsx +270 -0
- package/src/components/shared/form/form-field-combobox.tsx +1 -1
- package/src/components/shared/form/form-field-input.tsx +8 -2
- package/src/components/shared/form/form-field-radio-cards.tsx +1 -1
- package/src/components/shared/form/form-field-select.tsx +9 -5
- package/src/components/shared/layout/error-state.tsx +16 -5
- package/src/components/shared/link/transition-link.tsx +2 -0
- package/src/components/shared/row-actions.tsx +12 -2
- package/src/components/shared/ui-text.tsx +24 -0
- package/src/hooks/use-tracked-state.ts +35 -0
- package/src/index.ts +12 -0
- package/src/kanban.ts +9 -0
- package/src/lib/formatters.ts +5 -3
- package/src/lib/kanban-collision.ts +118 -0
- package/src/pwa.ts +24 -0
- 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
|
-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
// ไม่มี endpoint ให้ตรวจ = ไม่เดา (แอปที่ไม่มี route นี้จะได้ 404 แล้วถูกตีความผิดว่าเซสชันหมดอายุ)
|
|
66
|
+
if (!sessionCheckUrl || !looksLikeBrokenActionResponse(error)) return;
|
|
57
67
|
let cancelled = false;
|
|
58
|
-
fetch(
|
|
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
|
|
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={
|
|
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
|
-
|
|
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);
|
|
@@ -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 {
|
package/src/kanban.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @suphark/ui/kanban — ตัวช่วยบอร์ดหลายคอลัมน์บน dnd-kit
|
|
3
|
+
* แยกจาก barrel หลักเพราะ @dnd-kit/core สร้าง React context ตอน import (ใช้จาก server component ไม่ได้) เช่นเดียวกับ data-table
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
createKanbanCollisionDetection,
|
|
7
|
+
type KanbanCollisionOptions,
|
|
8
|
+
type KanbanColumnItems,
|
|
9
|
+
} from "./lib/kanban-collision";
|
package/src/lib/formatters.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { differenceInCalendarDays, formatDistanceToNow } from "date-fns";
|
|
2
2
|
import { th } from "date-fns/locale";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -6,15 +6,17 @@ import { th } from "date-fns/locale";
|
|
|
6
6
|
* ยกมาจาก supplychain `lib/utils/formatters.ts` เฉพาะส่วนที่ไม่ผูก domain
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
/** วันที่แบบสัมพัทธ์ภาษาไทย เช่น "วันนี้", "เมื่อวาน", "
|
|
9
|
+
/** วันที่แบบสัมพัทธ์ภาษาไทย เช่น "วันนี้", "เมื่อวาน", "พรุ่งนี้", "10 วันที่ผ่านมา", "ใน 2 เดือน" */
|
|
10
10
|
export function formatRelativeThaiDate(date: Date | string | number | null | undefined): string {
|
|
11
11
|
if (!date) return "ไม่ได้ระบุ";
|
|
12
12
|
const d = new Date(date);
|
|
13
13
|
if (Number.isNaN(d.getTime())) return "ไม่ได้ระบุ";
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// เทียบเป็นวันปฏิทิน (ไม่ใช่ 24 ชม.) และคงเครื่องหมาย — อนาคต 1 วันคือ "พรุ่งนี้" ไม่ใช่ "เมื่อวาน"
|
|
16
|
+
const diffDays = differenceInCalendarDays(new Date(), d);
|
|
16
17
|
if (diffDays === 0) return "วันนี้";
|
|
17
18
|
if (diffDays === 1) return "เมื่อวาน";
|
|
19
|
+
if (diffDays === -1) return "พรุ่งนี้";
|
|
18
20
|
|
|
19
21
|
return formatDistanceToNow(d, { addSuffix: true, locale: th });
|
|
20
22
|
}
|