@suphark/ui 0.6.0 → 0.7.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 +35 -0
- package/README.md +20 -0
- package/package.json +5 -1
- package/scripts/audit.mjs +218 -0
- package/src/components/app-shell/app-brand.tsx +1 -0
- package/src/components/app-shell/app-nav.tsx +2 -4
- package/src/components/app-shell/app-shell.tsx +6 -7
- package/src/components/app-shell/command-palette.tsx +256 -0
- package/src/components/app-shell/index.ts +11 -0
- package/src/components/app-shell/organization-switcher.tsx +150 -0
- package/src/components/app-shell/user-menu.tsx +8 -3
- package/src/components/data-table/data-table.tsx +34 -26
- package/src/components/data-table/types.ts +4 -0
- package/src/components/design-system/sections/showcase-data-display.tsx +2 -2
- package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +1 -1
- package/src/components/design-system/sections/showcase-overlays-navigation.tsx +2 -2
- package/src/components/design-system/sections/showcase-primitives.tsx +1 -1
- package/src/components/shared/action-form.tsx +12 -3
- package/src/components/shared/button/floating-save-button.tsx +1 -1
- package/src/components/shared/calendar-month-grid.tsx +182 -0
- package/src/components/shared/form/form-field-color-select.tsx +1 -1
- package/src/components/shared/form/form-field-combobox.tsx +1 -1
- package/src/components/shared/form/form-field-date.tsx +1 -1
- package/src/components/shared/form/form-field-input.tsx +6 -6
- package/src/components/shared/form/form-field-radio-cards.tsx +4 -0
- package/src/components/shared/form/labeled-field.tsx +21 -0
- package/src/components/shared/form-dialog.tsx +12 -3
- package/src/components/shared/form-message.tsx +26 -6
- package/src/components/shared/icon-picker.tsx +192 -0
- package/src/components/shared/notice.tsx +1 -0
- package/src/components/shared/progress-bar.tsx +83 -0
- package/src/components/shared/selection-button.tsx +29 -0
- package/src/components/shared/status-badge.tsx +1 -0
- package/src/components/shared/theme/theme-menu-items.tsx +31 -14
- package/src/components/shared/theme/theme-toggle.tsx +3 -3
- package/src/components/shared/ui-text.tsx +41 -0
- package/src/index.ts +22 -1
- package/src/lib/status-colors.ts +1 -0
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
export { AppBrand, type AppBrandProps } from "./app-brand";
|
|
2
2
|
export { AppNav, AppNavGroupMenu, type AppNavPinProps } from "./app-nav";
|
|
3
3
|
export { APP_MAIN_ID, AppShell, type AppShellProps } from "./app-shell";
|
|
4
|
+
export {
|
|
5
|
+
CommandPalette,
|
|
6
|
+
type CommandPaletteItem,
|
|
7
|
+
type CommandPaletteSection,
|
|
8
|
+
type CommandPaletteText,
|
|
9
|
+
} from "./command-palette";
|
|
4
10
|
export { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
|
|
5
11
|
export { HeaderCrumb, HeaderCrumbs } from "./header-crumb";
|
|
6
12
|
export { NavLink } from "./nav-link";
|
|
13
|
+
export {
|
|
14
|
+
type OrganizationOption,
|
|
15
|
+
OrganizationSwitcher,
|
|
16
|
+
type OrganizationSwitcherProps,
|
|
17
|
+
} from "./organization-switcher";
|
|
7
18
|
export { SubNav } from "./sub-nav";
|
|
8
19
|
export { isTabActive, TopTabs, topLevelTabs } from "./top-tabs";
|
|
9
20
|
export {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
|
|
4
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuGroup,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuLabel,
|
|
12
|
+
DropdownMenuSeparator,
|
|
13
|
+
DropdownMenuTrigger,
|
|
14
|
+
} from "@suphark/ui/components/ui/dropdown-menu";
|
|
15
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
16
|
+
import { Building2, Check } from "lucide-react";
|
|
17
|
+
import { useRouter } from "next/navigation";
|
|
18
|
+
import { type ReactNode, useTransition } from "react";
|
|
19
|
+
import { HeaderCrumb } from "./header-crumb";
|
|
20
|
+
|
|
21
|
+
export interface OrganizationOption {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
slug?: string;
|
|
25
|
+
logoUrl?: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface OrganizationSwitcherProps {
|
|
29
|
+
organizations: OrganizationOption[];
|
|
30
|
+
activeId: string | null;
|
|
31
|
+
/** แอปเรียก server action สลับองค์กร — component ทำ useTransition + router.refresh() หลัง resolve */
|
|
32
|
+
onSwitch: (id: string) => Promise<void> | void;
|
|
33
|
+
/** refresh หน้าหลังสลับ (ค่าเริ่มต้น true) */
|
|
34
|
+
refresh?: boolean;
|
|
35
|
+
/** ไม่มี active — ค่าเริ่มต้น `UiText.switcher.choose` */
|
|
36
|
+
fallbackLabel?: string;
|
|
37
|
+
/** หัว dropdown — ค่าเริ่มต้น `UiText.switcher.organization` */
|
|
38
|
+
heading?: string;
|
|
39
|
+
/** ข้อความจางท้ายชื่อ (ค่าเริ่มต้น `/slug` ถ้ามี) */
|
|
40
|
+
hint?: (org: OrganizationOption) => ReactNode;
|
|
41
|
+
/** slot ท้ายเมนู เช่น "สร้างองค์กร…", "จัดการองค์กร" */
|
|
42
|
+
footer?: ReactNode;
|
|
43
|
+
/** `header` = HeaderCrumb ใน header (layout top); `sidebar` = ปุ่มเต็มกว้างใน `sidebarHeader` ของ AppShell */
|
|
44
|
+
placement?: "header" | "sidebar";
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function OrgLogo({ org }: { org: OrganizationOption | undefined }) {
|
|
49
|
+
if (org?.logoUrl)
|
|
50
|
+
return <img src={org.logoUrl} alt="" className="size-5 rounded object-contain" />;
|
|
51
|
+
return <Building2 className="size-3.5 text-muted-foreground" aria-hidden="true" />;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* ตัวสลับองค์กร/tenant มาตรฐาน (แบบ Clerk OrganizationSwitcher) — ยกจาก Pi Auth TenantSwitcher ให้ Pi Done/DueBell ใช้ร่วม
|
|
56
|
+
* องค์กรเดียว = แสดงชื่อ ไม่มี dropdown; รายการที่ active มี ✓ และกดแล้วไม่ทำอะไร; ระหว่าง pending ปุ่ม disabled
|
|
57
|
+
*/
|
|
58
|
+
export function OrganizationSwitcher({
|
|
59
|
+
organizations,
|
|
60
|
+
activeId,
|
|
61
|
+
onSwitch,
|
|
62
|
+
refresh = true,
|
|
63
|
+
fallbackLabel,
|
|
64
|
+
heading,
|
|
65
|
+
hint = (o) => (o.slug ? `/${o.slug}` : undefined),
|
|
66
|
+
footer,
|
|
67
|
+
placement = "header",
|
|
68
|
+
className,
|
|
69
|
+
}: OrganizationSwitcherProps) {
|
|
70
|
+
const text = useUiText();
|
|
71
|
+
const router = useRouter();
|
|
72
|
+
const [pending, start] = useTransition();
|
|
73
|
+
const active = organizations.find((o) => o.id === activeId);
|
|
74
|
+
const label = active ? active.name : (fallbackLabel ?? text.switcher?.choose ?? "เลือกองค์กร");
|
|
75
|
+
const single = organizations.length <= 1 && !footer;
|
|
76
|
+
|
|
77
|
+
const trigger =
|
|
78
|
+
placement === "sidebar" ? (
|
|
79
|
+
<Button
|
|
80
|
+
type="button"
|
|
81
|
+
variant="outline"
|
|
82
|
+
size="sm"
|
|
83
|
+
disabled={pending}
|
|
84
|
+
aria-label={heading ?? text.switcher?.organization ?? "องค์กร"}
|
|
85
|
+
className={cn("h-8 w-full justify-start gap-2 bg-background px-2 font-normal", className)}
|
|
86
|
+
>
|
|
87
|
+
<OrgLogo org={active} />
|
|
88
|
+
<span className="truncate">{label}</span>
|
|
89
|
+
{!single && <SwitcherChevron className="ml-auto" />}
|
|
90
|
+
</Button>
|
|
91
|
+
) : (
|
|
92
|
+
<HeaderCrumb
|
|
93
|
+
icon={<OrgLogo org={active} />}
|
|
94
|
+
label={label}
|
|
95
|
+
hint={active ? hint(active) : undefined}
|
|
96
|
+
chevron={!single}
|
|
97
|
+
disabled={pending}
|
|
98
|
+
className={className}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (single) return trigger;
|
|
103
|
+
|
|
104
|
+
function pick(id: string) {
|
|
105
|
+
if (id === activeId) return;
|
|
106
|
+
start(async () => {
|
|
107
|
+
await onSwitch(id);
|
|
108
|
+
if (refresh) router.refresh();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<DropdownMenu>
|
|
114
|
+
<DropdownMenuTrigger render={trigger} />
|
|
115
|
+
<DropdownMenuContent align="start" className="w-56">
|
|
116
|
+
<DropdownMenuGroup>
|
|
117
|
+
<DropdownMenuLabel className="text-muted-foreground">
|
|
118
|
+
{heading ?? text.switcher?.organization ?? "องค์กร"}
|
|
119
|
+
</DropdownMenuLabel>
|
|
120
|
+
{organizations.map((o) => {
|
|
121
|
+
const isActive = o.id === activeId;
|
|
122
|
+
return (
|
|
123
|
+
<DropdownMenuItem
|
|
124
|
+
key={o.id}
|
|
125
|
+
onClick={() => pick(o.id)}
|
|
126
|
+
aria-current={isActive ? "true" : undefined}
|
|
127
|
+
className="gap-2"
|
|
128
|
+
>
|
|
129
|
+
<OrgLogo org={o} />
|
|
130
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
131
|
+
<span className="truncate">{o.name}</span>
|
|
132
|
+
{hint(o) && (
|
|
133
|
+
<span className="truncate text-muted-foreground text-xs">{hint(o)}</span>
|
|
134
|
+
)}
|
|
135
|
+
</span>
|
|
136
|
+
{isActive && <Check className="size-4 shrink-0" aria-hidden="true" />}
|
|
137
|
+
</DropdownMenuItem>
|
|
138
|
+
);
|
|
139
|
+
})}
|
|
140
|
+
</DropdownMenuGroup>
|
|
141
|
+
{footer && (
|
|
142
|
+
<>
|
|
143
|
+
<DropdownMenuSeparator />
|
|
144
|
+
{footer}
|
|
145
|
+
</>
|
|
146
|
+
)}
|
|
147
|
+
</DropdownMenuContent>
|
|
148
|
+
</DropdownMenu>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Initials } from "@suphark/ui/components/shared/initials";
|
|
4
4
|
import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
|
|
5
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
6
|
import { Button } from "@suphark/ui/components/ui/button";
|
|
6
7
|
import {
|
|
7
8
|
DropdownMenu,
|
|
@@ -41,6 +42,8 @@ export interface UserMenuProps {
|
|
|
41
42
|
variant?: "sidebar" | "header";
|
|
42
43
|
align?: "start" | "end";
|
|
43
44
|
className?: string;
|
|
45
|
+
/** aria-label ของปุ่มเปิดเมนู (ค่าเริ่มต้น `UiText.userMenu` "เมนูผู้ใช้") — ต่อท้ายชื่อผู้ใช้ให้ */
|
|
46
|
+
menuLabel?: string;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/** เมนูผู้ใช้ (ยกจาก supplychain NavUser + Pi Auth UserMenu) */
|
|
@@ -48,11 +51,13 @@ export function UserMenu({
|
|
|
48
51
|
user,
|
|
49
52
|
children,
|
|
50
53
|
onSignOut,
|
|
51
|
-
signOutLabel
|
|
54
|
+
signOutLabel,
|
|
52
55
|
variant = "sidebar",
|
|
53
56
|
align = variant === "header" ? "end" : "start",
|
|
54
57
|
className,
|
|
58
|
+
menuLabel,
|
|
55
59
|
}: UserMenuProps) {
|
|
60
|
+
const text = useUiText();
|
|
56
61
|
const [pending, start] = useTransition();
|
|
57
62
|
const description = user.description ?? user.email;
|
|
58
63
|
return (
|
|
@@ -61,7 +66,7 @@ export function UserMenu({
|
|
|
61
66
|
render={
|
|
62
67
|
<Button
|
|
63
68
|
variant="ghost"
|
|
64
|
-
aria-label={
|
|
69
|
+
aria-label={`${menuLabel ?? text.userMenu ?? "เมนูผู้ใช้"}: ${user.name}`}
|
|
65
70
|
className={cn(
|
|
66
71
|
variant === "sidebar"
|
|
67
72
|
? "h-auto w-full justify-start gap-2.5 px-2 py-2 text-left group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:p-0"
|
|
@@ -122,7 +127,7 @@ export function UserMenu({
|
|
|
122
127
|
<DropdownMenuSeparator />
|
|
123
128
|
<DropdownMenuItem disabled={pending} onClick={() => start(() => onSignOut())}>
|
|
124
129
|
<LogOut aria-hidden="true" />
|
|
125
|
-
{signOutLabel}
|
|
130
|
+
{signOutLabel ?? text.signOut ?? "ออกจากระบบ"}
|
|
126
131
|
</DropdownMenuItem>
|
|
127
132
|
</>
|
|
128
133
|
)}
|
|
@@ -139,6 +139,8 @@ export function DataTable<TData, TValue>({
|
|
|
139
139
|
enableColumnResizing = false,
|
|
140
140
|
enableRowSelection = true,
|
|
141
141
|
isLoading,
|
|
142
|
+
showToolbar = true,
|
|
143
|
+
showPagination = true,
|
|
142
144
|
pageSizeOptions,
|
|
143
145
|
toolbarFilters,
|
|
144
146
|
preservedFilterIds,
|
|
@@ -347,22 +349,26 @@ export function DataTable<TData, TValue>({
|
|
|
347
349
|
|
|
348
350
|
return (
|
|
349
351
|
<div className={cn("flex flex-col gap-4", containerClassName)}>
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
352
|
+
{showToolbar && (
|
|
353
|
+
<DataTableToolbar
|
|
354
|
+
table={table}
|
|
355
|
+
facetedFilters={facetedFilters}
|
|
356
|
+
actionsComponent={actionsComponent?.(table)}
|
|
357
|
+
globalFilterPlaceholder={globalFilterPlaceholder}
|
|
358
|
+
toolbarFilters={toolbarFilters?.(table)}
|
|
359
|
+
preservedFilterIds={preservedFilterIds}
|
|
360
|
+
/>
|
|
361
|
+
)}
|
|
358
362
|
{customView ? (
|
|
359
363
|
<>
|
|
360
364
|
{customView}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
365
|
+
{showPagination && (
|
|
366
|
+
<DataTablePagination
|
|
367
|
+
table={table}
|
|
368
|
+
pageSizeOptions={pageSizeOptions}
|
|
369
|
+
onPageSizeChange={onPageSizeChange}
|
|
370
|
+
/>
|
|
371
|
+
)}
|
|
366
372
|
</>
|
|
367
373
|
) : (
|
|
368
374
|
<>
|
|
@@ -379,19 +385,21 @@ export function DataTable<TData, TValue>({
|
|
|
379
385
|
tableContent
|
|
380
386
|
)}
|
|
381
387
|
</div>
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
388
|
+
{showPagination && (
|
|
389
|
+
<div
|
|
390
|
+
className={
|
|
391
|
+
stickyPagination
|
|
392
|
+
? "sticky bottom-0 z-20 border-t bg-background py-2 max-sm:-mx-2"
|
|
393
|
+
: undefined
|
|
394
|
+
}
|
|
395
|
+
>
|
|
396
|
+
<DataTablePagination
|
|
397
|
+
table={table}
|
|
398
|
+
pageSizeOptions={pageSizeOptions}
|
|
399
|
+
onPageSizeChange={onPageSizeChange}
|
|
400
|
+
/>
|
|
401
|
+
</div>
|
|
402
|
+
)}
|
|
395
403
|
</>
|
|
396
404
|
)}
|
|
397
405
|
</div>
|
|
@@ -95,6 +95,10 @@ export interface DataTableProps<TData, TValue> {
|
|
|
95
95
|
initialPageSize?: number;
|
|
96
96
|
meta?: Record<string, unknown>;
|
|
97
97
|
isLoading?: boolean;
|
|
98
|
+
/** ซ่อนแถบค้นหา/ตัวกรอง/View (ตารางตั้งค่าเล็ก ๆ) */
|
|
99
|
+
showToolbar?: boolean;
|
|
100
|
+
/** ซ่อน pagination (แถวน้อย ไม่ต้องแบ่งหน้า) */
|
|
101
|
+
showPagination?: boolean;
|
|
98
102
|
|
|
99
103
|
// Advanced
|
|
100
104
|
enableColumnResizing?: boolean;
|
|
@@ -414,7 +414,7 @@ export function ShowcaseDataDisplay() {
|
|
|
414
414
|
|
|
415
415
|
<Item variant="outline" className="p-3">
|
|
416
416
|
<ItemMedia variant="icon">
|
|
417
|
-
<AlertTriangle className="size-4 text-
|
|
417
|
+
<AlertTriangle className="size-4 text-warning" />
|
|
418
418
|
</ItemMedia>
|
|
419
419
|
<ItemContent>
|
|
420
420
|
<ItemTitle className="text-xs">หนังสือค้ำประกันผลงาน (Performance Bond)</ItemTitle>
|
|
@@ -444,7 +444,7 @@ export function ShowcaseDataDisplay() {
|
|
|
444
444
|
<AttachmentGroup>
|
|
445
445
|
<Attachment state="done" size="default">
|
|
446
446
|
<AttachmentMedia variant="icon">
|
|
447
|
-
<FileSpreadsheet className="size-4 text-
|
|
447
|
+
<FileSpreadsheet className="size-4 text-success" />
|
|
448
448
|
</AttachmentMedia>
|
|
449
449
|
<AttachmentContent>
|
|
450
450
|
<AttachmentTitle>BOQ_Facade_Rev2.xlsx</AttachmentTitle>
|
|
@@ -296,7 +296,7 @@ export function ApprovePackageButton({ id }: { id: string }) {
|
|
|
296
296
|
<span className="font-bold">✕ หลีกเลี่ยง:</span>
|
|
297
297
|
<span className="line-through">{item.bad}</span>
|
|
298
298
|
</div>
|
|
299
|
-
<div className="flex items-start gap-2 text-
|
|
299
|
+
<div className="flex items-start gap-2 text-success font-medium">
|
|
300
300
|
<span className="font-bold">✓ แนะนำ:</span>
|
|
301
301
|
<span>{item.good}</span>
|
|
302
302
|
</div>
|
|
@@ -252,11 +252,11 @@ export function ShowcaseOverlaysNavigation() {
|
|
|
252
252
|
<div className="p-4 flex flex-col gap-2 text-xs">
|
|
253
253
|
<div className="flex justify-between border-b pb-2">
|
|
254
254
|
<span>ส่งมอบตรงเวลา (On-Time Delivery)</span>
|
|
255
|
-
<span className="font-semibold text-
|
|
255
|
+
<span className="font-semibold text-success">96.5%</span>
|
|
256
256
|
</div>
|
|
257
257
|
<div className="flex justify-between border-b pb-2">
|
|
258
258
|
<span>มาตรฐานคุณภาพวัสดุ (Quality Pass Rate)</span>
|
|
259
|
-
<span className="font-semibold text-
|
|
259
|
+
<span className="font-semibold text-success">98.2%</span>
|
|
260
260
|
</div>
|
|
261
261
|
</div>
|
|
262
262
|
<DrawerFooter>
|
|
@@ -91,7 +91,7 @@ export function ShowcasePrimitives() {
|
|
|
91
91
|
label: "Pending Vendor / รอผู้ขาย",
|
|
92
92
|
variant: "outline",
|
|
93
93
|
icon: AlertCircle,
|
|
94
|
-
className: "border-
|
|
94
|
+
className: "border-warning/40 text-warning-muted-foreground bg-warning/10",
|
|
95
95
|
},
|
|
96
96
|
];
|
|
97
97
|
|
|
@@ -4,7 +4,13 @@ import { Button } from "@suphark/ui/components/ui/button";
|
|
|
4
4
|
import { Card, CardContent } from "@suphark/ui/components/ui/card";
|
|
5
5
|
import { cn } from "@suphark/ui/lib/utils";
|
|
6
6
|
import { type ReactNode, startTransition, useActionState } from "react";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type FormAction,
|
|
9
|
+
FormMessage,
|
|
10
|
+
type FormRenderContext,
|
|
11
|
+
type FormState,
|
|
12
|
+
formRenderContext,
|
|
13
|
+
} from "./form-message";
|
|
8
14
|
import { useUiText } from "./ui-text";
|
|
9
15
|
|
|
10
16
|
/**
|
|
@@ -16,6 +22,8 @@ import { useUiText } from "./ui-text";
|
|
|
16
22
|
* <ActionForm action={updateName} title="ชื่อองค์กร" submitLabel="บันทึก">
|
|
17
23
|
* <LabeledField label="ชื่อ"><Input name="name" defaultValue={org.name} /></LabeledField>
|
|
18
24
|
* </ActionForm>
|
|
25
|
+
* // error รายช่อง: action คืน { ok: false, error: "…", errors: { email: "รูปแบบไม่ถูกต้อง" } }
|
|
26
|
+
* <ActionForm action={save}>{({ fieldError }) => <LabeledField label="อีเมล" error={fieldError("email")}>…</LabeledField>}</ActionForm>
|
|
19
27
|
*/
|
|
20
28
|
export function ActionForm({
|
|
21
29
|
action,
|
|
@@ -29,7 +37,8 @@ export function ActionForm({
|
|
|
29
37
|
action: FormAction;
|
|
30
38
|
title?: ReactNode;
|
|
31
39
|
submitLabel?: ReactNode;
|
|
32
|
-
|
|
40
|
+
/** เนื้อฟอร์ม — เป็นฟังก์ชันได้เพื่อรับ `{ state, pending, errors, fieldError }` (error รายช่องจาก `FormState.errors`) */
|
|
41
|
+
children: ReactNode | ((ctx: FormRenderContext) => ReactNode);
|
|
33
42
|
/** ฟอร์มเล็กในแถวตาราง — ไม่มีกรอบ Card, ปุ่ม size sm, จัดแนวนอน wrap */
|
|
34
43
|
compact?: boolean;
|
|
35
44
|
className?: string;
|
|
@@ -56,7 +65,7 @@ export function ActionForm({
|
|
|
56
65
|
)}
|
|
57
66
|
>
|
|
58
67
|
{title && !compact && <h2 className="font-semibold text-sm">{title}</h2>}
|
|
59
|
-
{children}
|
|
68
|
+
{typeof children === "function" ? children(formRenderContext(state, pending)) : children}
|
|
60
69
|
<FormMessage state={state} className={compact ? "basis-full" : undefined} />
|
|
61
70
|
<Button
|
|
62
71
|
type="submit"
|
|
@@ -68,7 +68,7 @@ export function FloatingSaveButton({
|
|
|
68
68
|
className={cn(
|
|
69
69
|
"h-14 gap-3 rounded-full border-4 border-background px-8 text-base shadow-2xl transition-all duration-300",
|
|
70
70
|
showSuccess
|
|
71
|
-
? "scale-105 bg-
|
|
71
|
+
? "scale-105 bg-success text-success-foreground shadow-2xl shadow-success/40 hover:bg-success"
|
|
72
72
|
: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
73
73
|
buttonClassName,
|
|
74
74
|
)}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
4
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
5
|
+
import { Check } from "lucide-react";
|
|
6
|
+
import { type ReactNode, useMemo } from "react";
|
|
7
|
+
|
|
8
|
+
/** บริบทของช่องวันที่ให้ `renderDay`/`dayLabel` */
|
|
9
|
+
export interface CalendarDayContext<T> {
|
|
10
|
+
day: number;
|
|
11
|
+
date: Date;
|
|
12
|
+
items: T[];
|
|
13
|
+
isToday: boolean;
|
|
14
|
+
isSelected: boolean;
|
|
15
|
+
isWeekend: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CalendarDayState {
|
|
19
|
+
/** ทุกรายการของวันนั้นเสร็จแล้ว → `data-complete` + ✓ (เมื่อไม่มี renderDay) */
|
|
20
|
+
complete?: boolean;
|
|
21
|
+
hasItems?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CalendarMonthGridProps<T> {
|
|
25
|
+
/** เดือนที่แสดง `"YYYY-MM"` */
|
|
26
|
+
month: string;
|
|
27
|
+
/** วันที่ที่เลือก (1–31) */
|
|
28
|
+
selectedDay?: number | null;
|
|
29
|
+
onSelectDay?: (day: number, date: Date) => void;
|
|
30
|
+
/** รายการต่อวัน (key = วันที่ 1–31) */
|
|
31
|
+
itemsByDay?: Record<number, T[]>;
|
|
32
|
+
/** เนื้อหาใต้เลขวัน (จุดสถานะ ฯลฯ) — ไม่ส่ง หรือคืน null/undefined = ✓ ของแพ็กเกจเมื่อ complete */
|
|
33
|
+
renderDay?: (ctx: CalendarDayContext<T>) => ReactNode;
|
|
34
|
+
/** สถานะของช่องจากรายการ → data-attribute ให้ CSS ของแอปแต่งได้ */
|
|
35
|
+
dayState?: (items: T[], ctx: Omit<CalendarDayContext<T>, "items">) => CalendarDayState;
|
|
36
|
+
/** วันเริ่มสัปดาห์: 0 = อาทิตย์ (ค่าเริ่มต้น, ไทย), 1 = จันทร์ */
|
|
37
|
+
weekStartsOn?: 0 | 1;
|
|
38
|
+
/** ชื่อวัน 7 ตัวเริ่มที่อาทิตย์เสมอ (ค่าเริ่มต้นไทย) */
|
|
39
|
+
weekdayLabels?: readonly string[];
|
|
40
|
+
/** "วันนี้" — ไม่ส่ง = อ่านจาก browser หลัง mount (server ไม่รู้โซนเวลาผู้ใช้) */
|
|
41
|
+
today?: Date;
|
|
42
|
+
/** aria-label ของปุ่มวัน (ค่าเริ่มต้น "วันที่ N มี M รายการ") */
|
|
43
|
+
dayLabel?: (ctx: CalendarDayContext<T>) => string;
|
|
44
|
+
/** คำอธิบายสัญลักษณ์ใต้ตาราง */
|
|
45
|
+
legend?: ReactNode;
|
|
46
|
+
className?: string;
|
|
47
|
+
/** class เพิ่มของปุ่มวัน */
|
|
48
|
+
dayClassName?: string | ((ctx: CalendarDayContext<T>) => string | undefined);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const WEEKDAYS_TH = ["อา", "จ", "อ", "พ", "พฤ", "ศ", "ส"] as const;
|
|
52
|
+
export const WEEKDAYS_EN = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const;
|
|
53
|
+
|
|
54
|
+
function parseMonth(month: string): { year: number; monthIndex: number } {
|
|
55
|
+
const [y, m] = month.split("-");
|
|
56
|
+
const year = Number(y);
|
|
57
|
+
const monthIndex = Number(m) - 1;
|
|
58
|
+
if (!Number.isFinite(year) || !Number.isFinite(monthIndex) || monthIndex < 0 || monthIndex > 11) {
|
|
59
|
+
throw new Error(`CalendarMonthGrid: month ต้องเป็น "YYYY-MM" ได้ "${month}"`);
|
|
60
|
+
}
|
|
61
|
+
return { year, monthIndex };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* ตารางเดือน (ปฏิทินเลือกวัน) ที่แอปคุมเนื้อหาต่อวันเอง — ยกจาก DueBell/Pi Done ให้ใช้ร่วม
|
|
66
|
+
* ปุ่มวันมี `aria-pressed` + data-attribute แบบ boolean (มี/ไม่มี ไม่มีค่า "true"): `data-selected`, `data-today`,
|
|
67
|
+
* `data-has-items`, `data-complete`, `data-weekend` → CSS ของแอปใช้ `[data-selected]` / Tailwind `data-selected:`
|
|
68
|
+
* SSR ได้: "วันนี้" อ่านจาก browser หลัง mount (หรือส่ง `today`)
|
|
69
|
+
*/
|
|
70
|
+
export function CalendarMonthGrid<T = unknown>({
|
|
71
|
+
month,
|
|
72
|
+
selectedDay = null,
|
|
73
|
+
onSelectDay,
|
|
74
|
+
itemsByDay = {},
|
|
75
|
+
renderDay,
|
|
76
|
+
dayState,
|
|
77
|
+
weekStartsOn = 0,
|
|
78
|
+
weekdayLabels = WEEKDAYS_TH,
|
|
79
|
+
today: todayProp,
|
|
80
|
+
dayLabel,
|
|
81
|
+
legend,
|
|
82
|
+
className,
|
|
83
|
+
dayClassName,
|
|
84
|
+
}: CalendarMonthGridProps<T>) {
|
|
85
|
+
const mounted = useMounted();
|
|
86
|
+
const { year, monthIndex } = parseMonth(month);
|
|
87
|
+
// today: prop > browser หลัง mount > ไม่มี (server) — ไม่ใช้ new Date() ตอน render บน server
|
|
88
|
+
const today = todayProp ?? (mounted ? new Date() : null);
|
|
89
|
+
const isThisMonth = !!today && today.getFullYear() === year && today.getMonth() === monthIndex;
|
|
90
|
+
|
|
91
|
+
const { headers, blanks, days } = useMemo(() => {
|
|
92
|
+
const firstWeekday = new Date(year, monthIndex, 1).getDay(); // 0 = อาทิตย์
|
|
93
|
+
const daysInMonth = new Date(year, monthIndex + 1, 0).getDate();
|
|
94
|
+
const offset = (firstWeekday - weekStartsOn + 7) % 7;
|
|
95
|
+
const headers = Array.from({ length: 7 }, (_, i) => {
|
|
96
|
+
const weekday = (i + weekStartsOn) % 7;
|
|
97
|
+
return { weekday, label: weekdayLabels[weekday] ?? "" };
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
headers,
|
|
101
|
+
blanks: Array.from({ length: offset }, (_, i) => i),
|
|
102
|
+
days: Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
|
103
|
+
};
|
|
104
|
+
}, [year, monthIndex, weekStartsOn, weekdayLabels]);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className={cn("rounded-xl border bg-card p-3 sm:p-4", className)}>
|
|
108
|
+
<div className="mb-2 grid grid-cols-7 gap-1 text-center">
|
|
109
|
+
{headers.map((h) => (
|
|
110
|
+
<span
|
|
111
|
+
key={h.weekday}
|
|
112
|
+
className={cn(
|
|
113
|
+
"font-semibold text-xs",
|
|
114
|
+
h.weekday === 0
|
|
115
|
+
? "text-destructive"
|
|
116
|
+
: h.weekday === 6
|
|
117
|
+
? "text-primary"
|
|
118
|
+
: "text-muted-foreground",
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
{h.label}
|
|
122
|
+
</span>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
<div className="grid grid-cols-7 gap-1.5" role="grid" aria-label={month}>
|
|
126
|
+
{blanks.map((b) => (
|
|
127
|
+
<div key={`blank-${b}`} className="aspect-square" aria-hidden="true" />
|
|
128
|
+
))}
|
|
129
|
+
{days.map((day) => {
|
|
130
|
+
const date = new Date(year, monthIndex, day);
|
|
131
|
+
const items = itemsByDay[day] ?? [];
|
|
132
|
+
const weekday = date.getDay();
|
|
133
|
+
const base = {
|
|
134
|
+
day,
|
|
135
|
+
date,
|
|
136
|
+
isToday: isThisMonth && today?.getDate() === day,
|
|
137
|
+
isSelected: selectedDay === day,
|
|
138
|
+
isWeekend: weekday === 0 || weekday === 6,
|
|
139
|
+
};
|
|
140
|
+
const ctx: CalendarDayContext<T> = { ...base, items };
|
|
141
|
+
const state = dayState?.(items, base) ?? {};
|
|
142
|
+
const hasItems = state.hasItems ?? items.length > 0;
|
|
143
|
+
const complete = state.complete ?? false;
|
|
144
|
+
const extra = typeof dayClassName === "function" ? dayClassName(ctx) : dayClassName;
|
|
145
|
+
return (
|
|
146
|
+
<button
|
|
147
|
+
key={day}
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={() => onSelectDay?.(day, date)}
|
|
150
|
+
aria-pressed={ctx.isSelected}
|
|
151
|
+
aria-label={dayLabel ? dayLabel(ctx) : `วันที่ ${day} มี ${items.length} รายการ`}
|
|
152
|
+
data-selected={ctx.isSelected || undefined}
|
|
153
|
+
data-today={ctx.isToday || undefined}
|
|
154
|
+
data-has-items={hasItems || undefined}
|
|
155
|
+
data-complete={complete || undefined}
|
|
156
|
+
data-weekend={ctx.isWeekend || undefined}
|
|
157
|
+
className={cn(
|
|
158
|
+
"relative flex aspect-square select-none flex-col items-center justify-between rounded-xl border p-1.5 font-semibold text-xs outline-none transition-[border-color,box-shadow,background-color]",
|
|
159
|
+
"hover:border-primary/50 focus-visible:ring-2 focus-visible:ring-ring/50",
|
|
160
|
+
"data-today:border-primary data-today:text-primary",
|
|
161
|
+
"data-selected:border-primary data-selected:bg-primary/10 data-selected:shadow-[0_0_0_1px_var(--primary)]",
|
|
162
|
+
"data-complete:border-success/40 data-complete:bg-success/10 data-complete:text-success",
|
|
163
|
+
extra,
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
<span>{day}</span>
|
|
167
|
+
<span className="flex h-2 items-center justify-center gap-1">
|
|
168
|
+
{renderDay?.(ctx) ??
|
|
169
|
+
(complete && (
|
|
170
|
+
<span className="flex size-3.5 items-center justify-center rounded-full bg-success text-success-foreground">
|
|
171
|
+
<Check className="size-2.5" strokeWidth={3} aria-hidden="true" />
|
|
172
|
+
</span>
|
|
173
|
+
))}
|
|
174
|
+
</span>
|
|
175
|
+
</button>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</div>
|
|
179
|
+
{legend && <div className="mt-3 text-muted-foreground text-xs">{legend}</div>}
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -55,7 +55,7 @@ export function FormFieldColorSelect<T extends FieldValues>({
|
|
|
55
55
|
render={({ field }) => (
|
|
56
56
|
<FormItem>
|
|
57
57
|
<FormLabel>
|
|
58
|
-
{label} {props.required && <span className="text-
|
|
58
|
+
{label} {props.required && <span className="text-destructive">*</span>}
|
|
59
59
|
</FormLabel>
|
|
60
60
|
<Select onValueChange={field.onChange} value={field.value} {...props}>
|
|
61
61
|
<FormControl>
|
|
@@ -103,7 +103,7 @@ export function FormFieldCombobox<T extends FieldValues>({
|
|
|
103
103
|
render={({ field }) => (
|
|
104
104
|
<FormItem className={cn("flex flex-col gap-1.5", className)}>
|
|
105
105
|
<FormLabel>
|
|
106
|
-
{label} {required && <span className="text-
|
|
106
|
+
{label} {required && <span className="text-destructive">*</span>}
|
|
107
107
|
</FormLabel>
|
|
108
108
|
<FormControl>
|
|
109
109
|
<Popover open={open} onOpenChange={setOpen}>
|
|
@@ -36,7 +36,7 @@ export function FormFieldDate<T extends FieldValues>({
|
|
|
36
36
|
render={({ field }) => (
|
|
37
37
|
<FormItem className="flex flex-col">
|
|
38
38
|
<FormLabel>
|
|
39
|
-
{label} {required && <span className="text-
|
|
39
|
+
{label} {required && <span className="text-destructive">*</span>}
|
|
40
40
|
</FormLabel>
|
|
41
41
|
<FormControl>
|
|
42
42
|
<DatePicker
|