← 全部模板

数据管理

搜索任务,管理负责人和截止日期。演示会在刷新后重置。

模板预览

template-data-manager

任务列表

管理任务标题、负责人、状态和截止日期。

全部数据
12
进行中
6
草稿
4

数据

12 项结果 · 0 项已选择
数据
状态操作
Archived campaignMina已归档2026-08-20
Content planningMina草稿2026-10-15
Customer onboardingJun进行中2026-10-08
Keyboard navigation auditJun进行中2026-10-14
Legacy welcome screenJun已归档2026-09-01
页 1 / 3

最近变更

此视图中的变更将显示在这里。

这是本地示例,刷新后重置。请将 onSave 和 onDelete 连接到实际服务。

安装

按照安装指南设置一次基础主题和 Pretendard。模板不会覆盖已有主题、全局 CSS 或页面路由。

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<ManagedRecord>;
  deleteRecords: (ids: string[]) => Promise<void>;
}) {
  return <DataManager initialRecords={records} onSave={saveRecord} onDelete={deleteRecords} locale="zh" />;
}

连接应用

演示仅在内存中运行,刷新后重置。将 onSave 和 onDelete 连接到已认证的服务。initialValues 和 initialRecords 是初始快照;切换数据时请更改组件 key。 onSave 必须返回服务保存后的完整 ManagedRecord,包含最终 ID 和规范化字段。列表与保存提示使用返回值,后续编辑和删除使用返回的 ID。

请把实际保存操作连接到以下 Example 回调。

安装源码

@components/blocks/data-manager.tsx
"use client";

import * as React from "react";
import { useTable, tableFeatures, columnFilteringFeature, columnVisibilityFeature, globalFilteringFeature, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, createFilteredRowModel, createSortedRowModel, createPaginatedRowModel, filterFn_includesString, filterFn_equalsString, sortFn_alphanumeric, sortFn_text, type ColumnDef, type SortingState, type RowSelectionState, type ColumnFiltersState, type ColumnVisibilityState } from "@tanstack/react-table";
import { ko, ja, zhCN } from "@daypicker/react/locale";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
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<ManagedRecord>; onDelete: (ids: string[]) => Promise<void>; locale?: "ko" | "en" | "ja" | "zh" };
const copy = {
  en: { title: "Task records", intro: "Manage titles, owners, statuses and due dates.", 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: "The selected records will be removed. 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: { 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: { 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: { 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 };
const features = tableFeatures({
  columnFilteringFeature, columnVisibilityFeature, globalFilteringFeature,
  rowPaginationFeature, rowSelectionFeature, rowSortingFeature,
  filteredRowModel: createFilteredRowModel(), sortedRowModel: createSortedRowModel(), paginatedRowModel: createPaginatedRowModel(),
  filterFns: { includesString: filterFn_includesString, equalsString: filterFn_equalsString },
  sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },
});

function RecordsEditor({ initialRecords, onSave, onDelete, locale = "en" }: DataManagerProps) {
  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<ColumnVisibilityState>({});
  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<typeof features, 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 = useTable({ features, 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", initialState: { pagination: { pageIndex: 0, 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 { const saved = await onSave(snapshot); setRecords(current => upsertRecord(current, saved, snapshot.id)); setSelection({}); setDraft(null); setActivity(current => [`${t.saved}: ${saved.title}`, ...current].slice(0, 4)); toast.add({ title: t.saved, description: saved.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="@container/workspace mx-auto grid w-full min-w-0 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-2"><h2 className="text-2xl font-bold leading-tight tracking-tight">{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>
    <dl data-slot="workspace-metrics" className="grid min-w-0 grid-cols-3 gap-3 @min-[36rem]/workspace:gap-5">
      {([
        ["all", t.all, records.length],
        ["active", t.active, records.filter(record => record.status === "active").length],
        ["draft", t.draft, records.filter(record => record.status === "draft").length],
      ] as const).map(([key, text, count]) => (
        <Card key={key} variant={key === "active" ? "accent" : "raised"} className="@container/metric grid grid-rows-[minmax(2.5rem,auto)_auto] min-h-32 min-w-0 content-center justify-items-center gap-2 rounded-[calc(var(--neu-radius-surface)*0.5)] px-3 py-5 text-center @min-[36rem]/workspace:px-5">
          <dt className={`row-start-2 min-h-[3em] max-w-full text-balance text-xs font-medium leading-normal [overflow-wrap:anywhere] ${key === "active" ? "text-[var(--primary-foreground)]" : "text-[var(--muted-foreground)]"}`}>{text}</dt>
          <dd data-metric={key} className="row-start-1 m-0 max-w-full self-end text-[clamp(1.375rem,24cqi,2.25rem)] font-extrabold leading-[1.1] tracking-tight tabular-nums [overflow-wrap:anywhere]">{count}</dd>
        </Card>
      ))}
    </dl>
    <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)}><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" className="size-4 shrink-0"><path d="M12 5v14M5 12h14" /></svg>{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"><DataManager initialRecords={exampleRecords} locale={locale} onSave={async record => { await operation(); return record; }} onDelete={operation} /><div data-demo-controls className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--border)] pt-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></div>;
}
export type { ManagedRecord, RecordStatus };
@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, requestedId: string): ManagedRecord[] {
  return records.some(item => item.id === requestedId) ? records.map(item => item.id === requestedId ? { ...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" },
];