Simplify checklist templates modal and add item-by-item editor

This commit is contained in:
lizerk 2026-11-10 17:45:45 +03:00
parent 043f99a8fb
commit 3dab4b5118
2 changed files with 435 additions and 118 deletions

View File

@ -11389,10 +11389,11 @@ function TasksPageContent() {
setChecklistTemplateTarget("view");
setChecklistTemplatesOpen(true);
}}
className="app-action-secondary inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-2 text-xs"
title="Шаблоны чек-листов"
className="app-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
title="Открыть шаблоны чек-листов"
aria-label="Открыть шаблоны чек-листов"
>
<BookTemplate size={14} /> Шаблоны
<BookTemplate size={18} />
</button>
{checklistBadgeTotal > 0 ? (
<TaskCoverToggleButton
@ -13196,10 +13197,11 @@ function TasksPageContent() {
setChecklistTemplateTarget("form");
setChecklistTemplatesOpen(true);
}}
className="app-action-secondary inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-2 text-xs"
title="Шаблоны чек-листов"
className="app-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
title="Открыть шаблоны чек-листов"
aria-label="Открыть шаблоны чек-листов"
>
<BookTemplate size={14} /> Шаблоны
<BookTemplate size={18} />
</button>
<button
type="button"

View File

@ -1,10 +1,11 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
BookTemplate,
Check,
ChevronDown,
GripVertical,
Loader2,
Pencil,
Plus,
@ -13,6 +14,25 @@ import {
Users,
} from "lucide-react";
import {
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import type {
@ -48,6 +68,92 @@ function unwrap<T>(value: T[] | { results: T[] }): T[] {
return Array.isArray(value) ? value : value.results;
}
type ChecklistItemDraft = {
key: string;
title: string;
};
function draftItemsFromTitles(titles: string[]): ChecklistItemDraft[] {
return titles.map((title, index) => ({ key: `saved-${index}`, title }));
}
function draftItemsFromSource(sourceItems: string): ChecklistItemDraft[] {
return draftItemsFromTitles(
sourceItems
.split(/\r?\n/)
.map((title) => title.trim())
.filter(Boolean),
);
}
function SortableTemplateItem({
item,
index,
busy,
onChange,
onRemove,
}: {
item: ChecklistItemDraft;
index: number;
busy: boolean;
onChange: (title: string) => void;
onRemove: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.key, disabled: busy });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={`flex min-w-0 items-center gap-2 border-b border-[var(--border-subtle)] px-2 py-2 last:border-b-0 sm:px-3 ${isDragging ? "relative z-10 app-surface-elevated opacity-90" : ""}`}
>
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
type="button"
disabled={busy}
className="app-icon-button flex h-8 w-6 shrink-0 cursor-grab touch-none items-center justify-center rounded-md active:cursor-grabbing disabled:opacity-50"
aria-label={`Переместить пункт ${index + 1}`}
title="Перетащить, чтобы изменить порядок"
>
<GripVertical size={16} />
</button>
<span
className="h-4 w-4 shrink-0 rounded border-2 border-[var(--border-strong)]"
aria-hidden="true"
/>
<input
type="text"
value={item.title}
onChange={(event) => onChange(event.target.value)}
maxLength={500}
disabled={busy}
aria-label={`Название пункта ${index + 1}`}
className="app-input min-w-0 flex-1 rounded-lg px-2.5 py-2 text-sm"
/>
<button
type="button"
onClick={onRemove}
disabled={busy}
className="app-icon-button flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-[var(--danger-foreground)] disabled:opacity-50"
aria-label={`Удалить пункт ${index + 1}`}
title="Удалить пункт"
>
<Trash2 size={16} />
</button>
</div>
);
}
const tabs: { scope: TaskChecklistTemplateScope; label: string }[] = [
{ scope: "personal", label: "Мои" },
{ scope: "board", label: "Доски" },
@ -81,9 +187,24 @@ export default function TaskChecklistTemplatesModal({
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [proposingId, setProposingId] = useState<number | null>(null);
const [proposalBoardId, setProposalBoardId] = useState<number | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [itemsText, setItemsText] = useState("");
const [draftItems, setDraftItems] = useState<ChecklistItemDraft[]>([]);
const [newItemTitle, setNewItemTitle] = useState("");
const nextItemId = useRef(0);
const newItemInputRef = useRef<HTMLInputElement | null>(null);
const sortingSensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 180, tolerance: 6 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const [newEditorId, setNewEditorId] = useState<number | null>(null);
const [versions, setVersions] = useState<
Record<number, TaskChecklistTemplateRevision[]>
@ -99,10 +220,14 @@ export default function TaskChecklistTemplatesModal({
if (!isOpen) return;
let live = true;
setBoardId(currentBoardId ?? boards[0]?.id ?? null);
setProposalBoardId(currentBoardId ?? boards[0]?.id ?? null);
setProposingId(null);
setEditorOpen(false);
setTab("personal");
setName("");
setDescription("");
setItemsText(sourceItems);
setDraftItems(draftItemsFromSource(sourceItems));
setNewItemTitle("");
setEditingId(null);
setError(null);
setVersions({});
@ -127,7 +252,7 @@ export default function TaskChecklistTemplatesModal({
}, [isOpen, sourceItems, currentBoardId, boards]);
useEffect(() => {
if (!isOpen || !boardId) {
if (!isOpen || !boardId || tab !== "board") {
setRights(null);
setProposals([]);
return;
@ -151,7 +276,7 @@ export default function TaskChecklistTemplatesModal({
return () => {
live = false;
};
}, [isOpen, boardId]);
}, [isOpen, boardId, tab]);
const displayed = useMemo(
() =>
@ -170,20 +295,49 @@ export default function TaskChecklistTemplatesModal({
(tab === "company" && canPublish);
const canApply = draftTarget || taskId !== null;
const itemTitles = itemsText
.split("\n")
.map((value) => value.trim())
.filter(Boolean);
const itemTitles = draftItems.map((item) => item.title.trim());
const pendingNewItem = newItemTitle.trim();
const titlesToSave = pendingNewItem
? [...itemTitles, pendingNewItem]
: itemTitles;
const invalidItems = itemTitles.some((title) => !title);
const validItemCount = itemTitles.filter(Boolean).length;
const addItem = () => {
const title = newItemTitle.trim();
if (!title || draftItems.length >= 100 || busy) return;
const key = `new-${++nextItemId.current}`;
setDraftItems((current) => [...current, { key, title }]);
setNewItemTitle("");
requestAnimationFrame(() => newItemInputRef.current?.focus());
};
const sortItems = (event: DragEndEvent) => {
if (!event.over || event.active.id === event.over.id || busy) return;
setDraftItems((current) => {
const from = current.findIndex((item) => item.key === event.active.id);
const to = current.findIndex((item) => item.key === event.over?.id);
return from >= 0 && to >= 0 ? arrayMove(current, from, to) : current;
});
};
const resetEditor = () => {
setEditorOpen(false);
setEditingId(null);
setName("");
setDescription("");
setItemsText(sourceItems);
setDraftItems(draftItemsFromSource(sourceItems));
setNewItemTitle("");
};
const save = async () => {
if (busy || !name.trim() || !itemTitles.length || itemTitles.length > 100)
if (
busy ||
!name.trim() ||
!titlesToSave.length ||
invalidItems ||
titlesToSave.length > 100
)
return;
if (!editingId && !canCreateInTab) return;
setBusy(true);
@ -193,12 +347,12 @@ export default function TaskChecklistTemplatesModal({
? await apiClient.updateTaskChecklistTemplate(editingId, {
name: name.trim(),
description: description.trim(),
items: itemTitles,
items: titlesToSave,
})
: await apiClient.createTaskChecklistTemplate({
name: name.trim(),
description: description.trim(),
items: itemTitles,
items: titlesToSave,
scope: tab,
...(tab === "board" ? { board_id: boardId } : {}),
});
@ -216,12 +370,15 @@ export default function TaskChecklistTemplatesModal({
};
const edit = (template: TaskChecklistTemplate) => {
setEditorOpen(true);
setProposingId(null);
setTab(template.scope);
if (template.board_id !== null) setBoardId(template.board_id);
setEditingId(template.id);
setName(template.name);
setDescription(template.description);
setItemsText(template.items.join("\n"));
setDraftItems(draftItemsFromTitles(template.items));
setNewItemTitle("");
setError(null);
};
@ -268,16 +425,17 @@ export default function TaskChecklistTemplatesModal({
}
};
const propose = async (template: TaskChecklistTemplate) => {
if (!boardId || busy) return;
const propose = async (
template: TaskChecklistTemplate,
targetBoardId: number | null,
) => {
if (!targetBoardId || busy) return;
setBusy(true);
setError(null);
try {
const proposal = await apiClient.proposeTaskChecklistTemplate(
template.id,
boardId,
);
setProposals((current) => [proposal, ...current]);
await apiClient.proposeTaskChecklistTemplate(template.id, targetBoardId);
setBoardId(targetBoardId);
setProposingId(null);
setTab("board");
} catch (cause) {
setError(message(cause, "Не удалось отправить предложение"));
@ -363,7 +521,7 @@ export default function TaskChecklistTemplatesModal({
>
<div className="space-y-4">
<div
className="flex flex-wrap gap-1.5"
className="app-surface-muted grid grid-cols-3 gap-1 rounded-xl p-1"
role="tablist"
aria-label="Раздел библиотеки"
>
@ -375,19 +533,20 @@ export default function TaskChecklistTemplatesModal({
aria-selected={tab === scope}
onClick={() => {
setTab(scope);
setProposingId(null);
resetEditor();
}}
className={`rounded-lg px-3 py-2 text-sm font-medium ${tab === scope ? "app-action-primary" : "app-action-secondary"}`}
className={`min-w-0 rounded-lg px-2 py-2.5 text-center text-sm font-medium transition-colors ${tab === scope ? "app-action-primary" : "app-text-muted hover:text-[var(--foreground)]"}`}
>
{label}
</button>
))}
</div>
{tab === "board" || tab === "personal" ? (
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">
Доска {tab === "personal" ? "для предложения шаблона" : ""}
{tab === "board" ? (
<label className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
<span className="app-text-muted shrink-0 text-xs font-medium">
Доска
</span>
<select
className="app-input w-full rounded-lg px-3 py-2 text-sm"
@ -410,7 +569,28 @@ export default function TaskChecklistTemplatesModal({
</label>
) : null}
{canCreateInTab || editingId !== null ? (
{canCreateInTab && !editorOpen ? (
<button
type="button"
className="app-action-secondary flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-[var(--border-subtle)] px-3 py-2.5 text-sm font-medium"
onClick={() => {
setEditingId(null);
setName("");
setDescription("");
setDraftItems(draftItemsFromSource(sourceItems));
setNewItemTitle("");
setProposingId(null);
setEditorOpen(true);
}}
>
<Plus size={16} />
{sourceItems.trim()
? "Сохранить чек-лист как шаблон"
: "Создать шаблон"}
</button>
) : null}
{editorOpen && (canCreateInTab || editingId !== null) ? (
<section className="space-y-3 rounded-xl border border-[var(--border-subtle)] p-3">
<h3 className="text-sm font-semibold">
{editingId
@ -425,19 +605,95 @@ export default function TaskChecklistTemplatesModal({
placeholder="Название шаблона"
maxLength={255}
/>
<textarea
aria-label="Пункты шаблона чек-листа"
className="app-input w-full rounded-lg px-3 py-2 text-sm"
value={itemsText}
onChange={(event) => setItemsText(event.target.value)}
placeholder={
"Подготовить оборудование\nВыполнить проверку\nПодписать акт"
}
rows={5}
/>
<p className="app-text-muted text-xs">
{itemTitles.length} из 100 пунктов · один пункт в строке
</p>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Пункты чек-листа</span>
<span className="app-text-muted text-xs">
{validItemCount} / 100
</span>
</div>
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)]">
{draftItems.length > 0 ? (
<DndContext
sensors={sortingSensors}
collisionDetection={closestCenter}
onDragEnd={sortItems}
>
<SortableContext
items={draftItems.map((item) => item.key)}
strategy={verticalListSortingStrategy}
>
{draftItems.map((item, index) => (
<SortableTemplateItem
key={item.key}
item={item}
index={index}
busy={busy}
onChange={(title) =>
setDraftItems((current) =>
current.map((value) =>
value.key === item.key
? { ...value, title }
: value,
),
)
}
onRemove={() =>
setDraftItems((current) =>
current.filter((value) => value.key !== item.key),
)
}
/>
))}
</SortableContext>
</DndContext>
) : (
<p className="app-text-muted px-3 py-4 text-center text-sm">
Пунктов пока нет. Добавьте первый ниже.
</p>
)}
</div>
<div className="flex items-center gap-2">
<input
ref={newItemInputRef}
type="text"
className="app-input min-w-0 flex-1 rounded-xl px-3 py-2.5 text-sm"
aria-label="Новый пункт шаблона"
placeholder="Новый пункт"
maxLength={500}
value={newItemTitle}
disabled={busy || draftItems.length >= 100}
onChange={(event) => setNewItemTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
addItem();
}}
/>
<button
type="button"
onClick={addItem}
disabled={
busy || !newItemTitle.trim() || draftItems.length >= 100
}
className="app-icon-button flex h-10 w-10 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
title="Добавить пункт"
aria-label="Добавить пункт"
>
<Plus size={18} />
</button>
</div>
{draftItems.length > 1 ? (
<p className="app-text-muted text-xs">
Перетаскивайте пункты за маркер слева для изменения порядка.
</p>
) : null}
{invalidItems ? (
<p className="text-xs text-[var(--danger-foreground)]">
Заполните названия всех пунктов или удалите пустые.
</p>
) : null}
</div>
<input
aria-label="Описание шаблона"
className="app-input w-full rounded-lg px-3 py-2 text-sm"
@ -453,8 +709,9 @@ export default function TaskChecklistTemplatesModal({
disabled={
busy ||
!name.trim() ||
!itemTitles.length ||
itemTitles.length > 100
!titlesToSave.length ||
invalidItems ||
titlesToSave.length > 100
}
>
{busy ? (
@ -464,24 +721,23 @@ export default function TaskChecklistTemplatesModal({
)}
{editingId ? "Сохранить изменения" : "Сохранить шаблон"}
</button>
{editingId ? (
<button
type="button"
onClick={resetEditor}
className="app-action-secondary rounded-lg px-3 py-2 text-sm"
>
Отмена
</button>
) : null}
<button
type="button"
onClick={resetEditor}
disabled={busy}
className="app-action-secondary rounded-lg px-3 py-2 text-sm disabled:opacity-50"
>
Отмена
</button>
</div>
</section>
) : (
) : !canCreateInTab ? (
<p className="app-text-muted text-xs">
{tab === "board"
? "Публиковать здесь может создатель доски или назначенный редактор. Остальные сотрудники предлагают личные шаблоны."
: "Публикация корпоративных шаблонов доступна администраторам и уполномоченным сотрудникам."}
? "Публиковать может владелец доски или назначенный редактор. Вы можете предложить свой шаблон из вкладки «Мои»."
: "Корпоративные шаблоны публикуют уполномоченные сотрудники."}
</p>
)}
) : null}
<section className="space-y-2">
<h3 className="text-sm font-semibold">
@ -497,7 +753,9 @@ export default function TaskChecklistTemplatesModal({
</p>
) : displayed.length === 0 ? (
<p className="app-text-muted rounded-lg border border-[var(--border-subtle)] p-3 text-sm">
Пока нет опубликованных шаблонов.
{tab === "personal"
? "У вас пока нет шаблонов. Создайте первый."
: "В этом разделе пока нет шаблонов."}
</p>
) : (
displayed.map((template) => (
@ -538,15 +796,23 @@ export default function TaskChecklistTemplatesModal({
onClick={() => void apply(template)}
className="app-action-primary rounded-lg px-3 py-2 text-xs disabled:opacity-50"
>
Добавить в чек-лист
Применить
</button>
) : null}
{tab === "personal" && boardId ? (
{tab === "personal" && boards.length > 0 ? (
<button
type="button"
disabled={busy}
onClick={() => void propose(template)}
className="app-action-secondary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs disabled:opacity-50"
onClick={() => {
setProposingId((current) =>
current === template.id ? null : template.id,
);
setProposalBoardId(
currentBoardId ?? boards[0]?.id ?? null,
);
}}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs disabled:opacity-50"
aria-expanded={proposingId === template.id}
>
<Send size={13} /> Предложить на доску
</button>
@ -579,6 +845,47 @@ export default function TaskChecklistTemplatesModal({
<ChevronDown size={13} /> История
</button>
</div>
{tab === "personal" && proposingId === template.id ? (
<div className="app-surface-muted mt-3 space-y-2 rounded-lg p-3">
<label className="block space-y-1">
<span className="app-text-muted text-xs font-medium">
Куда отправить на согласование
</span>
<select
className="app-input w-full rounded-lg px-3 py-2 text-sm"
value={proposalBoardId ?? ""}
onChange={(event) =>
setProposalBoardId(Number(event.target.value) || null)
}
aria-label="Доска для предложения"
>
{boards.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
</option>
))}
</select>
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={busy || !proposalBoardId}
onClick={() => void propose(template, proposalBoardId)}
className="app-action-primary rounded-lg px-3 py-2 text-xs disabled:opacity-50"
>
Отправить
</button>
<button
type="button"
disabled={busy}
onClick={() => setProposingId(null)}
className="app-action-secondary rounded-lg px-3 py-2 text-xs"
>
Отмена
</button>
</div>
</div>
) : null}
{openVersions === template.id ? (
<div className="app-surface-muted mt-3 space-y-1 rounded-lg p-2 text-xs">
{(versions[template.id] || []).map((version) => (
@ -661,63 +968,71 @@ export default function TaskChecklistTemplatesModal({
) : null}
{tab === "board" && rights?.is_owner ? (
<section className="space-y-3 rounded-xl border border-[var(--border-subtle)] p-3">
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Users size={16} /> Редакторы шаблонов доски
</h3>
<p className="app-text-muted text-xs">
Назначение редактора даёт право публиковать и менять шаблоны этой
доски, но не расширяет доступ к задачам.
</p>
{rights.editor_ids.map((id) => (
<div
key={id}
className="flex items-center justify-between gap-2 text-xs"
>
<span>{userLabel(id)}</span>
<details className="rounded-xl border border-[var(--border-subtle)] p-3">
<summary className="flex cursor-pointer items-center gap-2 text-sm font-semibold">
<Users size={16} />
Редакторы шаблонов доски
<span className="app-text-muted ml-auto text-xs font-normal">
{rights.editor_ids.length}
</span>
</summary>
<div className="mt-3 space-y-3">
<p className="app-text-muted text-xs">
Назначение редактора даёт право публиковать и менять шаблоны
этой доски, но не расширяет доступ к задачам.
</p>
{rights.editor_ids.map((id) => (
<div
key={id}
className="flex items-center justify-between gap-2 text-xs"
>
<span>{userLabel(id)}</span>
<button
type="button"
disabled={busy}
onClick={() => void setEditor(id, false)}
className="app-action-danger rounded-lg px-2 py-1.5"
>
Отозвать
</button>
</div>
))}
<div className="flex gap-2">
<select
className="app-input min-w-0 flex-1 rounded-lg px-2 py-2 text-sm"
value={newEditorId ?? ""}
onChange={(event) =>
setNewEditorId(Number(event.target.value) || null)
}
aria-label="Выберите редактора шаблонов доски"
>
<option value="">Выберите сотрудника</option>
{editorCandidates
.filter(
(employee) =>
employee.is_active &&
employee.id !== userId &&
!rights.editor_ids.includes(employee.id),
)
.map((employee) => (
<option key={employee.id} value={employee.id}>
{userLabel(employee.id)}
</option>
))}
</select>
<button
type="button"
disabled={busy}
onClick={() => void setEditor(id, false)}
className="app-action-danger rounded-lg px-2 py-1.5"
disabled={busy || !newEditorId}
onClick={() =>
newEditorId && void setEditor(newEditorId, true)
}
className="app-action-primary rounded-lg px-3 py-2 text-xs"
>
Отозвать
Назначить
</button>
</div>
))}
<div className="flex gap-2">
<select
className="app-input min-w-0 flex-1 rounded-lg px-2 py-2 text-sm"
value={newEditorId ?? ""}
onChange={(event) =>
setNewEditorId(Number(event.target.value) || null)
}
aria-label="Выберите редактора шаблонов доски"
>
<option value="">Выберите сотрудника</option>
{editorCandidates
.filter(
(employee) =>
employee.is_active &&
employee.id !== userId &&
!rights.editor_ids.includes(employee.id),
)
.map((employee) => (
<option key={employee.id} value={employee.id}>
{userLabel(employee.id)}
</option>
))}
</select>
<button
type="button"
disabled={busy || !newEditorId}
onClick={() => newEditorId && void setEditor(newEditorId, true)}
className="app-action-primary rounded-lg px-3 py-2 text-xs"
>
Назначить
</button>
</div>
</section>
</details>
) : null}
{canApply ? (