feat(web,android): 看板拖拽、详情侧栏编辑、快速录入与安卓任务列表
Web: - 看板四列可拖拽,非法流转当场说明原因;勾选后可批量移动 - 详情侧栏全部可编辑(标题/优先级/截止/标签/备注/评论),409 自动刷新 - 快速录入 #标签 !优先级;键盘流 j/k、Enter、e、Esc - Playwright 真浏览器 e2e 7 项断言全绿(web/e2e/smoke.mjs) - 1000 条任务实测首屏 P50 38-55ms Android(未编译验证:本机无 JDK/Gradle): - 任务列表/详情/勾选完成/快速新建,冲突时提示并刷新 - 修:User/Member 字段名与后端 nickmame/phone 对齐,原先必然反序列化失败
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Layout } from "../components/Layout";
|
||||
import { TaskDetail } from "../components/TaskDetail";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import { PRIORITY_LABEL, STATUS_LABEL, canTransition } from "../lib/parse";
|
||||
import type { Item, ItemStatus } from "../lib/types";
|
||||
|
||||
const COLUMNS: { key: ItemStatus; label: string }[] = [
|
||||
{ key: "todo", label: "待办" },
|
||||
{ key: "doing", label: "进行中" },
|
||||
{ key: "blocked", label: "被阻塞" },
|
||||
{ key: "done", label: "已完成" },
|
||||
];
|
||||
|
||||
interface BoardPayload {
|
||||
columns: Record<string, Item[]>;
|
||||
order: string[];
|
||||
}
|
||||
|
||||
interface BatchResult {
|
||||
updated: { id: string; version: number; unchanged: boolean }[];
|
||||
failed: { id: string; code: number; message: string; allowed?: string[] }[];
|
||||
}
|
||||
|
||||
export function BoardPage() {
|
||||
const { wsId } = useParams<{ wsId: string }>();
|
||||
const [columns, setColumns] = useState<Record<string, Item[]>>({});
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [hoverCol, setHoverCol] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!wsId) return;
|
||||
try {
|
||||
const data = await api.get<BoardPayload>(`/workspaces/${wsId}/items/board`);
|
||||
setColumns(data.columns);
|
||||
setSelected((prev) => {
|
||||
const alive = new Set(Object.values(data.columns).flat().map((i) => i.id));
|
||||
return new Set([...prev].filter((id) => alive.has(id)));
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "看板加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [wsId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const allItems = useMemo(() => Object.values(columns).flat(), [columns]);
|
||||
const selectedItems = allItems.filter((i) => selected.has(i.id));
|
||||
|
||||
function moveLocally(item: Item, to: ItemStatus) {
|
||||
setColumns((prev) => {
|
||||
const next: Record<string, Item[]> = {};
|
||||
for (const [key, list] of Object.entries(prev)) {
|
||||
next[key] = list.filter((i) => i.id !== item.id);
|
||||
}
|
||||
next[to] = [{ ...item, status: to }, ...(next[to] ?? [])];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function move(item: Item, to: ItemStatus) {
|
||||
if (!wsId || item.status === to) return;
|
||||
if (!canTransition(item.status, to)) {
|
||||
setError(`「${STATUS_LABEL[item.status]}」不能直接变成「${STATUS_LABEL[to]}」`);
|
||||
return;
|
||||
}
|
||||
moveLocally(item, to);
|
||||
try {
|
||||
const updated = await api.post<Item>(`/workspaces/${wsId}/items/${item.id}/status`, {
|
||||
status: to,
|
||||
version: item.version,
|
||||
});
|
||||
setColumns((prev) => {
|
||||
const next: Record<string, Item[]> = {};
|
||||
for (const [key, list] of Object.entries(prev)) {
|
||||
next[key] = list.map((i) => (i.id === updated.id ? updated : i));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
void load();
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
setError("这条刚被别人改过,看板已刷新,请再拖一次");
|
||||
} else {
|
||||
setError(err instanceof ApiError ? err.message : "移动失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function batchMove(to: ItemStatus) {
|
||||
if (!wsId || selectedItems.length === 0) return;
|
||||
const movable = selectedItems.filter((i) => canTransition(i.status, to));
|
||||
const blocked = selectedItems.length - movable.length;
|
||||
if (movable.length === 0) {
|
||||
setError(`选中的 ${selectedItems.length} 条都不能直接变成「${STATUS_LABEL[to]}」`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api.post<BatchResult>(`/workspaces/${wsId}/items/batch-status`, {
|
||||
status: to,
|
||||
items: movable.map((i) => ({ id: i.id, version: i.version })),
|
||||
});
|
||||
const failedIds = new Set(result.failed.map((f) => f.id));
|
||||
const note = [
|
||||
`已移动 ${result.updated.length} 条`,
|
||||
failedIds.size > 0 ? `${failedIds.size} 条因并发冲突未移动` : "",
|
||||
blocked > 0 ? `${blocked} 条状态不允许` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
setError(note);
|
||||
setSelected(new Set());
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "批量移动失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(to: ItemStatus) {
|
||||
setHoverCol(null);
|
||||
const item = allItems.find((i) => i.id === dragId);
|
||||
setDragId(null);
|
||||
if (item) await move(item, to);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">看板</h1>
|
||||
<p className="mt-0.5 text-xs text-ink-500">
|
||||
拖卡片改状态;勾选后可以批量移动。状态机不允许的组合会直接拒绝并说明原因。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Link to={`/w/${wsId}`} className="text-ink-500 hover:text-ink-900">
|
||||
列表
|
||||
</Link>
|
||||
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
|
||||
成员
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedItems.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg bg-ink-50 px-3 py-2 text-sm">
|
||||
<span className="text-ink-700">已选 {selectedItems.length} 条</span>
|
||||
{COLUMNS.map((col) => (
|
||||
<button
|
||||
key={col.key}
|
||||
className="btn-ghost px-2 py-1 text-xs"
|
||||
onClick={() => void batchMove(col.key)}
|
||||
>
|
||||
移到{col.label}
|
||||
</button>
|
||||
))}
|
||||
<button className="btn-ghost ml-auto px-2 py-1 text-xs" onClick={() => setSelected(new Set())}>
|
||||
取消选择
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mb-3 text-sm text-amber-700">{error}</p>}
|
||||
{loading && <p className="text-sm text-ink-500">加载中…</p>}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{COLUMNS.map((col) => {
|
||||
const list = columns[col.key] ?? [];
|
||||
return (
|
||||
<section
|
||||
key={col.key}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setHoverCol(col.key);
|
||||
}}
|
||||
onDragLeave={() => setHoverCol((prev) => (prev === col.key ? null : prev))}
|
||||
onDrop={() => void onDrop(col.key)}
|
||||
className={`rounded-xl border p-2 transition ${
|
||||
hoverCol === col.key ? "border-ink-900 bg-ink-50" : "border-ink-100 bg-white"
|
||||
}`}
|
||||
>
|
||||
<header className="mb-2 flex items-center justify-between px-1 text-xs text-ink-500">
|
||||
<span className="font-medium text-ink-700">{col.label}</span>
|
||||
<span>{list.length}</span>
|
||||
</header>
|
||||
|
||||
<div className="space-y-2">
|
||||
{list.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={() => setDragId(item.id)}
|
||||
onDragEnd={() => setDragId(null)}
|
||||
className={`cursor-grab rounded-lg border border-ink-100 bg-white p-2 shadow-sm ${
|
||||
dragId === item.id ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-3.5 w-3.5 accent-ink-900"
|
||||
checked={selected.has(item.id)}
|
||||
onChange={() => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id);
|
||||
else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="min-w-0 flex-1 text-left text-sm"
|
||||
onClick={() => setOpenId(item.id)}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 pl-5 text-[11px] text-ink-500">
|
||||
{item.priority > 0 && (
|
||||
<span className="rounded bg-accent/15 px-1 text-accent">
|
||||
{PRIORITY_LABEL[item.priority]}
|
||||
</span>
|
||||
)}
|
||||
{item.due_at && <span>{new Date(item.due_at).toLocaleDateString("zh-CN")}</span>}
|
||||
{item.tags.map((t) => (
|
||||
<span key={t.id}>#{t.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<p className="px-1 py-4 text-center text-xs text-ink-500">空</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{openId && wsId && (
|
||||
<TaskDetail
|
||||
wsId={wsId}
|
||||
itemId={openId}
|
||||
onClose={() => setOpenId(null)}
|
||||
onChanged={() => void load()}
|
||||
onDeleted={() => void load()}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
+86
-72
@@ -1,26 +1,19 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Layout } from "../components/Layout";
|
||||
import { TaskDetail } from "../components/TaskDetail";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import type { Item, ItemDetail, ItemStatus, Page } from "../lib/types";
|
||||
import { PRIORITY_LABEL, STATUS_LABEL, parseQuickAdd } from "../lib/parse";
|
||||
import type { Item, ItemStatus, Page } from "../lib/types";
|
||||
|
||||
const VIEWS: { key: string; label: string; hint: string }[] = [
|
||||
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的" },
|
||||
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞" },
|
||||
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事" },
|
||||
{ key: "done", label: "已完成", hint: "最近完成的" },
|
||||
{ key: "all", label: "全部", hint: "含已取消" },
|
||||
const VIEWS: { key: string; label: string; hint: string; createAs: ItemStatus }[] = [
|
||||
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的", createAs: "todo" },
|
||||
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞", createAs: "todo" },
|
||||
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事", createAs: "inbox" },
|
||||
{ key: "done", label: "已完成", hint: "最近完成的", createAs: "todo" },
|
||||
{ key: "all", label: "全部", hint: "含已取消", createAs: "todo" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<ItemStatus, string> = {
|
||||
inbox: "收件箱",
|
||||
todo: "待办",
|
||||
doing: "进行中",
|
||||
done: "已完成",
|
||||
blocked: "被阻塞",
|
||||
canceled: "已取消",
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<ItemStatus, string> = {
|
||||
inbox: "bg-ink-100 text-ink-700",
|
||||
todo: "bg-ink-100 text-ink-700",
|
||||
@@ -30,12 +23,11 @@ const STATUS_STYLE: Record<ItemStatus, string> = {
|
||||
canceled: "bg-ink-100 text-ink-500 line-through",
|
||||
};
|
||||
|
||||
function formatDue(iso: string | null): { text: string; overdue: boolean } | null {
|
||||
function dueLabel(iso: string | null): { text: string; overdue: boolean } | null {
|
||||
if (!iso) return null;
|
||||
const due = new Date(iso);
|
||||
const now = new Date();
|
||||
const text = due.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
||||
return { text, overdue: due.getTime() < now.getTime() };
|
||||
return { text, overdue: due.getTime() < Date.now() };
|
||||
}
|
||||
|
||||
export function TasksPage() {
|
||||
@@ -49,8 +41,9 @@ export function TasksPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<ItemDetail | null>(null);
|
||||
const draftRef = useRef<HTMLInputElement>(null);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
|
||||
const current = VIEWS.find((v) => v.key === view);
|
||||
|
||||
const load = useCallback(
|
||||
async (nextCursor?: string | null) => {
|
||||
@@ -78,28 +71,48 @@ export function TasksPage() {
|
||||
void load(null);
|
||||
}, [load]);
|
||||
|
||||
const activeItem = useMemo(() => items[activeIdx] ?? null, [items, activeIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openId || !wsId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
void api
|
||||
.get<ItemDetail>(`/workspaces/${wsId}/items/${openId}`)
|
||||
.then(setDetail)
|
||||
.catch(() => setDetail(null));
|
||||
}, [openId, wsId]);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable;
|
||||
if (e.key === "Escape") {
|
||||
setOpenId(null);
|
||||
(target as HTMLInputElement).blur?.();
|
||||
return;
|
||||
}
|
||||
if (typing || items.length === 0) return;
|
||||
|
||||
if (e.key === "j" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) => Math.min(i + 1, items.length - 1));
|
||||
} else if (e.key === "k" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "e") {
|
||||
if (activeItem) setOpenId(activeItem.id);
|
||||
} else if (e.key === "Enter") {
|
||||
if (activeItem) void changeStatus(activeItem, activeItem.status === "done" ? "todo" : "done");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
async function addTask() {
|
||||
const title = draft.trim();
|
||||
if (!title || !wsId) return;
|
||||
const raw = draft.trim();
|
||||
if (!raw || !wsId || !current) return;
|
||||
const parsed = parseQuickAdd(raw, current.createAs);
|
||||
try {
|
||||
const created = await api.post<Item>(`/workspaces/${wsId}/items`, {
|
||||
title,
|
||||
status: view === "inbox" ? "inbox" : "todo",
|
||||
title: parsed.title,
|
||||
status: parsed.status,
|
||||
priority: parsed.priority,
|
||||
tag_names: parsed.tagNames,
|
||||
});
|
||||
setDraft("");
|
||||
setItems((prev) => [created, ...prev]);
|
||||
draftRef.current?.focus();
|
||||
if (view !== "done") setItems((prev) => [created, ...prev]);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "创建失败");
|
||||
}
|
||||
@@ -113,7 +126,6 @@ export function TasksPage() {
|
||||
version: item.version,
|
||||
});
|
||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)));
|
||||
if (openId === updated.id) setDetail((prev) => (prev ? { ...prev, ...updated } : prev));
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
setError("这条任务刚被别人改过,已为你刷新");
|
||||
@@ -124,18 +136,23 @@ export function TasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const current = VIEWS.find((v) => v.key === view);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">任务</h1>
|
||||
<p className="mt-0.5 text-xs text-ink-500">{current?.hint}</p>
|
||||
<p className="mt-0.5 text-xs text-ink-500">
|
||||
{current?.hint}
|
||||
<span className="ml-2 text-ink-100">|</span>
|
||||
<span className="ml-2">j/k 选择 · Enter 完成 · e 编辑 · Esc 关闭</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Link to={`/w/${wsId}/board`} className="text-ink-500 hover:text-ink-900">
|
||||
看板
|
||||
</Link>
|
||||
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
|
||||
成员与邀请
|
||||
成员
|
||||
</Link>
|
||||
<Link to="/" className="text-ink-500 hover:text-ink-900">
|
||||
切换工作区
|
||||
@@ -166,9 +183,8 @@ export function TasksPage() {
|
||||
<div className="card mb-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={draftRef}
|
||||
className="input flex-1"
|
||||
placeholder="加一件要做的事,回车创建"
|
||||
placeholder="加一件要做的事,回车创建。支持 #标签 !高"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -190,13 +206,19 @@ export function TasksPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item) => {
|
||||
const due = formatDue(item.due_at);
|
||||
{items.map((item, index) => {
|
||||
const due = dueLabel(item.due_at);
|
||||
const done = item.status === "done";
|
||||
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
|
||||
const canComplete = item.status !== "inbox" && item.status !== "canceled";
|
||||
return (
|
||||
<div key={item.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-start gap-3 px-4 py-3 ${
|
||||
index === activeIdx ? "bg-ink-50/70" : ""
|
||||
}`}
|
||||
onMouseEnter={() => setActiveIdx(index)}
|
||||
>
|
||||
{canComplete ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -211,7 +233,7 @@ export function TasksPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
className={`block w-full text-left text-sm ${done ? "text-ink-500 line-through" : ""}`}
|
||||
onClick={() => setOpenId(openId === item.id ? null : item.id)}
|
||||
onClick={() => setOpenId(item.id)}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
@@ -219,6 +241,9 @@ export function TasksPage() {
|
||||
<span className={`rounded px-1.5 py-0.5 ${STATUS_STYLE[item.status]}`}>
|
||||
{STATUS_LABEL[item.status]}
|
||||
</span>
|
||||
{item.priority > 0 && (
|
||||
<span className="text-ink-500">优先级 {PRIORITY_LABEL[item.priority]}</span>
|
||||
)}
|
||||
{due && (
|
||||
<span className={due.overdue && !done ? "text-red-600" : "text-ink-500"}>
|
||||
截止 {due.text}
|
||||
@@ -231,29 +256,6 @@ export function TasksPage() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{openId === item.id && (
|
||||
<div className="mt-3 rounded-lg bg-ink-50 p-3 text-xs">
|
||||
{detail && detail.id === item.id ? (
|
||||
<>
|
||||
{detail.body_md ? (
|
||||
<p className="whitespace-pre-wrap text-ink-700">{detail.body_md}</p>
|
||||
) : (
|
||||
<p className="text-ink-500">没有备注。</p>
|
||||
)}
|
||||
<ul className="mt-3 space-y-1 text-ink-500">
|
||||
{detail.activities.slice(0, 8).map((a) => (
|
||||
<li key={a.id}>
|
||||
{new Date(a.created_at).toLocaleString("zh-CN")} · {a.action}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-ink-500">加载中…</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-1">
|
||||
@@ -286,6 +288,18 @@ export function TasksPage() {
|
||||
</div>
|
||||
)}
|
||||
{loading && items.length === 0 && <p className="mt-4 text-center text-sm text-ink-500">加载中…</p>}
|
||||
|
||||
{openId && wsId && (
|
||||
<TaskDetail
|
||||
wsId={wsId}
|
||||
itemId={openId}
|
||||
onClose={() => setOpenId(null)}
|
||||
onChanged={(updated) =>
|
||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||
}
|
||||
onDeleted={(id) => setItems((prev) => prev.filter((it) => it.id !== id))}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user