@suphark/ui 0.1.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 +36 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/components.json +21 -0
- package/package.json +108 -0
- package/scripts/audit-base-ui-props.mjs +179 -0
- package/scripts/audit-tailwind-typography.mjs +100 -0
- package/src/components/design-system/design-system-showcase.tsx +138 -0
- package/src/components/design-system/sections/showcase-data-display.tsx +454 -0
- package/src/components/design-system/sections/showcase-feedback.tsx +273 -0
- package/src/components/design-system/sections/showcase-forms.tsx +373 -0
- package/src/components/design-system/sections/showcase-foundations.tsx +384 -0
- package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +397 -0
- package/src/components/design-system/sections/showcase-overlays-navigation.tsx +479 -0
- package/src/components/design-system/sections/showcase-overview.tsx +368 -0
- package/src/components/design-system/sections/showcase-primitives.tsx +463 -0
- package/src/components/shared/__tests__/multi-select-combobox.test.tsx +180 -0
- package/src/components/shared/__tests__/name-id-fields.test.ts +24 -0
- package/src/components/shared/button/add-new-button.tsx +19 -0
- package/src/components/shared/button/base/action-button.tsx +134 -0
- package/src/components/shared/button/base/link-button.tsx +130 -0
- package/src/components/shared/button/cancel-button.tsx +27 -0
- package/src/components/shared/button/confirm-button.tsx +26 -0
- package/src/components/shared/button/delete-button.tsx +36 -0
- package/src/components/shared/button/floating-save-button.tsx +89 -0
- package/src/components/shared/button/index.ts +13 -0
- package/src/components/shared/button/print-button.tsx +67 -0
- package/src/components/shared/button/refresh-button.tsx +63 -0
- package/src/components/shared/button/save-button.tsx +29 -0
- package/src/components/shared/button/submit-button.tsx +58 -0
- package/src/components/shared/client-redirect.tsx +11 -0
- package/src/components/shared/combobox.tsx +116 -0
- package/src/components/shared/data-list.tsx +65 -0
- package/src/components/shared/date/date-range-picker.tsx +106 -0
- package/src/components/shared/description-list.tsx +27 -0
- package/src/components/shared/empty-row.tsx +13 -0
- package/src/components/shared/empty-state.tsx +31 -0
- package/src/components/shared/env-snippet.tsx +62 -0
- package/src/components/shared/form/__tests__/simple-select.test.tsx +72 -0
- package/src/components/shared/form/form-field-boolean.tsx +93 -0
- package/src/components/shared/form/form-field-color-select.tsx +86 -0
- package/src/components/shared/form/form-field-combobox.tsx +167 -0
- package/src/components/shared/form/form-field-date.tsx +55 -0
- package/src/components/shared/form/form-field-formatted-number.tsx +103 -0
- package/src/components/shared/form/form-field-input.tsx +372 -0
- package/src/components/shared/form/form-field-radio-cards.tsx +127 -0
- package/src/components/shared/form/form-field-radio-group.tsx +56 -0
- package/src/components/shared/form/form-field-select.tsx +312 -0
- package/src/components/shared/form/simple-select.tsx +152 -0
- package/src/components/shared/form-dialog.tsx +109 -0
- package/src/components/shared/form-message.tsx +30 -0
- package/src/components/shared/initials.tsx +33 -0
- package/src/components/shared/layout/error-state.tsx +160 -0
- package/src/components/shared/layout/page-shell.tsx +57 -0
- package/src/components/shared/link/transition-link.tsx +72 -0
- package/src/components/shared/multi-select-combobox.tsx +392 -0
- package/src/components/shared/name-id-fields.tsx +133 -0
- package/src/components/shared/navigation/page-breadcrumb.tsx +100 -0
- package/src/components/shared/notice.tsx +35 -0
- package/src/components/shared/number/formatted-number.tsx +133 -0
- package/src/components/shared/page-header.tsx +25 -0
- package/src/components/shared/page-skeleton.tsx +18 -0
- package/src/components/shared/row-actions.tsx +93 -0
- package/src/components/shared/row-menu.tsx +55 -0
- package/src/components/shared/secret-reveal.tsx +53 -0
- package/src/components/shared/section-card.tsx +44 -0
- package/src/components/shared/stat.tsx +26 -0
- package/src/components/shared/status-badge.tsx +47 -0
- package/src/components/shared/text/primary-secondary-text.tsx +186 -0
- package/src/components/shared/theme/index.ts +4 -0
- package/src/components/shared/theme/theme-menu-items.tsx +43 -0
- package/src/components/shared/theme/theme-provider.tsx +39 -0
- package/src/components/shared/theme/theme-switch.tsx +36 -0
- package/src/components/shared/theme/theme-toggle.tsx +76 -0
- package/src/components/shared/time/client-date-formatter.tsx +188 -0
- package/src/components/shared/ui-text.tsx +47 -0
- package/src/components/ui/__tests__/combobox.test.tsx +40 -0
- package/src/components/ui/__tests__/date-picker.test.tsx +53 -0
- package/src/components/ui/__tests__/slider.test.tsx +18 -0
- package/src/components/ui/__tests__/tabs.test.tsx +33 -0
- package/src/components/ui/accordion.tsx +69 -0
- package/src/components/ui/alert-dialog.tsx +161 -0
- package/src/components/ui/alert.tsx +68 -0
- package/src/components/ui/aspect-ratio.tsx +22 -0
- package/src/components/ui/attachment.tsx +194 -0
- package/src/components/ui/avatar.tsx +92 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/breadcrumb.tsx +102 -0
- package/src/components/ui/button-group.tsx +77 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/calendar.tsx +184 -0
- package/src/components/ui/card.tsx +87 -0
- package/src/components/ui/carousel.tsx +230 -0
- package/src/components/ui/chart.tsx +338 -0
- package/src/components/ui/checkbox.tsx +27 -0
- package/src/components/ui/collapsible.tsx +17 -0
- package/src/components/ui/combobox.tsx +272 -0
- package/src/components/ui/command.tsx +180 -0
- package/src/components/ui/context-menu.tsx +244 -0
- package/src/components/ui/date-picker.tsx +308 -0
- package/src/components/ui/dialog.tsx +137 -0
- package/src/components/ui/drawer.tsx +208 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/empty.tsx +93 -0
- package/src/components/ui/field.tsx +223 -0
- package/src/components/ui/form.tsx +161 -0
- package/src/components/ui/hover-card.tsx +46 -0
- package/src/components/ui/input-group.tsx +145 -0
- package/src/components/ui/input-otp.tsx +85 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/item.tsx +187 -0
- package/src/components/ui/kbd.tsx +26 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/menubar.tsx +266 -0
- package/src/components/ui/navigation-menu.tsx +160 -0
- package/src/components/ui/pagination.tsx +117 -0
- package/src/components/ui/popover.tsx +76 -0
- package/src/components/ui/progress.tsx +66 -0
- package/src/components/ui/radio-group.tsx +38 -0
- package/src/components/ui/relative-date.tsx +40 -0
- package/src/components/ui/resizable.tsx +41 -0
- package/src/components/ui/scroll-area.tsx +67 -0
- package/src/components/ui/searchable-select.tsx +124 -0
- package/src/components/ui/select.tsx +189 -0
- package/src/components/ui/separator.tsx +21 -0
- package/src/components/ui/sheet.tsx +124 -0
- package/src/components/ui/sidebar.tsx +691 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +55 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/table.tsx +95 -0
- package/src/components/ui/tabs.tsx +79 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/toggle-group.tsx +86 -0
- package/src/components/ui/toggle.tsx +44 -0
- package/src/components/ui/tooltip.tsx +54 -0
- package/src/hooks/use-copy-to-clipboard.ts +27 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/index.ts +156 -0
- package/src/lib/formatters.ts +44 -0
- package/src/lib/permission.ts +75 -0
- package/src/lib/status-colors.ts +52 -0
- package/src/lib/utils.ts +6 -0
- package/src/next.ts +15 -0
- package/src/styles.css +214 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Field, FieldDescription, FieldLabel } from "@suphark/ui/components/ui/field";
|
|
4
|
+
import { Input } from "@suphark/ui/components/ui/input";
|
|
5
|
+
import { type ReactNode, useId, useState } from "react";
|
|
6
|
+
|
|
7
|
+
/** รูปแบบตัวระบุที่ derive จากชื่อ: kebab (slug/code/key), camel (attribute key), upper (รหัสตำแหน่ง) */
|
|
8
|
+
export type IdentifierStyle = "kebab" | "camel" | "upper";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* แปลงชื่อเป็นตัวระบุมาตรฐาน (เหมือน Clerk: พิมพ์ชื่อแล้ว slug ขึ้นให้เอง) — ตัดเครื่องหมายกำกับ, เก็บเฉพาะ a-z 0-9,
|
|
12
|
+
* แบ่งคำที่ช่องว่าง/เครื่องหมาย; ตัวอักษรที่ไม่ใช่ละติน (เช่นไทย) ตัดทิ้ง → อาจได้ค่าว่าง = ให้ผู้ใช้ตั้งเอง
|
|
13
|
+
*/
|
|
14
|
+
export function toIdentifier(name: string, style: IdentifierStyle, maxLength = 64): string {
|
|
15
|
+
const words = name
|
|
16
|
+
.normalize("NFKD")
|
|
17
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
18
|
+
.replace(/[^A-Za-z0-9]+/g, " ")
|
|
19
|
+
.trim()
|
|
20
|
+
.split(/\s+/)
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map((w) => w.toLowerCase());
|
|
23
|
+
if (words.length === 0) return "";
|
|
24
|
+
let out: string;
|
|
25
|
+
if (style === "camel")
|
|
26
|
+
out = words.map((w, i) => (i === 0 ? w : w[0]!.toUpperCase() + w.slice(1))).join("");
|
|
27
|
+
else if (style === "upper") out = words.join("_").toUpperCase();
|
|
28
|
+
else out = words.join("-");
|
|
29
|
+
// camel ต้องขึ้นต้นด้วยตัวอักษร; kebab/upper ห้ามขึ้นต้น/ลงท้ายด้วยเครื่องหมาย (ตัดความยาวแล้วอาจเหลือ "-" ท้าย)
|
|
30
|
+
out = out.slice(0, maxLength).replace(/[-_]+$/, "");
|
|
31
|
+
if (style === "camel" && /^[0-9]/.test(out)) out = `x${out}`;
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* คู่ช่อง "ชื่อ" + ตัวระบุ (slug/code/key) แบบ Clerk: พิมพ์ชื่อแล้วตัวระบุขึ้นให้อัตโนมัติ แก้เองได้
|
|
37
|
+
* เมื่อแก้เองแล้วจะไม่ถูกทับอีก (ลบจนว่าง = กลับไป auto); มีปุ่ม "ใช้ตามชื่อ" เพื่อกลับไปค่า derive
|
|
38
|
+
* ส่งค่าเป็น input ธรรมดาชื่อ `nameField`/`idField` — ใช้ในฟอร์ม server action ได้เลย
|
|
39
|
+
*/
|
|
40
|
+
export function NameIdFields({
|
|
41
|
+
nameField = "name",
|
|
42
|
+
idField,
|
|
43
|
+
style = "kebab",
|
|
44
|
+
nameLabel,
|
|
45
|
+
namePlaceholder,
|
|
46
|
+
idLabel,
|
|
47
|
+
idHint,
|
|
48
|
+
idPlaceholder,
|
|
49
|
+
idPattern,
|
|
50
|
+
idMaxLength = 64,
|
|
51
|
+
resetLabel,
|
|
52
|
+
autoFocus = true,
|
|
53
|
+
defaultName = "",
|
|
54
|
+
defaultId = "",
|
|
55
|
+
}: {
|
|
56
|
+
nameField?: string;
|
|
57
|
+
idField: string;
|
|
58
|
+
style?: IdentifierStyle;
|
|
59
|
+
nameLabel: ReactNode;
|
|
60
|
+
namePlaceholder?: string;
|
|
61
|
+
idLabel: ReactNode;
|
|
62
|
+
idHint?: ReactNode;
|
|
63
|
+
idPlaceholder?: string;
|
|
64
|
+
/** regex สำหรับ native validation ของ browser (ตรงกับ zod ฝั่ง server) */
|
|
65
|
+
idPattern?: string;
|
|
66
|
+
idMaxLength?: number;
|
|
67
|
+
/** ข้อความปุ่มกลับไปใช้ค่าจากชื่อ (แสดงเมื่อแก้เอง) */
|
|
68
|
+
resetLabel: ReactNode;
|
|
69
|
+
autoFocus?: boolean;
|
|
70
|
+
defaultName?: string;
|
|
71
|
+
defaultId?: string;
|
|
72
|
+
}) {
|
|
73
|
+
const [name, setName] = useState(defaultName);
|
|
74
|
+
const [id, setId] = useState(defaultId || toIdentifier(defaultName, style, idMaxLength));
|
|
75
|
+
const [manual, setManual] = useState(Boolean(defaultId));
|
|
76
|
+
const derived = toIdentifier(name, style, idMaxLength);
|
|
77
|
+
const nameId = useId();
|
|
78
|
+
const idId = useId();
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<Field>
|
|
82
|
+
<FieldLabel htmlFor={nameId}>{nameLabel}</FieldLabel>
|
|
83
|
+
<Input
|
|
84
|
+
id={nameId}
|
|
85
|
+
name={nameField}
|
|
86
|
+
required
|
|
87
|
+
placeholder={namePlaceholder}
|
|
88
|
+
autoFocus={autoFocus}
|
|
89
|
+
value={name}
|
|
90
|
+
onChange={(e) => {
|
|
91
|
+
setName(e.target.value);
|
|
92
|
+
if (!manual) setId(toIdentifier(e.target.value, style, idMaxLength));
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
</Field>
|
|
96
|
+
<Field>
|
|
97
|
+
<FieldLabel htmlFor={idId}>{idLabel}</FieldLabel>
|
|
98
|
+
<Input
|
|
99
|
+
id={idId}
|
|
100
|
+
name={idField}
|
|
101
|
+
required
|
|
102
|
+
placeholder={idPlaceholder}
|
|
103
|
+
pattern={idPattern}
|
|
104
|
+
maxLength={idMaxLength}
|
|
105
|
+
value={id}
|
|
106
|
+
className="font-mono"
|
|
107
|
+
onChange={(e) => {
|
|
108
|
+
setId(e.target.value);
|
|
109
|
+
// ลบจนว่าง = กลับไปโหมด auto (จะเติมให้ใหม่เมื่อแก้ชื่อครั้งถัดไป)
|
|
110
|
+
setManual(e.target.value !== "");
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
{(idHint || (manual && derived && derived !== id)) && (
|
|
114
|
+
<FieldDescription className="flex flex-wrap items-center gap-x-2">
|
|
115
|
+
{idHint}
|
|
116
|
+
{manual && derived && derived !== id && (
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className="underline underline-offset-4 hover:text-foreground"
|
|
120
|
+
onClick={() => {
|
|
121
|
+
setManual(false);
|
|
122
|
+
setId(derived);
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{resetLabel}
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
128
|
+
</FieldDescription>
|
|
129
|
+
)}
|
|
130
|
+
</Field>
|
|
131
|
+
</>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Breadcrumb,
|
|
3
|
+
BreadcrumbItem,
|
|
4
|
+
BreadcrumbLink,
|
|
5
|
+
BreadcrumbList,
|
|
6
|
+
BreadcrumbPage,
|
|
7
|
+
BreadcrumbSeparator,
|
|
8
|
+
} from "@suphark/ui/components/ui/breadcrumb";
|
|
9
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
10
|
+
import Link from "next/link";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Represents a single item in the breadcrumb trail.
|
|
15
|
+
*/
|
|
16
|
+
export type PageBreadcrumbItem = {
|
|
17
|
+
/**
|
|
18
|
+
* The text to display for the breadcrumb item.
|
|
19
|
+
*/
|
|
20
|
+
label: string;
|
|
21
|
+
/**
|
|
22
|
+
* The URL to link to. If not provided, this item will be rendered
|
|
23
|
+
* as the current, non-clickable page (`<BreadcrumbPage>`).
|
|
24
|
+
*/
|
|
25
|
+
href?: string;
|
|
26
|
+
/**
|
|
27
|
+
* An optional icon element to display before the label.
|
|
28
|
+
*/
|
|
29
|
+
icon?: React.ReactNode;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Props for the PageBreadcrumb component.
|
|
34
|
+
*/
|
|
35
|
+
type PageBreadcrumbProps = {
|
|
36
|
+
/**
|
|
37
|
+
* An array of breadcrumb items to display.
|
|
38
|
+
*/
|
|
39
|
+
items: PageBreadcrumbItem[];
|
|
40
|
+
/**
|
|
41
|
+
* Optional CSS class(es) to apply to the root <Breadcrumb> component.
|
|
42
|
+
*/
|
|
43
|
+
className?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A shared component that renders a breadcrumb trail based on a list of items.
|
|
48
|
+
* It automatically handles rendering items as links or as the current page.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* const items = [
|
|
52
|
+
* { label: "Home", href: "/" },
|
|
53
|
+
* { label: "Projects", href: "/projects" },
|
|
54
|
+
* { label: "My Project", icon: <Briefcase /> },
|
|
55
|
+
* ];
|
|
56
|
+
* <PageBreadcrumb items={items} />
|
|
57
|
+
*/
|
|
58
|
+
export function PageBreadcrumb({ items, className }: PageBreadcrumbProps) {
|
|
59
|
+
return (
|
|
60
|
+
<Breadcrumb className={cn("print:hidden", className)}>
|
|
61
|
+
<BreadcrumbList>
|
|
62
|
+
{items.map((item, index) => (
|
|
63
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: ลำดับ breadcrumb คงที่ ไม่ reorder — label อาจซ้ำจึงใช้ตำแหน่งร่วม
|
|
64
|
+
<React.Fragment key={`${item.label}-${index}`}>
|
|
65
|
+
<BreadcrumbItem>
|
|
66
|
+
{item.href ? (
|
|
67
|
+
// Item is a clickable link
|
|
68
|
+
<BreadcrumbLink
|
|
69
|
+
render={
|
|
70
|
+
<Link
|
|
71
|
+
href={item.href}
|
|
72
|
+
// Add flex display for icon alignment
|
|
73
|
+
className="flex items-center gap-1.5"
|
|
74
|
+
/>
|
|
75
|
+
}
|
|
76
|
+
>
|
|
77
|
+
{item.icon}
|
|
78
|
+
{item.label}
|
|
79
|
+
</BreadcrumbLink>
|
|
80
|
+
) : (
|
|
81
|
+
// Item is the current, non-clickable page
|
|
82
|
+
<BreadcrumbPage
|
|
83
|
+
// Add flex display for icon alignment
|
|
84
|
+
className="flex items-center gap-1.5"
|
|
85
|
+
>
|
|
86
|
+
{item.icon}
|
|
87
|
+
{item.label}
|
|
88
|
+
</BreadcrumbPage>
|
|
89
|
+
)}
|
|
90
|
+
</BreadcrumbItem>
|
|
91
|
+
{/* Add a separator if it's not the last item */}
|
|
92
|
+
{index < items.length - 1 && <BreadcrumbSeparator />}
|
|
93
|
+
</React.Fragment>
|
|
94
|
+
))}
|
|
95
|
+
</BreadcrumbList>
|
|
96
|
+
</Breadcrumb>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// export default PageBreadcrumb;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Alert, AlertDescription, AlertTitle } from "@suphark/ui/components/ui/alert";
|
|
2
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
3
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
4
|
+
import type { Tone } from "./status-badge";
|
|
5
|
+
|
|
6
|
+
export type NoticeTone = Exclude<Tone, "neutral">;
|
|
7
|
+
|
|
8
|
+
const TONE: Record<NoticeTone, string> = {
|
|
9
|
+
info: "border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-100",
|
|
10
|
+
success:
|
|
11
|
+
"border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100",
|
|
12
|
+
warning:
|
|
13
|
+
"border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100",
|
|
14
|
+
danger:
|
|
15
|
+
"border-red-200 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Alert ของ shadcn + สีตามความหมาย (info/success/warning/danger) */
|
|
19
|
+
export function Notice({
|
|
20
|
+
tone = "info",
|
|
21
|
+
title,
|
|
22
|
+
children,
|
|
23
|
+
className,
|
|
24
|
+
...props
|
|
25
|
+
}: Omit<ComponentProps<typeof Alert>, "variant" | "title"> & {
|
|
26
|
+
tone?: NoticeTone;
|
|
27
|
+
title?: ReactNode;
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<Alert className={cn(TONE[tone], className)} {...props}>
|
|
31
|
+
{title && <AlertTitle>{title}</AlertTitle>}
|
|
32
|
+
<AlertDescription className="text-inherit">{children}</AlertDescription>
|
|
33
|
+
</Alert>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/** ค่าทศนิยมแบบ object (เช่น Prisma.Decimal, decimal.js) — ขอแค่ toString() ให้ตัวเลข */
|
|
6
|
+
export interface DecimalLike {
|
|
7
|
+
toString(): string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Props for the FormattedNumber component.
|
|
12
|
+
*/
|
|
13
|
+
interface FormattedNumberProps {
|
|
14
|
+
/**
|
|
15
|
+
* The value to format. Can be a Decimal-like object (Prisma.Decimal / decimal.js), number, string, null, or undefined.
|
|
16
|
+
*/
|
|
17
|
+
value: DecimalLike | number | string | null | undefined;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Optional Intl.NumberFormat options to customize the output.
|
|
21
|
+
* Defaults to { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 }.
|
|
22
|
+
*/
|
|
23
|
+
options?: Intl.NumberFormatOptions;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The locale to use for formatting. Defaults to 'th-TH'.
|
|
27
|
+
*/
|
|
28
|
+
locale?: string;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Placeholder text to display while mounting or if the value is invalid/null/undefined.
|
|
32
|
+
* Defaults to "-".
|
|
33
|
+
*/
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Optional CSS class name(s) to apply to the span element.
|
|
38
|
+
*/
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A client-side component to safely format and display numeric or Decimal values,
|
|
44
|
+
* preventing hydration mismatches and handling null/undefined states.
|
|
45
|
+
* * @example
|
|
46
|
+
* // Default formatting (2 decimal places)
|
|
47
|
+
* <FormattedNumber value={prismaDecimalValue} />
|
|
48
|
+
* * @example
|
|
49
|
+
* // No decimals
|
|
50
|
+
* <FormattedNumber value={12345} options={{ minimumFractionDigits: 0, maximumFractionDigits: 0 }} />
|
|
51
|
+
* * @example
|
|
52
|
+
* // Currency format
|
|
53
|
+
* <FormattedNumber
|
|
54
|
+
* value={12345.67}
|
|
55
|
+
* options={{ style: 'currency', currency: 'THB' }}
|
|
56
|
+
* locale="th-TH"
|
|
57
|
+
* />
|
|
58
|
+
*/
|
|
59
|
+
export function FormattedNumber({
|
|
60
|
+
value,
|
|
61
|
+
options: userOptions,
|
|
62
|
+
locale = "th-TH",
|
|
63
|
+
placeholder = "-",
|
|
64
|
+
className,
|
|
65
|
+
}: FormattedNumberProps) {
|
|
66
|
+
const [formattedValue, setFormattedValue] = useState<string>(placeholder);
|
|
67
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
// Set mounted to true only on the client-side
|
|
71
|
+
setIsMounted(true);
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
// Memoize default options
|
|
75
|
+
const defaultOptions: Intl.NumberFormatOptions = useMemo(
|
|
76
|
+
() => ({
|
|
77
|
+
style: "decimal",
|
|
78
|
+
minimumFractionDigits: 2,
|
|
79
|
+
maximumFractionDigits: 2,
|
|
80
|
+
}),
|
|
81
|
+
[],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
// Only run formatting logic on the client *after* mounting
|
|
86
|
+
if (!isMounted) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let numericValue: number;
|
|
91
|
+
|
|
92
|
+
// 1. Check for null/undefined/empty string explicitly to show placeholder
|
|
93
|
+
if (value === null || value === undefined || value === "") {
|
|
94
|
+
setFormattedValue(placeholder);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 2. Convert value to number
|
|
99
|
+
try {
|
|
100
|
+
// Handle Prisma Decimal object (or any object with toFixed/toString)
|
|
101
|
+
if (typeof value === "object" && value !== null && "toString" in value) {
|
|
102
|
+
numericValue = Number(value.toString());
|
|
103
|
+
} else {
|
|
104
|
+
// Handle number, string
|
|
105
|
+
numericValue = Number(value);
|
|
106
|
+
}
|
|
107
|
+
} catch (_e) {
|
|
108
|
+
// Handle potential errors during conversion
|
|
109
|
+
numericValue = NaN;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 3. Check if conversion resulted in a valid number
|
|
113
|
+
if (Number.isNaN(numericValue)) {
|
|
114
|
+
setFormattedValue(placeholder);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 4. Format the valid number
|
|
119
|
+
try {
|
|
120
|
+
// Merge user options with defaults (user options take precedence)
|
|
121
|
+
const finalOptions = { ...defaultOptions, ...userOptions };
|
|
122
|
+
|
|
123
|
+
const formatter = new Intl.NumberFormat(locale, finalOptions);
|
|
124
|
+
setFormattedValue(formatter.format(numericValue));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error("Error formatting number:", error);
|
|
127
|
+
setFormattedValue(placeholder); // Fallback on formatting error
|
|
128
|
+
}
|
|
129
|
+
}, [isMounted, value, userOptions, locale, placeholder, defaultOptions]);
|
|
130
|
+
|
|
131
|
+
// Render placeholder during SSR/hydration, formatted value on client post-mount
|
|
132
|
+
return <span className={className}>{formattedValue}</span>;
|
|
133
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/** หัวหน้าเพจของ console: ชื่อ + คำอธิบาย + ปุ่มด้านขวา */
|
|
5
|
+
export function PageHeader({
|
|
6
|
+
title,
|
|
7
|
+
description,
|
|
8
|
+
actions,
|
|
9
|
+
className,
|
|
10
|
+
}: {
|
|
11
|
+
title: ReactNode;
|
|
12
|
+
description?: ReactNode;
|
|
13
|
+
actions?: ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<div className={cn("flex flex-wrap items-end justify-between gap-3", className)}>
|
|
18
|
+
<div className="min-w-0">
|
|
19
|
+
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
|
20
|
+
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
|
21
|
+
</div>
|
|
22
|
+
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Skeleton } from "@suphark/ui/components/ui/skeleton";
|
|
2
|
+
|
|
3
|
+
export function PageSkeleton() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="animate-pulse space-y-6">
|
|
6
|
+
<div className="space-y-2">
|
|
7
|
+
<Skeleton className="h-10 w-1/3" />
|
|
8
|
+
<Skeleton className="h-4 w-1/2" />
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div className="grid gap-6">
|
|
12
|
+
<Skeleton className="h-[200px] w-full rounded-xl" />
|
|
13
|
+
<Skeleton className="h-[200px] w-full rounded-xl" />
|
|
14
|
+
<Skeleton className="h-[200px] w-full rounded-xl" />
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuSeparator,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from "@suphark/ui/components/ui/dropdown-menu";
|
|
11
|
+
import { MoreHorizontalIcon } from "lucide-react";
|
|
12
|
+
import { useRouter } from "next/navigation";
|
|
13
|
+
import { useState, useTransition } from "react";
|
|
14
|
+
import { useUiText } from "./ui-text";
|
|
15
|
+
|
|
16
|
+
export type RowAction =
|
|
17
|
+
| {
|
|
18
|
+
type?: "item";
|
|
19
|
+
label: string;
|
|
20
|
+
href?: string;
|
|
21
|
+
onSelect?: () => void;
|
|
22
|
+
destructive?: boolean;
|
|
23
|
+
confirm?: string;
|
|
24
|
+
action?: (formData: FormData) => Promise<unknown>;
|
|
25
|
+
data?: Record<string, string>;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
}
|
|
28
|
+
| { type: "separator" };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* เมนู "…" ท้ายแถว — รวมการกระทำรองไว้ที่เดียวแทนปุ่ม/ฟอร์มหลายอันในแถว
|
|
32
|
+
* รายการเรียก server action ได้โดยตรง (`action` + `data`) หรือไปหน้าอื่น (`href`) หรือเปิด dialog (`onSelect`)
|
|
33
|
+
* `confirm` = ถามยืนยันก่อน (ใช้กับสิ่งที่ย้อนกลับไม่ได้)
|
|
34
|
+
*/
|
|
35
|
+
export function RowActions({ items, label }: { items: RowAction[]; label?: string }) {
|
|
36
|
+
const router = useRouter();
|
|
37
|
+
const text = useUiText();
|
|
38
|
+
const [pending, start] = useTransition();
|
|
39
|
+
const [open, setOpen] = useState(false);
|
|
40
|
+
return (
|
|
41
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
42
|
+
<DropdownMenuTrigger
|
|
43
|
+
render={
|
|
44
|
+
<Button
|
|
45
|
+
variant="ghost"
|
|
46
|
+
size="icon-sm"
|
|
47
|
+
aria-label={label ?? text.actions}
|
|
48
|
+
disabled={pending}
|
|
49
|
+
/>
|
|
50
|
+
}
|
|
51
|
+
>
|
|
52
|
+
<MoreHorizontalIcon />
|
|
53
|
+
</DropdownMenuTrigger>
|
|
54
|
+
<DropdownMenuContent align="end" className="min-w-52">
|
|
55
|
+
{items.map((it, i) =>
|
|
56
|
+
it.type === "separator" ? (
|
|
57
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: separator ไม่มี identity
|
|
58
|
+
<DropdownMenuSeparator key={`sep-${i}`} />
|
|
59
|
+
) : (
|
|
60
|
+
<DropdownMenuItem
|
|
61
|
+
key={it.label}
|
|
62
|
+
variant={it.destructive ? "destructive" : "default"}
|
|
63
|
+
disabled={it.disabled}
|
|
64
|
+
onClick={() => {
|
|
65
|
+
setOpen(false);
|
|
66
|
+
if (it.confirm && !window.confirm(it.confirm)) return;
|
|
67
|
+
if (it.href) {
|
|
68
|
+
router.push(it.href);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (it.onSelect) {
|
|
72
|
+
it.onSelect();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (it.action) {
|
|
76
|
+
const fd = new FormData();
|
|
77
|
+
for (const [k, v] of Object.entries(it.data ?? {})) fd.set(k, v);
|
|
78
|
+
const run = it.action;
|
|
79
|
+
start(async () => {
|
|
80
|
+
await run(fd);
|
|
81
|
+
router.refresh();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{it.label}
|
|
87
|
+
</DropdownMenuItem>
|
|
88
|
+
),
|
|
89
|
+
)}
|
|
90
|
+
</DropdownMenuContent>
|
|
91
|
+
</DropdownMenu>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useState } from "react";
|
|
4
|
+
import { FormDialog } from "./form-dialog";
|
|
5
|
+
import type { FormState } from "./form-message";
|
|
6
|
+
import { type RowAction, RowActions } from "./row-actions";
|
|
7
|
+
|
|
8
|
+
export type RowMenuItem = RowAction | { label: string; dialog: string; disabled?: boolean };
|
|
9
|
+
|
|
10
|
+
export interface RowMenuDialog {
|
|
11
|
+
title: ReactNode;
|
|
12
|
+
description?: ReactNode;
|
|
13
|
+
action: (prev: FormState, formData: FormData) => Promise<FormState>;
|
|
14
|
+
submitLabel?: string;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* เมนู "…" ที่บางรายการเปิด dialog ฟอร์ม — ใช้จาก server component ได้
|
|
20
|
+
* (รายการอ้าง dialog ด้วย key; children ของ dialog เป็น JSX ที่ render จาก server ได้)
|
|
21
|
+
*/
|
|
22
|
+
export function RowMenu({
|
|
23
|
+
items,
|
|
24
|
+
dialogs = {},
|
|
25
|
+
label,
|
|
26
|
+
}: {
|
|
27
|
+
items: RowMenuItem[];
|
|
28
|
+
dialogs?: Record<string, RowMenuDialog>;
|
|
29
|
+
label?: string;
|
|
30
|
+
}) {
|
|
31
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
32
|
+
const actions: RowAction[] = items.map((it) =>
|
|
33
|
+
"dialog" in it
|
|
34
|
+
? { label: it.label, disabled: it.disabled, onSelect: () => setOpen(it.dialog) }
|
|
35
|
+
: it,
|
|
36
|
+
);
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<RowActions items={actions} label={label} />
|
|
40
|
+
{Object.entries(dialogs).map(([key, d]) => (
|
|
41
|
+
<FormDialog
|
|
42
|
+
key={key}
|
|
43
|
+
open={open === key}
|
|
44
|
+
onOpenChange={(o) => setOpen(o ? key : null)}
|
|
45
|
+
title={d.title}
|
|
46
|
+
description={d.description}
|
|
47
|
+
action={d.action}
|
|
48
|
+
submitLabel={d.submitLabel}
|
|
49
|
+
>
|
|
50
|
+
{d.children}
|
|
51
|
+
</FormDialog>
|
|
52
|
+
))}
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { Notice } from "./notice";
|
|
7
|
+
import { useUiText } from "./ui-text";
|
|
8
|
+
|
|
9
|
+
/** แสดงค่าลับที่เห็นได้ครั้งเดียว (client secret, webhook secret, SCIM token) พร้อมปุ่มคัดลอก */
|
|
10
|
+
export function SecretReveal({
|
|
11
|
+
values,
|
|
12
|
+
title,
|
|
13
|
+
}: {
|
|
14
|
+
values: Record<string, string>;
|
|
15
|
+
title?: string;
|
|
16
|
+
}) {
|
|
17
|
+
const text = useUiText();
|
|
18
|
+
const [copied, setCopied] = useState<string | null>(null);
|
|
19
|
+
async function copy(key: string, value: string) {
|
|
20
|
+
try {
|
|
21
|
+
await navigator.clipboard.writeText(value);
|
|
22
|
+
setCopied(key);
|
|
23
|
+
setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500);
|
|
24
|
+
} catch {
|
|
25
|
+
// clipboard ไม่ได้ (http/permission) — ผู้ใช้ยังคัดลอกจากข้อความได้
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return (
|
|
29
|
+
<Notice tone="warning" title={title ?? text.showOnce}>
|
|
30
|
+
<dl className="flex flex-col gap-2">
|
|
31
|
+
{Object.entries(values).map(([k, v]) => (
|
|
32
|
+
<div key={k} className="flex flex-col gap-1">
|
|
33
|
+
<dt className="font-mono text-[0.7rem] text-muted-foreground">{k}</dt>
|
|
34
|
+
<dd className="flex items-center gap-2">
|
|
35
|
+
<code className="min-w-0 flex-1 break-all rounded-md bg-background/70 px-2 py-1 font-mono text-xs">
|
|
36
|
+
{v}
|
|
37
|
+
</code>
|
|
38
|
+
<Button
|
|
39
|
+
type="button"
|
|
40
|
+
variant="outline"
|
|
41
|
+
size="icon-sm"
|
|
42
|
+
aria-label={`${text.copy} ${k}`}
|
|
43
|
+
onClick={() => copy(k, v)}
|
|
44
|
+
>
|
|
45
|
+
{copied === k ? <CheckIcon /> : <CopyIcon />}
|
|
46
|
+
</Button>
|
|
47
|
+
</dd>
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</dl>
|
|
51
|
+
</Notice>
|
|
52
|
+
);
|
|
53
|
+
}
|