Simplify checklist templates modal and add item-by-item editor
This commit is contained in:
parent
043f99a8fb
commit
3dab4b5118
@ -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"
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user