387 lines
16 KiB
TypeScript
387 lines
16 KiB
TypeScript
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 (
|
||
<>
|
||
<PageHeader
|
||
title="Чеклисты"
|
||
subtitle="Списки пунктов, которые охранник отмечает на контрольных точках"
|
||
actions={
|
||
can('routes.manage') && (
|
||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/checklists/new')}>
|
||
Новый чеклист
|
||
</Button>
|
||
)
|
||
}
|
||
/>
|
||
{list.isLoading ? (
|
||
<LoadingState />
|
||
) : list.isError ? (
|
||
<ErrorState error={list.error} onRetry={() => list.refetch()} />
|
||
) : (
|
||
<Grid container spacing={3}>
|
||
<Grid size={{ xs: 12, md: 4 }}>
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||
{list.data?.length === 0 && checklistId !== 'new' && (
|
||
<Typography sx={{ color: colors.muted, px: 1 }}>Чеклистов пока нет.</Typography>
|
||
)}
|
||
{list.data?.map((c) => (
|
||
<Box
|
||
key={c.id}
|
||
onClick={() => 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',
|
||
}}
|
||
>
|
||
<Typography sx={{ fontWeight: 600 }}>{c.name}</Typography>
|
||
<Typography sx={{ color: colors.muted, fontSize: 13 }}>
|
||
Пунктов: {c.items.length} · {c.checkpointsCount ? `на точках: ${c.checkpointsCount}` : 'не прикреплён к точкам'}
|
||
</Typography>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Grid>
|
||
<Grid size={{ xs: 12, md: 8 }}>
|
||
{checklistId ? (
|
||
<ChecklistForm key={checklistId} checklistId={checklistId} existing={list.data?.find((c) => c.id === checklistId)} />
|
||
) : (
|
||
<Card>
|
||
<EmptyState icon={ChecklistIcon} title="Выберите чеклист">
|
||
{can('routes.manage') ? 'или создайте новый. Чеклисты прикрепляются к контрольным точкам на странице «Маршруты».' : null}
|
||
</EmptyState>
|
||
</Card>
|
||
)}
|
||
</Grid>
|
||
</Grid>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
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<Form>(() => (existing ? toForm(existing) : { name: '', description: '', items: [] }));
|
||
const [renaming, setRenaming] = useState<ItemForm | null>(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<Checklist>('/checklists', { body }) : api<Checklist>(`/checklists/${checklistId}`, { method: 'PUT', body });
|
||
},
|
||
onSuccess: (saved) => {
|
||
// Сразу кладём в список, чтобы после перехода на /checklists/:id форма не мигнула «не найден».
|
||
qc.setQueryData<Checklist[]>(['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 <Card><EmptyState title="Чеклист не найден" /></Card>;
|
||
|
||
return (
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
<Card title={isNew ? 'Новый чеклист' : 'Чеклист'}>
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
<TextField label="Название" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required disabled={!editable} placeholder="Например: Периметр ночью" />
|
||
<TextField label="Описание" multiline minRows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} disabled={!editable} />
|
||
</Box>
|
||
</Card>
|
||
|
||
<Card title="Пункты" subtitle={form.items.length ? `${form.items.length} шт. Охранник ставит галочку напротив каждого` : 'Пока пусто'} flush>
|
||
<Box sx={{ px: 2, pb: 2, pt: 1.5, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||
{form.items.map((it, i) => (
|
||
<Box key={it.key} sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1.25, pl: 1.75, border: `1px solid ${colors.line}`, borderRadius: '12px', bgcolor: '#fff' }}>
|
||
<Typography sx={{ color: colors.muted, fontSize: 13, minWidth: 18 }}>{i + 1}.</Typography>
|
||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||
<Typography sx={{ fontSize: 15 }}>{it.text}</Typography>
|
||
{it.id && it.usedIn > 1 && (
|
||
<Typography sx={{ color: colors.muted, fontSize: 12.5 }}>Общий пункт: есть ещё в {it.usedIn - 1} чеклист{it.usedIn - 1 === 1 ? 'е' : 'ах'}</Typography>
|
||
)}
|
||
{!it.id && <Typography sx={{ color: colors.muted, fontSize: 12.5 }}>Новый пункт — появится после сохранения</Typography>}
|
||
</Box>
|
||
{editable && (
|
||
<Box sx={{ display: 'flex', flexShrink: 0 }}>
|
||
{it.id && (
|
||
<Tooltip title="Переименовать пункт">
|
||
<IconButton size="small" onClick={() => setRenaming(it)}>
|
||
<EditIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
)}
|
||
<Tooltip title="Выше">
|
||
<span>
|
||
<IconButton size="small" onClick={() => move(i, -1)} disabled={i === 0}>
|
||
<ArrowUpwardIcon fontSize="small" />
|
||
</IconButton>
|
||
</span>
|
||
</Tooltip>
|
||
<Tooltip title="Ниже">
|
||
<span>
|
||
<IconButton size="small" onClick={() => move(i, 1)} disabled={i === form.items.length - 1}>
|
||
<ArrowDownwardIcon fontSize="small" />
|
||
</IconButton>
|
||
</span>
|
||
</Tooltip>
|
||
<Tooltip title="Убрать из чеклиста">
|
||
<IconButton size="small" onClick={() => setForm((f) => ({ ...f, items: f.items.filter((x) => x.key !== it.key) }))}>
|
||
<DeleteIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
))}
|
||
{editable && <ItemPicker onPick={add} exclude={form.items.map((i) => i.id).filter((x): x is string => !!x)} />}
|
||
</Box>
|
||
</Card>
|
||
|
||
{!isNew && existing && (
|
||
<Card title="Где используется">
|
||
{existing.routes.length ? (
|
||
<Box component="ul" sx={{ m: 0, pl: 2.5 }}>
|
||
{existing.routes.map((r) => (
|
||
<li key={r.id}>
|
||
<Typography sx={{ fontSize: 14 }}>
|
||
{r.siteName} · маршрут «{r.name}»
|
||
</Typography>
|
||
</li>
|
||
))}
|
||
</Box>
|
||
) : (
|
||
<Typography sx={{ color: colors.muted, fontSize: 14 }}>Не прикреплён ни к одной точке. Прикрепить можно на странице «Маршруты», у контрольной точки.</Typography>
|
||
)}
|
||
</Card>
|
||
)}
|
||
|
||
{editable && (
|
||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||
{!isNew && (
|
||
<Button color="error" onClick={archive}>
|
||
В архив
|
||
</Button>
|
||
)}
|
||
<Button variant="contained" onClick={() => save.mutate()} disabled={!form.name.trim() || save.isPending}>
|
||
{save.isPending ? 'Сохраняем…' : 'Сохранить чеклист'}
|
||
</Button>
|
||
</Box>
|
||
)}
|
||
|
||
{renaming && (
|
||
<RenameDialog
|
||
item={renaming}
|
||
onClose={() => 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);
|
||
}}
|
||
/>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// Поле добавления пункта: подсказки из справочника компании, Enter — добавить свой текст.
|
||
function ItemPicker({ onPick, exclude }: { onPick: (v: ChecklistItemRow | string) => void; exclude: string[] }) {
|
||
const [input, setInput] = useState('');
|
||
const [options, setOptions] = useState<ChecklistItemRow[]>([]);
|
||
|
||
useEffect(() => {
|
||
const ctrl = new AbortController();
|
||
const t = window.setTimeout(() => {
|
||
api<ChecklistItemRow[]>(`/checklist-items?q=${encodeURIComponent(input.trim())}`, { signal: ctrl.signal })
|
||
.then(setOptions)
|
||
.catch(() => undefined);
|
||
}, 250);
|
||
return () => {
|
||
ctrl.abort();
|
||
window.clearTimeout(t);
|
||
};
|
||
}, [input]);
|
||
|
||
return (
|
||
<Autocomplete<ChecklistItemRow, false, false, true>
|
||
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) => (
|
||
<li {...props} key={o.id}>
|
||
<Box>
|
||
<Typography sx={{ fontSize: 14 }}>{o.text}</Typography>
|
||
{o.usedIn > 0 && <Typography sx={{ fontSize: 12, color: colors.muted }}>в чеклистах: {o.usedIn}</Typography>}
|
||
</Box>
|
||
</li>
|
||
)}
|
||
renderInput={(params) => (
|
||
<TextField {...params} placeholder="Добавить пункт: начните вводить и выберите из списка или нажмите Enter" />
|
||
)}
|
||
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 (
|
||
<Dialog open onClose={onClose} fullWidth maxWidth="sm">
|
||
<DialogTitle>Переименовать пункт</DialogTitle>
|
||
<DialogContent>
|
||
{item.usedIn > 1 && (
|
||
<Alert severity="info" sx={{ mb: 2, borderRadius: '10px' }}>
|
||
Пункт общий: новое название будет во всех чеклистах, где он есть ({item.usedIn}). В прошлых отчётах останется старое.
|
||
</Alert>
|
||
)}
|
||
<TextField autoFocus fullWidth value={text} onChange={(e) => setText(e.target.value)} sx={{ mt: item.usedIn > 1 ? 0 : 1 }} />
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={onClose}>Отмена</Button>
|
||
<Button variant="contained" onClick={save} disabled={!text.trim() || text.trim() === item.text || busy}>
|
||
Сохранить
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
);
|
||
}
|