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:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user