Files
mkd/components/admin/ResponsibilityZonesSection.tsx
2026-02-04 00:17:04 +05:00

204 lines
8.3 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};