Data management
Search, status filters, sorting, pagination, selection, create/edit and confirmed deletion.
Interactive local example. Reload resets the data. Connect onSave and onDelete to your service.
WORKSPACE / RECORDS
Small details. Clear progress.
Find the work that matters, keep it current and move it forward.
All records12
Active6
Draft4
Records
12 matching records · 0 selected
| Status | Actions | ||||
|---|---|---|---|---|---|
| Archived campaign | Mina | Archived | 2026-08-20 | ||
| Content planning | Mina | Draft | 2026-10-15 | ||
| Customer onboarding | Jun | Active | 2026-10-08 | ||
| Keyboard navigation audit | Jun | Active | 2026-10-14 | ||
| Legacy welcome screen | Jun | Archived | 2026-09-01 |
Page 1 of 3
Recent activity
Changes made in this view will appear here.
Install
Set up the base theme and Pretendard once using the installation guide. Adding these templates does not overwrite your theme, global CSS or page routes.
Installation: template-data-manager
npx shadcn@latest add @neumorphism-ui/template-data-managerUsage
Usage
"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="en" />;
}Connect your application
The demos run in memory and reset on reload. Connect onSave and onDelete to your authenticated service. initialValues and initialRecords are initial snapshots; change the component key when switching data sets.
Connect the Example callbacks below to your real persistence handlers.
Installed source
@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" },
];