204 lines
8.3 KiB
TypeScript
204 lines
8.3 KiB
TypeScript
|
|
import React, { useEffect, useState, useMemo } from 'react';
|
|||
|
|
import { backendApi } from '../../services/apiClient';
|
|||
|
|
import { SECTION_LABELS, SECTION_SUBS, RESPONSIBILITY_GROUPS } from '../../constants/permissions';
|
|||
|
|
import { Loader2, Users, Save, ChevronDown, ChevronRight } from 'lucide-react';
|
|||
|
|
|
|||
|
|
type Assignment = { employeeId: string; section: string; subId: string };
|
|||
|
|
|
|||
|
|
const SECTIONS_WITH_SUBS = (Object.keys(SECTION_SUBS) as string[]).filter(
|
|||
|
|
(s) => SECTION_SUBS[s] && SECTION_SUBS[s].length > 0 && s !== 'dashboard'
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
export const ResponsibilityZonesSection: React.FC = () => {
|
|||
|
|
const [assignments, setAssignments] = useState<Assignment[]>([]);
|
|||
|
|
const [employees, setEmployees] = useState<Array<{ id: string; name: string }>>([]);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
const [saving, setSaving] = useState<string | null>(null);
|
|||
|
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['hr', 'finance', 'requests']));
|
|||
|
|
|
|||
|
|
const byZone = useMemo(() => {
|
|||
|
|
const map: Record<string, string[]> = {};
|
|||
|
|
for (const a of assignments) {
|
|||
|
|
const key = `${a.section}_${a.subId}`;
|
|||
|
|
if (!map[key]) map[key] = [];
|
|||
|
|
map[key].push(a.employeeId);
|
|||
|
|
}
|
|||
|
|
return map;
|
|||
|
|
}, [assignments]);
|
|||
|
|
|
|||
|
|
const load = async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
const [resp, emp] = await Promise.all([
|
|||
|
|
backendApi.getResponsibility(),
|
|||
|
|
backendApi.getEmployeesList(),
|
|||
|
|
]);
|
|||
|
|
setAssignments(resp.assignments || []);
|
|||
|
|
setEmployees(emp || []);
|
|||
|
|
} catch (e: unknown) {
|
|||
|
|
setError(e instanceof Error ? e.message : 'Ошибка загрузки');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
load();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const toggleSection = (section: string) => {
|
|||
|
|
setExpandedSections((prev) => {
|
|||
|
|
const next = new Set(prev);
|
|||
|
|
if (next.has(section)) next.delete(section);
|
|||
|
|
else next.add(section);
|
|||
|
|
return next;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getSelectedForZone = (section: string, subId: string): string[] => {
|
|||
|
|
return byZone[`${section}_${subId}`] || [];
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const setSelectedForZone = (section: string, subId: string, employeeIds: string[]) => {
|
|||
|
|
setAssignments((prev) => {
|
|||
|
|
const key = `${section}_${subId}`;
|
|||
|
|
const rest = prev.filter((a) => a.section !== section || a.subId !== subId);
|
|||
|
|
const next = [...rest, ...employeeIds.map((employeeId) => ({ employeeId, section, subId }))];
|
|||
|
|
return next;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const saveZone = async (section: string, subId: string) => {
|
|||
|
|
const key = `${section}_${subId}`;
|
|||
|
|
setSaving(key);
|
|||
|
|
try {
|
|||
|
|
const employeeIds = byZone[key] || [];
|
|||
|
|
await backendApi.putResponsibility({ section, subId, employeeIds });
|
|||
|
|
} catch (e: unknown) {
|
|||
|
|
setError(e instanceof Error ? e.message : 'Ошибка сохранения');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(null);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return (
|
|||
|
|
<div className="flex items-center justify-center py-12">
|
|||
|
|
<Loader2 className="w-8 h-8 text-primary-500 animate-spin" />
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (error) {
|
|||
|
|
return (
|
|||
|
|
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm">
|
|||
|
|
{error}
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={load}
|
|||
|
|
className="ml-3 underline font-medium"
|
|||
|
|
>
|
|||
|
|
Повторить
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<p className="text-slate-600 text-sm max-w-2xl">
|
|||
|
|
Назначьте сотрудников ответственными за подразделы модулей. Это используется для уведомлений (например, новая вакансия → ответственные за «Кадры → Вакансии») и для учёта эффективности по зонам.
|
|||
|
|
</p>
|
|||
|
|
<p className="text-slate-500 text-xs max-w-2xl">
|
|||
|
|
Группы зон (например, «ЗП и обучение» vs «Найм и адаптация» в Кадрах) заданы в настройках для наглядности; привязка выполняется по каждому подразделу.
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{SECTIONS_WITH_SUBS.map((section) => {
|
|||
|
|
const subs = SECTION_SUBS[section];
|
|||
|
|
const sectionLabel = SECTION_LABELS[section] || section;
|
|||
|
|
const isExpanded = expandedSections.has(section);
|
|||
|
|
const groups = RESPONSIBILITY_GROUPS[section];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div key={section} className="border border-slate-200 rounded-xl overflow-hidden bg-white">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => toggleSection(section)}
|
|||
|
|
className="w-full flex items-center gap-2 px-4 py-3 text-left font-semibold text-slate-800 bg-slate-50 hover:bg-slate-100 transition-colors"
|
|||
|
|
>
|
|||
|
|
{isExpanded ? (
|
|||
|
|
<ChevronDown className="w-4 h-4 text-slate-500" />
|
|||
|
|
) : (
|
|||
|
|
<ChevronRight className="w-4 h-4 text-slate-500" />
|
|||
|
|
)}
|
|||
|
|
<Users className="w-4 h-4 text-primary-600" />
|
|||
|
|
{sectionLabel}
|
|||
|
|
</button>
|
|||
|
|
{isExpanded && (
|
|||
|
|
<div className="p-4 pt-0 space-y-4">
|
|||
|
|
{groups && groups.length > 0 && (
|
|||
|
|
<div className="text-xs text-slate-500 mb-2">
|
|||
|
|
Группы: {groups.map((g) => g.name).join(' · ')}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{subs.map((sub) => {
|
|||
|
|
const zoneKey = `${section}_${sub.id}`;
|
|||
|
|
const selected = getSelectedForZone(section, sub.id);
|
|||
|
|
const savingThis = saving === zoneKey;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
key={zoneKey}
|
|||
|
|
className="flex flex-wrap items-center gap-3 py-2 border-b border-slate-100 last:border-0"
|
|||
|
|
>
|
|||
|
|
<div className="min-w-[12rem] text-sm font-medium text-slate-700">
|
|||
|
|
{sub.label}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex-1 flex flex-wrap items-center gap-2">
|
|||
|
|
<select
|
|||
|
|
multiple
|
|||
|
|
value={selected}
|
|||
|
|
onChange={(e) => {
|
|||
|
|
const opts = Array.from(e.target.selectedOptions, (o) => o.value);
|
|||
|
|
setSelectedForZone(section, sub.id, opts);
|
|||
|
|
}}
|
|||
|
|
className="min-w-[14rem] min-h-[2rem] text-sm border border-slate-300 rounded-lg px-2 py-1 bg-white"
|
|||
|
|
size={Math.min(4, employees.length + 1)}
|
|||
|
|
title="Удерживайте Ctrl (Cmd) для выбора нескольких"
|
|||
|
|
>
|
|||
|
|
{employees.map((emp) => (
|
|||
|
|
<option key={emp.id} value={emp.id}>
|
|||
|
|
{emp.name}
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => saveZone(section, sub.id)}
|
|||
|
|
disabled={savingThis}
|
|||
|
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{savingThis ? (
|
|||
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|||
|
|
) : (
|
|||
|
|
<Save className="w-3 h-3" />
|
|||
|
|
)}
|
|||
|
|
Сохранить
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|