204 lines
8.3 KiB
TypeScript
Executable File
204 lines
8.3 KiB
TypeScript
Executable File
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>
|
||
);
|
||
};
|