aspans_patrol/apps/web/src/pages/ChecklistsPage.tsx

387 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}