import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Alert, Autocomplete, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Grid2 as Grid, IconButton, TextField, Tooltip, Typography, } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import ChecklistIcon from '@mui/icons-material/Checklist'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import DeleteIcon from '@mui/icons-material/DeleteOutline'; import EditIcon from '@mui/icons-material/EditOutlined'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '../api/client'; import type { Checklist, ChecklistItemRow } from '../api/types'; import { useAuth } from '../auth/AuthContext'; import { useChecklists } from '../lib/queries'; import { useConfirm, useNotify } from '../ui/feedback'; import { Card, EmptyState, ErrorState, LoadingState, PageHeader } from '../ui/layout'; import { colors } from '../ui/theme'; // Пункт в форме: сохранённый (id) или новый текст. usedIn — во скольких чеклистах компании он есть. type ItemForm = { key: string; id?: string; text: string; usedIn: number }; type Form = { name: string; description: string; items: ItemForm[] }; const toForm = (c: Checklist): Form => ({ name: c.name, description: c.description || '', items: c.items.map((i) => ({ key: i.id, id: i.id, text: i.text, usedIn: i.usedIn })), }); // Сравнение текстов — как на сервере: без регистра, пробелов по краям и «ё»/«е». const norm = (s: string) => s.trim().replace(/\s+/g, ' ').toLowerCase().replace(/ё/g, 'е'); export function ChecklistsPage() { const { checklistId } = useParams(); const navigate = useNavigate(); const { can } = useAuth(); const list = useChecklists(); return ( <> } onClick={() => navigate('/checklists/new')}> Новый чеклист ) } /> {list.isLoading ? ( ) : list.isError ? ( list.refetch()} /> ) : ( {list.data?.length === 0 && checklistId !== 'new' && ( Чеклистов пока нет. )} {list.data?.map((c) => ( navigate(`/checklists/${c.id}`)} sx={{ p: 1.75, borderRadius: '12px', cursor: 'pointer', bgcolor: '#fff', border: `1px solid ${checklistId === c.id ? colors.brand : colors.line}`, boxShadow: checklistId === c.id ? `0 0 0 3px ${colors.brandSoft}` : 'none', }} > {c.name} Пунктов: {c.items.length} · {c.checkpointsCount ? `на точках: ${c.checkpointsCount}` : 'не прикреплён к точкам'} ))} {checklistId ? ( c.id === checklistId)} /> ) : ( {can('routes.manage') ? 'или создайте новый. Чеклисты прикрепляются к контрольным точкам на странице «Маршруты».' : null} )} )} ); } function ChecklistForm({ checklistId, existing }: { checklistId: string; existing?: Checklist }) { const isNew = checklistId === 'new'; const navigate = useNavigate(); const qc = useQueryClient(); const notify = useNotify(); const confirm = useConfirm(); const { can } = useAuth(); const editable = can('routes.manage'); const [form, setForm] = useState
(() => (existing ? toForm(existing) : { name: '', description: '', items: [] })); const [renaming, setRenaming] = useState(null); // Список загрузился уже после открытия формы — подставляем. useEffect(() => { if (existing && !form.name && !form.items.length) setForm(toForm(existing)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [existing]); const save = useMutation({ mutationFn: () => { const body = { name: form.name.trim(), description: form.description.trim() || null, items: form.items.map((i) => (i.id ? { id: i.id } : { text: i.text.trim() })), }; return isNew ? api('/checklists', { body }) : api(`/checklists/${checklistId}`, { method: 'PUT', body }); }, onSuccess: (saved) => { // Сразу кладём в список, чтобы после перехода на /checklists/:id форма не мигнула «не найден». qc.setQueryData(['checklists'], (old) => (old ? [...old.filter((c) => c.id !== saved.id), saved] : [saved])); qc.invalidateQueries({ queryKey: ['checklists'] }); qc.invalidateQueries({ queryKey: ['routes'] }); setForm(toForm(saved)); notify.success('Чеклист сохранён'); if (isNew) navigate(`/checklists/${saved.id}`, { replace: true }); }, onError: notify.error, }); const archive = async () => { const ok = await confirm({ title: 'Убрать чеклист в архив?', message: existing?.checkpointsCount ? `Он снимется с точек (${existing.checkpointsCount}). Прошлые отчёты сохранятся.` : 'Прошлые отчёты сохранятся.', confirmText: 'В архив', danger: true, }); if (!ok) return; try { await api(`/checklists/${checklistId}/archive`, { method: 'POST' }); qc.invalidateQueries({ queryKey: ['checklists'] }); qc.invalidateQueries({ queryKey: ['routes'] }); notify.success('Чеклист в архиве'); navigate('/checklists'); } catch (e) { notify.error(e); } }; const add = (picked: ChecklistItemRow | string) => { const text = typeof picked === 'string' ? picked.trim().replace(/\s+/g, ' ') : picked.text; if (!text) return; if (form.items.some((i) => norm(i.text) === norm(text))) { notify.info('Такой пункт уже есть в этом чеклисте'); return; } const item: ItemForm = typeof picked === 'string' ? { key: crypto.randomUUID(), text, usedIn: 0 } : { key: picked.id, id: picked.id, text: picked.text, usedIn: picked.usedIn }; setForm((f) => ({ ...f, items: [...f.items, item] })); }; const move = (i: number, dir: -1 | 1) => setForm((f) => { const items = [...f.items]; const j = i + dir; if (j < 0 || j >= items.length) return f; [items[i], items[j]] = [items[j], items[i]]; return { ...f, items }; }); if (!isNew && !existing) return ; return ( setForm({ ...form, name: e.target.value })} required disabled={!editable} placeholder="Например: Периметр ночью" /> setForm({ ...form, description: e.target.value })} disabled={!editable} /> {form.items.map((it, i) => ( {i + 1}. {it.text} {it.id && it.usedIn > 1 && ( Общий пункт: есть ещё в {it.usedIn - 1} чеклист{it.usedIn - 1 === 1 ? 'е' : 'ах'} )} {!it.id && Новый пункт — появится после сохранения} {editable && ( {it.id && ( setRenaming(it)}> )} move(i, -1)} disabled={i === 0}> move(i, 1)} disabled={i === form.items.length - 1}> setForm((f) => ({ ...f, items: f.items.filter((x) => x.key !== it.key) }))}> )} ))} {editable && i.id).filter((x): x is string => !!x)} />} {!isNew && existing && ( {existing.routes.length ? ( {existing.routes.map((r) => (
  • {r.siteName} · маршрут «{r.name}»
  • ))}
    ) : ( Не прикреплён ни к одной точке. Прикрепить можно на странице «Маршруты», у контрольной точки. )}
    )} {editable && ( {!isNew && ( )} )} {renaming && ( setRenaming(null)} onDone={(text) => { setForm((f) => ({ ...f, items: f.items.map((x) => (x.id === renaming.id ? { ...x, text } : x)) })); qc.invalidateQueries({ queryKey: ['checklists'] }); qc.invalidateQueries({ queryKey: ['routes'] }); setRenaming(null); }} /> )}
    ); } // Поле добавления пункта: подсказки из справочника компании, Enter — добавить свой текст. function ItemPicker({ onPick, exclude }: { onPick: (v: ChecklistItemRow | string) => void; exclude: string[] }) { const [input, setInput] = useState(''); const [options, setOptions] = useState([]); useEffect(() => { const ctrl = new AbortController(); const t = window.setTimeout(() => { api(`/checklist-items?q=${encodeURIComponent(input.trim())}`, { signal: ctrl.signal }) .then(setOptions) .catch(() => undefined); }, 250); return () => { ctrl.abort(); window.clearTimeout(t); }; }, [input]); return ( freeSolo size="small" options={options.filter((o) => !exclude.includes(o.id))} filterOptions={(x) => x} getOptionLabel={(o) => (typeof o === 'string' ? o : o.text)} inputValue={input} value={null} onInputChange={(_, v, reason) => reason !== 'reset' && setInput(v)} onChange={(_, v) => { if (!v) return; onPick(v); setInput(''); }} renderOption={(props, o) => (
  • {o.text} {o.usedIn > 0 && в чеклистах: {o.usedIn}}
  • )} renderInput={(params) => ( )} sx={{ mt: 0.5 }} /> ); } function RenameDialog({ item, onClose, onDone }: { item: ItemForm; onClose: () => void; onDone: (text: string) => void }) { const notify = useNotify(); const [text, setText] = useState(item.text); const [busy, setBusy] = useState(false); const save = async () => { setBusy(true); try { const r = await api<{ id: string; text: string }>(`/checklist-items/${item.id}`, { method: 'PATCH', body: { text: text.trim() } }); notify.success('Пункт переименован'); onDone(r.text); } catch (e) { notify.error(e); } finally { setBusy(false); } }; return ( Переименовать пункт {item.usedIn > 1 && ( Пункт общий: новое название будет во всех чеклистах, где он есть ({item.usedIn}). В прошлых отчётах останется старое. )} setText(e.target.value)} sx={{ mt: item.usedIn > 1 ? 0 : 1 }} /> ); }