← 모든 템플릿

데이터 관리

작업을 검색하고 담당자와 마감일을 관리합니다. 데모는 새로고침하면 초기화됩니다.

템플릿 미리보기

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="ko" />;
}

연결 방식

아래 데모는 메모리에서만 동작하며 새로고침하면 초기화됩니다. 실제 컴포넌트의 onSave·onDelete 콜백에 인증된 서비스를 연결하세요. initialValues·initialRecords는 최초 값이므로 다른 데이터로 전환할 때 key를 바꾸세요. onSave는 서버가 저장한 최종 ID와 필드를 담은 ManagedRecord를 반환해야 합니다. 목록과 저장 알림에는 응답 값이 반영되며, 이후 수정·삭제에는 반환된 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" },
];