Files
mkd/components/admin/ResponsibilityZonesSection.tsx

204 lines
8.3 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};