데이터 관리
검색, 상태 필터, 정렬, 페이지 이동, 선택, 생성·편집과 삭제 확인.
로컬 작동 예제입니다. 새로고침하면 초기화됩니다. onSave와 onDelete에 실제 서비스를 연결하세요.
WORKSPACE / RECORDS
작은 기록, 명확한 진행.
필요한 작업을 찾고, 내용을 정리하고, 다음 단계로 옮기세요.
전체 데이터12
진행 중6
초안4
데이터
12 개 검색 결과 · 0 개 선택됨
| 상태 | 작업 | ||||
|---|---|---|---|---|---|
| Archived campaign | Mina | 보관됨 | 2026-08-20 | ||
| Content planning | Mina | 초안 | 2026-10-15 | ||
| Customer onboarding | Jun | 진행 중 | 2026-10-08 | ||
| Keyboard navigation audit | Jun | 진행 중 | 2026-10-14 | ||
| Legacy welcome screen | Jun | 보관됨 | 2026-09-01 |
페이지 1 / 3
최근 변경
이 화면에서 변경한 내용이 표시됩니다.
설치
기본 테마와 Pretendard는 설치 안내에 따라 한 번 설정하세요. 이 템플릿은 기존 테마·전역 CSS·페이지 경로를 덮어쓰지 않습니다.
설치: template-data-manager
npx shadcn@latest add @neumorphism-ui/template-data-manager사용 예제
사용 예제
"use client";
import { DataManager, type ManagedRecord } from "@/components/blocks/data-manager";
export function Example({ records, saveRecord, deleteRecords }: {
records: ManagedRecord[];
saveRecord: (record: ManagedRecord) => Promise<void>;
deleteRecords: (ids: string[]) => Promise<void>;
}) {
return <DataManager initialRecords={records} onSave={saveRecord} onDelete={deleteRecords} locale="ko" />;
}연결 방식
아래 데모는 메모리에서만 동작하며 새로고침하면 초기화됩니다. 실제 컴포넌트의 onSave·onDelete 콜백에 인증된 서비스를 연결하세요. initialValues·initialRecords는 최초 값이므로 다른 데이터로 전환할 때 key를 바꾸세요.
실제 저장 처리는 아래 Example의 콜백에 연결합니다.
설치되는 소스
@components/blocks/data-manager.tsx
@components/blocks/data-manager.tsx
"use client";
import * as React from "react";
import { getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, type ColumnDef, type SortingState, type RowSelectionState, type ColumnFiltersState, type VisibilityState } from "@tanstack/react-table";
import { ko, ja, zhCN } from "@daypicker/react/locale";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Select, SelectItem } from "@/components/ui/select";
import { Field, FieldLabel, FieldControl, FieldError } from "@/components/ui/field";
import { Form } from "@/components/ui/form";
import { DatePicker } from "@/components/ui/date-picker";
import { DataTable, DataTableColumnHeader, DataTablePagination } from "@/components/ui/data-table";
import { Sheet, SheetContent, SheetTitle, SheetDescription } from "@/components/ui/sheet";
import { AlertDialog, AlertDialogContent, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel } from "@/components/ui/alert-dialog";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Menubar, MenubarMenu, MenubarTrigger, MenubarContent, MenubarItem, MenubarCheckboxItem } from "@/components/ui/menubar";
import { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuViewport } from "@/components/ui/navigation-menu";
import { ToastProvider, Toaster, useToast } from "@/components/ui/toast";
import { exampleRecords, parseRecordDate, formatRecordDate, upsertRecord, removeRecords, type ManagedRecord, type RecordStatus } from "./data-manager-model";
export type DataManagerProps = { initialRecords: readonly ManagedRecord[]; onSave: (record: ManagedRecord) => Promise<void>; onDelete: (ids: string[]) => Promise<void>; locale?: "ko" | "en" | "ja" | "zh" };
const copy = {
en: { kicker: "WORKSPACE / RECORDS", title: "Small details. Clear progress.", intro: "Find the work that matters, keep it current and move it forward.", records: "Records", activity: "Recent activity", workspace: "Workspace", all: "All records", active: "Active", draft: "Draft", archived: "Archived", search: "Search records", status: "Status", record: "Title", owner: "Owner", due: "Due date", actions: "Actions", view: "View", reset: "Reset filters", add: "New record", edit: "Edit", editTitle: "Edit record", newTitle: "Create record", editorHint: "Changes are applied only after a successful save.", empty: "No matching records. Change or reset your filters.", selected: "selected", shown: "matching records", selectPage: "Select current page", selectRecord: "Select", delete: "Delete selected", confirm: "Delete these records?", deleteHint: "This removes the selected records after your delete handler succeeds. This action cannot be undone here.", cancel: "Cancel", save: "Save record", saving: "Saving…", deleting: "Deleting…", failure: "Could not complete the action", recovery: "Nothing was removed or overwritten. Your edits are preserved; please try again.", saved: "Record saved", deleted: "Records deleted", noActivity: "Changes made in this view will appear here.", required: "Enter a title, not only spaces.", discard: "Discard edits?", discardBody: "This draft has changes that have not been saved.", discardAction: "Discard draft", keep: "Keep editing", previous: "Previous", next: "Next", pageSize: "Rows per page", page: "Page", of: "of", selectDate: "Select date", clearDate: "Clear date", demo: "Interactive local example. Reload resets the data. Connect onSave and onDelete to your service.", fail: "Fail the next operation", dismiss: "Dismiss notification" },
ko: { kicker: "WORKSPACE / RECORDS", title: "작은 기록, 명확한 진행.", intro: "필요한 작업을 찾고, 내용을 정리하고, 다음 단계로 옮기세요.", records: "데이터", activity: "최근 변경", workspace: "작업 공간", all: "전체 데이터", active: "진행 중", draft: "초안", archived: "보관됨", search: "데이터 검색", status: "상태", record: "제목", owner: "담당자", due: "마감일", actions: "작업", view: "보기", reset: "필터 초기화", add: "새 항목", edit: "편집", editTitle: "항목 편집", newTitle: "항목 만들기", editorHint: "저장에 성공한 경우에만 변경 사항을 반영합니다.", empty: "일치하는 항목이 없습니다. 필터를 변경하거나 초기화하세요.", selected: "개 선택됨", shown: "개 검색 결과", selectPage: "현재 페이지 전체 선택", selectRecord: "선택", delete: "선택 항목 삭제", confirm: "선택한 항목을 삭제할까요?", deleteHint: "삭제 처리에 성공한 뒤 목록에서 제거합니다. 이 화면에서는 되돌릴 수 없습니다.", cancel: "취소", save: "항목 저장", saving: "저장 중…", deleting: "삭제 중…", failure: "작업을 완료하지 못했습니다", recovery: "기존 항목은 변경되지 않았습니다. 편집 내용은 유지되니 다시 시도하세요.", saved: "항목을 저장했습니다", deleted: "항목을 삭제했습니다", noActivity: "이 화면에서 변경한 내용이 표시됩니다.", required: "공백이 아닌 제목을 입력하세요.", discard: "편집 내용을 버릴까요?", discardBody: "아직 저장하지 않은 변경 사항이 있습니다.", discardAction: "편집 내용 버리기", keep: "계속 편집", previous: "이전", next: "다음", pageSize: "페이지당 항목", page: "페이지", of: "/", selectDate: "날짜 선택", clearDate: "날짜 지우기", demo: "로컬 작동 예제입니다. 새로고침하면 초기화됩니다. onSave와 onDelete에 실제 서비스를 연결하세요.", fail: "다음 작업 실패 재현", dismiss: "알림 닫기" },
ja: { kicker: "WORKSPACE / RECORDS", title: "小さな記録、明確な進捗。", intro: "必要な作業を見つけ、内容を整え、次へ進めましょう。", records: "データ", activity: "最近の変更", workspace: "ワークスペース", all: "すべてのデータ", active: "進行中", draft: "下書き", archived: "保管済み", search: "データを検索", status: "状態", record: "タイトル", owner: "担当者", due: "期限", actions: "操作", view: "表示", reset: "フィルターを解除", add: "新しい項目", edit: "編集", editTitle: "項目を編集", newTitle: "項目を作成", editorHint: "保存に成功した場合のみ変更を反映します。", empty: "一致する項目がありません。フィルターを変更してください。", selected: "件選択", shown: "件の結果", selectPage: "現在のページを選択", selectRecord: "選択", delete: "選択項目を削除", confirm: "選択した項目を削除しますか?", deleteHint: "削除処理に成功してから一覧から取り除きます。ここでは元に戻せません。", cancel: "キャンセル", save: "項目を保存", saving: "保存中…", deleting: "削除中…", failure: "操作を完了できませんでした", recovery: "元の項目は変更されていません。編集内容を保ったまま再試行できます。", saved: "項目を保存しました", deleted: "項目を削除しました", noActivity: "この画面での変更がここに表示されます。", required: "空白以外のタイトルを入力してください。", discard: "編集内容を破棄しますか?", discardBody: "保存していない変更があります。", discardAction: "変更を破棄", keep: "編集を続ける", previous: "前へ", next: "次へ", pageSize: "ページあたり", page: "ページ", of: "/", selectDate: "日付を選択", clearDate: "日付をクリア", demo: "ローカル例です。再読み込みで初期化されます。onSave と onDelete にサービスを接続してください。", fail: "次の操作を失敗させる", dismiss: "通知を閉じる" },
zh: { kicker: "WORKSPACE / RECORDS", title: "细致记录,清晰推进。", intro: "找到重要工作,更新内容,推进下一步。", records: "数据", activity: "最近变更", workspace: "工作空间", all: "全部数据", active: "进行中", draft: "草稿", archived: "已归档", search: "搜索数据", status: "状态", record: "标题", owner: "负责人", due: "截止日期", actions: "操作", view: "视图", reset: "重置筛选", add: "新建记录", edit: "编辑", editTitle: "编辑记录", newTitle: "创建记录", editorHint: "仅在保存成功后应用更改。", empty: "没有匹配的记录。请更改或重置筛选。", selected: "项已选择", shown: "项结果", selectPage: "选择当前页", selectRecord: "选择", delete: "删除所选", confirm: "删除所选记录?", deleteHint: "删除处理成功后才从列表移除。此处无法撤销。", cancel: "取消", save: "保存记录", saving: "正在保存…", deleting: "正在删除…", failure: "无法完成操作", recovery: "原记录未被移除或覆盖。编辑内容已保留,请重试。", saved: "记录已保存", deleted: "记录已删除", noActivity: "此视图中的变更将显示在这里。", required: "请输入非空白标题。", discard: "放弃编辑?", discardBody: "当前草稿有未保存的更改。", discardAction: "放弃草稿", keep: "继续编辑", previous: "上一页", next: "下一页", pageSize: "每页行数", page: "页", of: "/", selectDate: "选择日期", clearDate: "清除日期", demo: "这是本地示例,刷新后重置。请将 onSave 和 onDelete 连接到实际服务。", fail: "模拟下次操作失败", dismiss: "关闭通知" },
} as const;
const statusClass: Record<RecordStatus, string> = { active: "border-[var(--success)]", draft: "border-[var(--primary)]", archived: "border-[var(--border)]" };
const dateLocales = { ko, en: undefined, ja, zh: zhCN };
function RecordsEditor({ initialRecords, onSave, onDelete, locale = "en" }: DataManagerProps) {
"use no memo";
const t = copy[locale]; const id = React.useId(); const toast = useToast();
const [records, setRecords] = React.useState(() => initialRecords.map(record => ({ ...record })));
const [search, setSearch] = React.useState(""); const [filters, setFilters] = React.useState<ColumnFiltersState>([]);
const [sorting, setSorting] = React.useState<SortingState>([{ id: "title", desc: false }]);
const [selection, setSelection] = React.useState<RowSelectionState>({}); const [visibility, setVisibility] = React.useState<VisibilityState>({});
const [draft, setDraft] = React.useState<ManagedRecord | null>(null); const [baseline, setBaseline] = React.useState<ManagedRecord | null>(null);
const [pending, setPending] = React.useState(false); const busy = React.useRef(false);
const [failed, setFailed] = React.useState(false); const [deleteIds, setDeleteIds] = React.useState<string[]>([]);
const [discardOpen, setDiscardOpen] = React.useState(false); const [activity, setActivity] = React.useState<string[]>([]);
const opener = React.useRef<HTMLButtonElement | null>(null);
const deleteTrigger = React.useRef<HTMLButtonElement | null>(null);
const dirty = draft !== null && JSON.stringify(draft) !== JSON.stringify(baseline);
const status = String(filters.find(filter => filter.id === "status")?.value ?? "all");
const filterStatus = (value: string) => { setFilters(value === "all" ? [] : [{ id: "status", value }]); setSelection({}); };
function openEditor(record: ManagedRecord | null, trigger: HTMLButtonElement) {
if (busy.current) return;
const next = record ? { ...record } : { id: crypto.randomUUID(), title: "", owner: "Alex", status: "draft" as const, dueDate: "" };
opener.current = trigger; setBaseline(next); setDraft(next); setFailed(false);
}
const reset = () => { setSearch(""); setFilters([]); setSelection({}); };
const columns: ColumnDef<ManagedRecord>[] = [
{ id: "selection", enableSorting: false, enableGlobalFilter: false, header: ({ table }) => <Checkbox aria-label={t.selectPage} checked={table.getIsAllPageRowsSelected() ? true : table.getIsSomePageRowsSelected() ? "indeterminate" : false} onCheckedChange={value => table.toggleAllPageRowsSelected(value === true)} disabled={pending || !table.getRowModel().rows.length} />, cell: ({ row }) => <Checkbox aria-label={`${t.selectRecord} ${row.original.title}`} checked={row.getIsSelected()} disabled={pending} onCheckedChange={value => row.toggleSelected(value === true)} /> },
{ accessorKey: "title", header: ({ column }) => <DataTableColumnHeader column={column} title={t.record} />, cell: ({ row }) => <span className="block min-w-40 max-w-80 font-medium [overflow-wrap:anywhere]">{row.original.title}</span> },
{ accessorKey: "owner", header: ({ column }) => <DataTableColumnHeader column={column} title={t.owner} /> },
{ accessorKey: "status", filterFn: "equalsString", header: t.status, cell: ({ row }) => <span className={`inline-flex whitespace-nowrap rounded-full border bg-[var(--neu-surface)] px-2.5 py-1 text-xs font-medium text-[var(--foreground)] ${statusClass[row.original.status]}`}>{t[row.original.status]}</span> },
{ accessorKey: "dueDate", header: ({ column }) => <DataTableColumnHeader column={column} title={t.due} />, cell: ({ row }) => <span className="whitespace-nowrap text-xs tabular-nums text-[var(--muted-foreground)]">{row.original.dueDate || "—"}</span> },
{ id: "actions", enableGlobalFilter: false, header: t.actions, cell: ({ row }) => <Button size="sm" variant="ghost" disabled={pending} onClick={event => openEditor(row.original, event.currentTarget)} aria-label={`${t.edit} ${row.original.title}`}>{t.edit}</Button> },
];
const table = useReactTable({ data: records, columns, state: { globalFilter: search, columnFilters: filters, sorting, rowSelection: selection, columnVisibility: visibility }, onGlobalFilterChange: setSearch, onColumnFiltersChange: setFilters, onSortingChange: setSorting, onRowSelectionChange: setSelection, onColumnVisibilityChange: setVisibility, getRowId: row => row.id, globalFilterFn: "includesString", getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), initialState: { pagination: { pageSize: 5 } } });
const selected = table.getSelectedRowModel().rows.map(row => row.original.id);
const requestClose = () => { if (busy.current) return; if (dirty) setDiscardOpen(true); else { setDraft(null); setFailed(false); } };
async function saveRecord(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); if (!draft || busy.current) return;
const snapshot = { ...draft, title: draft.title.trim() }; if (!snapshot.title) return;
busy.current = true; setPending(true); setFailed(false);
try { await onSave(snapshot); setRecords(current => upsertRecord(current, snapshot)); setSelection({}); setDraft(null); setActivity(current => [`${t.saved}: ${snapshot.title}`, ...current].slice(0, 4)); toast.add({ title: t.saved, description: snapshot.title, type: "success" }); }
catch { setFailed(true); }
finally { busy.current = false; setPending(false); }
}
async function deleteSelected() {
if (!deleteIds.length || busy.current) return;
const snapshot = [...deleteIds]; busy.current = true; setPending(true); setFailed(false);
try { await onDelete(snapshot); setRecords(current => removeRecords(current, snapshot)); setSelection({}); setDeleteIds([]); setActivity(current => [`${t.deleted}: ${snapshot.length}`, ...current].slice(0, 4)); toast.add({ title: t.deleted, description: String(snapshot.length), type: "success" }); }
catch { setFailed(true); }
finally { busy.current = false; setPending(false); }
}
const error = failed && <Alert variant="destructive"><AlertTitle>{t.failure}</AlertTitle><AlertDescription>{t.recovery}</AlertDescription></Alert>;
return <div data-slot="data-manager" className="mx-auto grid w-full max-w-6xl gap-7 font-sans text-[var(--foreground)]">
<header className="flex flex-wrap items-start justify-between gap-5 border-b border-[var(--border)] pb-6"><div className="grid max-w-xl gap-3"><p className="text-xs font-semibold tracking-[0.16em] text-[var(--muted-foreground)]">{t.kicker}</p><h2 className="text-3xl font-semibold leading-tight tracking-tight sm:text-4xl">{t.title}</h2><p className="text-sm leading-relaxed text-[var(--muted-foreground)]">{t.intro}</p></div><NavigationMenu aria-label={t.workspace}><NavigationMenuList><NavigationMenuItem><NavigationMenuTrigger>{t.workspace}</NavigationMenuTrigger><NavigationMenuContent><NavigationMenuLink href={`#${id}-records`}>{t.records}</NavigationMenuLink><NavigationMenuLink href={`#${id}-activity`}>{t.activity}</NavigationMenuLink></NavigationMenuContent></NavigationMenuItem></NavigationMenuList><NavigationMenuViewport /></NavigationMenu></header>
<div className="grid grid-cols-3 gap-3 sm:gap-5">{([[t.all, records.length], [t.active, records.filter(record => record.status === "active").length], [t.draft, records.filter(record => record.status === "draft").length]] as const).map(([label, value]) => <div key={label} className="grid gap-3 rounded-[var(--neu-radius-surface)] border border-[color:var(--neu-edge)] bg-[var(--neu-surface)] p-4 text-center [box-shadow:var(--neu-shadow-inset)] first:[box-shadow:var(--neu-shadow-raised-sm)] first:text-[var(--neu-accent-ink)] sm:p-5"><span className="text-xs leading-relaxed text-[var(--muted-foreground)]">{label}</span><strong className="text-2xl font-semibold tabular-nums sm:text-3xl">{value}</strong></div>)}</div>
<section id={`${id}-records`} className="grid min-w-0 scroll-mt-24 gap-5 rounded-[var(--neu-radius-surface)] border border-[var(--border)] bg-[var(--neu-surface)] p-4 sm:p-6" aria-busy={pending}>
<div className="flex flex-wrap items-center justify-between gap-4"><h3 className="text-lg font-semibold">{t.records}</h3><Button variant="primary" disabled={pending} onClick={event => openEditor(null, event.currentTarget)}>+ {t.add}</Button></div>
<div className="flex flex-wrap items-center justify-between gap-3"><Menubar aria-label={t.actions}><MenubarMenu><MenubarTrigger ref={deleteTrigger} disabled={pending}>{t.records}</MenubarTrigger><MenubarContent><MenubarItem onClick={reset}>{t.reset}</MenubarItem><MenubarItem disabled={!selected.length || pending} onClick={() => { setFailed(false); setDeleteIds(selected); }}>{t.delete} ({selected.length})</MenubarItem></MenubarContent></MenubarMenu><MenubarMenu><MenubarTrigger disabled={pending}>{t.view}</MenubarTrigger><MenubarContent><MenubarCheckboxItem checked={table.getColumn("owner")!.getIsVisible()} onCheckedChange={value => table.getColumn("owner")!.toggleVisibility(value)}>{t.owner}</MenubarCheckboxItem><MenubarCheckboxItem checked={table.getColumn("dueDate")!.getIsVisible()} onCheckedChange={value => table.getColumn("dueDate")!.toggleVisibility(value)}>{t.due}</MenubarCheckboxItem></MenubarContent></MenubarMenu></Menubar><span role="status" className="text-xs tabular-nums text-[var(--muted-foreground)]">{table.getFilteredRowModel().rows.length} {t.shown} · {selected.length} {t.selected}</span></div>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_11rem_auto]"><Input aria-label={t.search} placeholder={t.search} type="search" value={search} disabled={pending} onChange={event => { setSearch(event.target.value); setSelection({}); }} /><Select aria-label={t.status} value={status} disabled={pending} onChange={event => filterStatus(event.target.value)}><SelectItem value="all">{t.all}</SelectItem>{(["draft", "active", "archived"] as const).map(value => <SelectItem key={value} value={value}>{t[value]}</SelectItem>)}</Select><Button disabled={pending || (!search && !filters.length)} onClick={reset}>{t.reset}</Button></div>
<DataTable table={table} caption={t.records} emptyMessage={t.empty} />
<DataTablePagination table={table} labels={{ previous: t.previous, next: t.next, rowsPerPage: t.pageSize, page: (current, total) => `${t.page} ${current} ${t.of} ${total}` }} />
</section>
<section id={`${id}-activity`} aria-label={t.activity} className="grid scroll-mt-24 gap-3 border-t border-[var(--border)] pt-5"><h3 className="text-sm font-semibold">{t.activity}</h3>{activity.length ? <ol className="grid list-none gap-2 p-0 text-sm text-[var(--muted-foreground)]">{activity.map((message, index) => <li key={`${index}-${message}`} className="[overflow-wrap:anywhere]">{message}</li>)}</ol> : <p className="text-sm text-[var(--muted-foreground)]">{t.noActivity}</p>}</section>
<Sheet open={draft !== null} onOpenChange={open => { if (!open) requestClose(); }}><SheetContent finalFocus={opener} closeLabel={t.cancel}>
<div className="grid gap-2"><SheetTitle>{draft && records.some(record => record.id === draft.id) ? t.editTitle : t.newTitle}</SheetTitle><SheetDescription>{t.editorHint}</SheetDescription></div>
{draft && <Form key={draft.id} onSubmit={saveRecord} aria-busy={pending}><fieldset disabled={pending} className="grid min-w-0 gap-5 border-0 p-0"><Field name="title" validate={value => String(value).trim() ? null : t.required}><FieldLabel>{t.record}</FieldLabel><FieldControl render={<Input />} required maxLength={100} value={draft.title} onValueChange={title => { setDraft(current => current && { ...current, title }); setFailed(false); }} /><FieldError>{t.required}</FieldError></Field>
<div className="grid gap-2"><label htmlFor={`${id}-owner`} className="text-sm font-semibold">{t.owner}</label><Select id={`${id}-owner`} name="owner" value={draft.owner} onChange={event => setDraft(current => current && { ...current, owner: event.target.value })}>{Array.from(new Set([draft.owner, ...records.map(record => record.owner), "Alex", "Mina", "Jun"])).map(owner => <SelectItem key={owner} value={owner}>{owner}</SelectItem>)}</Select></div>
<div className="grid gap-2"><label htmlFor={`${id}-status`} className="text-sm font-semibold">{t.status}</label><Select id={`${id}-status`} name="status" value={draft.status} onChange={event => setDraft(current => current && { ...current, status: event.target.value as RecordStatus })}>{(["draft", "active", "archived"] as const).map(value => <SelectItem key={value} value={value}>{t[value]}</SelectItem>)}</Select></div>
<div className="grid gap-2"><label htmlFor={`${id}-due`} className="text-sm font-semibold">{t.due}</label><DatePicker id={`${id}-due`} label={t.due} name="dueDate" value={parseRecordDate(draft.dueDate)} onValueChange={date => setDraft(current => current && { ...current, dueDate: formatRecordDate(date) })} locale={dateLocales[locale]} placeholder={t.selectDate} clearLabel={t.clearDate} disabled={pending} /></div>
</fieldset>{error}<div className="flex flex-wrap justify-end gap-3 border-t border-[var(--border)] pt-5"><Button disabled={pending} onClick={requestClose}>{t.cancel}</Button><Button type="submit" variant="primary" disabled={pending}>{pending ? t.saving : t.save}</Button></div></Form>}
</SheetContent></Sheet>
<AlertDialog open={discardOpen} onOpenChange={setDiscardOpen}><AlertDialogContent><AlertDialogTitle>{t.discard}</AlertDialogTitle><AlertDialogDescription>{t.discardBody}</AlertDialogDescription><AlertDialogFooter><AlertDialogCancel>{t.keep}</AlertDialogCancel><Button variant="destructive" onClick={() => { setDiscardOpen(false); setDraft(null); setFailed(false); }}>{t.discardAction}</Button></AlertDialogFooter></AlertDialogContent></AlertDialog>
<AlertDialog open={deleteIds.length > 0} onOpenChange={open => { if (!open && !pending) { setDeleteIds([]); setFailed(false); } }}><AlertDialogContent finalFocus={deleteTrigger}><AlertDialogTitle>{t.confirm} ({deleteIds.length})</AlertDialogTitle><AlertDialogDescription>{t.deleteHint}</AlertDialogDescription>{error}<AlertDialogFooter><AlertDialogCancel disabled={pending}>{t.cancel}</AlertDialogCancel><Button variant="destructive" disabled={pending} onClick={deleteSelected}>{pending ? t.deleting : t.delete}</Button></AlertDialogFooter></AlertDialogContent></AlertDialog>
</div>;
}
export function DataManager(props: DataManagerProps) {
return <ToastProvider><RecordsEditor {...props} /><Toaster closeLabel={copy[props.locale ?? "en"].dismiss} /></ToastProvider>;
}
export function DataManagerExample({ locale = "en" }: { locale?: DataManagerProps["locale"] }) {
const [failNext, setFailNext] = React.useState(false); const t = copy[locale];
const operation = async () => { await new Promise(resolve => setTimeout(resolve, 350)); if (failNext) { setFailNext(false); throw new Error("Simulated operation failure"); } };
return <div className="grid gap-6"><div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-dashed border-[var(--border)] p-4 text-xs leading-relaxed text-[var(--muted-foreground)]"><p className="max-w-xl">{t.demo}</p><label className="flex items-center gap-2"><input type="checkbox" checked={failNext} onChange={event => setFailNext(event.target.checked)} />{t.fail}</label></div><DataManager initialRecords={exampleRecords} locale={locale} onSave={operation} onDelete={operation} /></div>;
}
export type { ManagedRecord, RecordStatus };
@components/blocks/data-manager-model.ts
@components/blocks/data-manager-model.ts
export type RecordStatus = "draft" | "active" | "archived";
export type ManagedRecord = { id: string; title: string; owner: string; status: RecordStatus; dueDate: string };
// Date-only values stay in local calendar time when passed to DatePicker.
export function parseRecordDate(value: string): Date | undefined {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
const [year, month, day] = value.split("-").map(Number);
const date = new Date(year, month - 1, day, 12);
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day ? date : undefined;
}
export function formatRecordDate(value: Date | undefined): string {
if (!value || !Number.isFinite(value.getTime())) return "";
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
}
export function upsertRecord(records: readonly ManagedRecord[], record: ManagedRecord): ManagedRecord[] {
return records.some(item => item.id === record.id) ? records.map(item => item.id === record.id ? { ...record } : item) : [{ ...record }, ...records];
}
export function removeRecords(records: readonly ManagedRecord[], ids: readonly string[]): ManagedRecord[] {
const selected = new Set(ids);
return records.filter(record => !selected.has(record.id));
}
export const exampleRecords: ManagedRecord[] = [
{ id: "REC-101", title: "Website accessibility review", owner: "Mina", status: "active", dueDate: "2026-10-02" },
{ id: "REC-102", title: "Profile settings", owner: "Alex", status: "draft", dueDate: "2026-10-05" },
{ id: "REC-103", title: "Customer onboarding", owner: "Jun", status: "active", dueDate: "2026-10-08" },
{ id: "REC-104", title: "Notification preferences", owner: "Alex", status: "draft", dueDate: "2026-10-09" },
{ id: "REC-105", title: "Monthly product report", owner: "Mina", status: "active", dueDate: "2026-10-12" },
{ id: "REC-106", title: "Legacy welcome screen", owner: "Jun", status: "archived", dueDate: "2026-09-01" },
{ id: "REC-107", title: "Keyboard navigation audit", owner: "Jun", status: "active", dueDate: "2026-10-14" },
{ id: "REC-108", title: "Content planning", owner: "Mina", status: "draft", dueDate: "2026-10-15" },
{ id: "REC-109", title: "Release notes", owner: "Alex", status: "active", dueDate: "2026-10-20" },
{ id: "REC-110", title: "Archived campaign", owner: "Mina", status: "archived", dueDate: "2026-08-20" },
{ id: "REC-111", title: "Mobile layout polish", owner: "Jun", status: "draft", dueDate: "2026-10-21" },
{ id: "REC-112", title: "Support documentation", owner: "Alex", status: "active", dueDate: "2026-10-22" },
];