feat: замещение пользователей (substitution) — заместители по умолчанию и по формам, контур ознакомления, inbox reasons

- таблица user_delegations (миграция 0067) + колонки delegated_from_user_id/pending_review в task_assignees
- delegation.service: резолвер с цепочкой и защитой от циклов, bulk-до-назначение чанками, чистка истёкших замещений
- центральный хук в addTaskAssignee: заместитель добавляется при любом назначении (ручном, автопереходе, user-поле, MCP)
- контур ознакомления: после перехода отсутствующий остаётся в исполнителях с pending_review, снимается кнопкой «Ознакомлен»
- inbox reasons delegation/delegation_review + фильтр «Замещение» на главной
- блок «Замещение» в профиле пользователя, бейдж «за <имя>» и баннер ознакомления в карточке задачи
This commit is contained in:
2026-08-05 10:44:55 +03:00
parent 17a8dc42f2
commit 667bddeac9
15 changed files with 1216 additions and 12 deletions

View File

@@ -20,7 +20,7 @@ import { apiRequest, queryClient } from '@/lib/queryClient';
import { authService } from '@/services/auth.service';
import { normalizeSelectOptions } from '@/lib/fieldOptions';
import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect';
import { ArrowLeft, Save, Loader2, User as UserIcon, Mail, Shield, Briefcase, FileText, History, Calendar, Clock, CheckCircle2, Circle, AlertCircle } from 'lucide-react';
import { ArrowLeft, Save, Loader2, User as UserIcon, Mail, Shield, Briefcase, FileText, History, Calendar, Clock, CheckCircle2, Circle, AlertCircle, UserCheck } from 'lucide-react';
import { Link } from 'wouter';
import CompanyFieldInput from '@/components/CompanyFieldInput';
import GeoField from '@/components/GeoField';
@@ -114,6 +114,29 @@ interface AuditLogEntry {
createdAt: string;
}
interface UserDelegation {
id: number;
userId: number;
delegateUserId: number;
formId: number | null;
dateFrom: string;
dateTo: string;
isActive: boolean;
delegateName: string | null;
formName: string | null;
}
function formatDelegationDate(dateStr: string) {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function delegationState(d: UserDelegation, today: string): 'active' | 'expired' | 'disabled' {
if (!d.isActive) return 'disabled';
if (d.dateTo < today) return 'expired';
return 'active';
}
const contactItemSchema = z.object({
value: z.string().min(1, 'Обязательно'),
tag: z.string().min(1, 'Выберите тег'),
@@ -606,6 +629,96 @@ export default function UserProfilePage() {
});
const userStatuses = statusesData?.statuses || [];
// === Замещения пользователя ===
const { data: formsData } = useQuery<{ success: boolean; forms: Array<{ id: number; name: string }> }>({
queryKey: ['/api/forms'],
enabled: !!data,
});
const allForms = formsData?.forms || [];
const { data: delegationsData } = useQuery<{ success: boolean; delegations: UserDelegation[] }>({
queryKey: ['/api/users', userId, 'delegations'],
queryFn: async () => {
const res = await apiRequest('GET', `/api/users/${userId}/delegations`);
return (await res.json()) as { success: boolean; delegations: UserDelegation[] };
},
enabled: !isNaN(userId) && !!data,
});
const delegations = delegationsData?.delegations || [];
const todayStr = useMemo(() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}, []);
const currentDelegations = delegations.filter(
(d) => delegationState(d, todayStr) === 'active' && d.dateFrom <= todayStr
);
const [newDelegateId, setNewDelegateId] = useState('');
const [newFormId, setNewFormId] = useState('all');
const [newDateFrom, setNewDateFrom] = useState('');
const [newDateTo, setNewDateTo] = useState('');
const invalidateDelegations = () =>
queryClient.invalidateQueries({ queryKey: ['/api/users', userId, 'delegations'] });
const createDelegationMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest('POST', `/api/users/${userId}/delegations`, {
delegateUserId: Number(newDelegateId),
formId: newFormId === 'all' ? null : Number(newFormId),
dateFrom: newDateFrom,
dateTo: newDateTo,
});
return res.json();
},
onSuccess: () => {
toast({ title: 'Замещение добавлено' });
setNewDelegateId('');
setNewFormId('all');
setNewDateFrom('');
setNewDateTo('');
invalidateDelegations();
},
onError: (err: any) => {
const isConflict = err?.status === 409;
toast({
title: 'Ошибка',
description: isConflict
? 'Замещение на этот период уже существует'
: err?.message || 'Не удалось добавить замещение',
variant: 'destructive',
});
},
});
const deactivateDelegationMutation = useMutation({
mutationFn: async (delegationId: number) => {
const res = await apiRequest('PATCH', `/api/users/${userId}/delegations/${delegationId}`, { isActive: false });
return res.json();
},
onSuccess: () => {
toast({ title: 'Замещение отключено' });
invalidateDelegations();
},
onError: (err: any) => {
toast({ title: 'Ошибка', description: err?.message || 'Не удалось отключить замещение', variant: 'destructive' });
},
});
const deleteDelegationMutation = useMutation({
mutationFn: async (delegationId: number) => {
const res = await apiRequest('DELETE', `/api/users/${userId}/delegations/${delegationId}`);
return res.json();
},
onSuccess: () => {
toast({ title: 'Замещение удалено' });
invalidateDelegations();
},
onError: (err: any) => {
toast({ title: 'Ошибка', description: err?.message || 'Не удалось удалить замещение', variant: 'destructive' });
},
});
const { data: linkedTasksData, isLoading: linkedTasksLoading } = useQuery<{ success: boolean; tasks: LinkedTask[] }>({
queryKey: ['/api/users', userId, 'linked-tasks'],
queryFn: async () => {
@@ -787,6 +900,14 @@ export default function UserProfilePage() {
<Badge key={role.id} variant="outline" className="text-xs">{role.name}</Badge>
))}
</div>
{currentDelegations.length > 0 && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<UserCheck className="w-3.5 h-3.5 shrink-0" />
<span>
Замещает: {currentDelegations.map((d) => `${d.delegateName || '—'} (до ${formatDelegationDate(d.dateTo)})`).join(', ')}
</span>
</div>
)}
</div>
<Link href="/users">
<Button variant="outline" size="sm">
@@ -940,6 +1061,134 @@ export default function UserProfilePage() {
</Button>
)}
</form>
{/* Замещение пользователя на период отсутствия */}
{canEdit && (
<div className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<UserCheck className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Замещение</h3>
</div>
{delegations.length === 0 ? (
<p className="text-xs text-muted-foreground">Замещения не настроены</p>
) : (
<div className="space-y-2">
{delegations.map((d) => {
const state = delegationState(d, todayStr);
return (
<div
key={d.id}
className="flex items-center gap-2 flex-wrap border border-border rounded px-2.5 py-2 text-xs"
data-testid={`delegation-row-${d.id}`}
>
<span className="font-medium">{d.delegateName || '—'}</span>
<span className="text-muted-foreground">
{d.formId ? (d.formName || `Форма #${d.formId}`) : 'Все формы (по умолчанию)'}
</span>
<span className="text-muted-foreground">
с {formatDelegationDate(d.dateFrom)} по {formatDelegationDate(d.dateTo)}
</span>
{state === 'active' && <Badge variant="default" className="text-[10px] h-4 px-1.5">Активно</Badge>}
{state === 'expired' && <Badge variant="secondary" className="text-[10px] h-4 px-1.5">Истекло</Badge>}
{state === 'disabled' && <Badge variant="outline" className="text-[10px] h-4 px-1.5">Отключено</Badge>}
<div className="ml-auto flex items-center gap-1">
{state === 'active' && (
<Button
variant="outline"
size="sm"
className="h-6 text-[11px] px-2"
disabled={deactivateDelegationMutation.isPending}
onClick={() => deactivateDelegationMutation.mutate(d.id)}
>
Отключить
</Button>
)}
<Button
variant="outline"
size="sm"
className="h-6 text-[11px] px-2 text-destructive hover:text-destructive"
disabled={deleteDelegationMutation.isPending}
onClick={() => {
if (window.confirm(`Удалить замещение «${d.delegateName || '—'}»?`)) {
deleteDelegationMutation.mutate(d.id);
}
}}
>
Удалить
</Button>
</div>
</div>
);
})}
</div>
)}
{/* Форма добавления замещения */}
<div className="flex items-end gap-2 flex-wrap pt-1">
<div className="space-y-1">
<Label className="text-xs">Заместитель</Label>
<Select value={newDelegateId} onValueChange={setNewDelegateId}>
<SelectTrigger className="w-48 h-8 text-xs">
<SelectValue placeholder="Выберите пользователя" />
</SelectTrigger>
<SelectContent>
{allUsers
.filter((u: any) => u.id !== userId)
.map((u: any) => (
<SelectItem key={u.id} value={String(u.id)}>
{u.fullName || `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Форма</Label>
<Select value={newFormId} onValueChange={setNewFormId}>
<SelectTrigger className="w-48 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">По умолчанию (все формы)</SelectItem>
{allForms.map((f) => (
<SelectItem key={f.id} value={String(f.id)}>{f.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">С</Label>
<Input
type="date"
className="w-auto h-8 text-xs"
value={newDateFrom}
onChange={(e) => setNewDateFrom(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">По</Label>
<Input
type="date"
className="w-auto h-8 text-xs"
value={newDateTo}
onChange={(e) => setNewDateTo(e.target.value)}
/>
</div>
<Button
size="sm"
className="h-8"
disabled={
createDelegationMutation.isPending || !newDelegateId || !newDateFrom || !newDateTo
}
onClick={() => createDelegationMutation.mutate()}
>
{createDelegationMutation.isPending && <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />}
Добавить
</Button>
</div>
</div>
)}
</TabsContent>
{/* Custom tabs */}