747 lines
34 KiB
TypeScript
747 lines
34 KiB
TypeScript
|
|
import React, { useEffect, useState } from 'react';
|
|||
|
|
import { backendApi, PortalUserRow } from '../../services/apiClient';
|
|||
|
|
import { Employee } from '../../types';
|
|||
|
|
import { UserRole } from '../../types';
|
|||
|
|
import { District } from '../../types';
|
|||
|
|
import { UserPlus, Loader2, Pencil, Trash2, UsersRound, Shield, CheckSquare, Square, X, UserCog } from 'lucide-react';
|
|||
|
|
import { PermissionTemplateRow } from '../../services/apiClient';
|
|||
|
|
import {
|
|||
|
|
SECTION_LABELS,
|
|||
|
|
SECTION_SUBS,
|
|||
|
|
ALL_SECTION_KEYS,
|
|||
|
|
getPermissionLevel,
|
|||
|
|
permissionKey,
|
|||
|
|
PERMISSION_LEVEL_LABELS,
|
|||
|
|
type PermissionLevel,
|
|||
|
|
} from '../../constants/permissions';
|
|||
|
|
import { ROLE_NAMES } from '../../constants/roleAccess';
|
|||
|
|
|
|||
|
|
type CreateMode = 'select' | 'createNew';
|
|||
|
|
|
|||
|
|
export const UsersSection: React.FC = () => {
|
|||
|
|
const [portalUsers, setPortalUsers] = useState<PortalUserRow[]>([]);
|
|||
|
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
|||
|
|
const [districts, setDistricts] = useState<District[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
|||
|
|
const [createMode, setCreateMode] = useState<CreateMode>('select');
|
|||
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|||
|
|
const [formEmployeeId, setFormEmployeeId] = useState('');
|
|||
|
|
const [formLogin, setFormLogin] = useState('');
|
|||
|
|
const [formPassword, setFormPassword] = useState('');
|
|||
|
|
const [formEmail, setFormEmail] = useState('');
|
|||
|
|
const [formRole, setFormRole] = useState<UserRole>('ENGINEER');
|
|||
|
|
const [empName, setEmpName] = useState('');
|
|||
|
|
const [empPosition, setEmpPosition] = useState('');
|
|||
|
|
const [empPhone, setEmpPhone] = useState('');
|
|||
|
|
const [empSalary, setEmpSalary] = useState('');
|
|||
|
|
const [empDistrictId, setEmpDistrictId] = useState('');
|
|||
|
|
const [saving, setSaving] = useState(false);
|
|||
|
|
const [createError, setCreateError] = useState<string | null>(null);
|
|||
|
|
const [permissionsModalUserId, setPermissionsModalUserId] = useState<number | null>(null);
|
|||
|
|
const [permissionTemplates, setPermissionTemplates] = useState<PermissionTemplateRow[]>([]);
|
|||
|
|
const [userPermissions, setUserPermissions] = useState<string[]>([]);
|
|||
|
|
const [userScope, setUserScope] = useState<'all' | 'own_district'>('all');
|
|||
|
|
|
|||
|
|
const load = async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
const [users, emps, dists, templates] = await Promise.all([
|
|||
|
|
backendApi.getPortalUsers(),
|
|||
|
|
backendApi.getEmployees(),
|
|||
|
|
backendApi.getDistricts(),
|
|||
|
|
backendApi.getPermissionTemplates(),
|
|||
|
|
]);
|
|||
|
|
setPortalUsers(users);
|
|||
|
|
setEmployees(emps);
|
|||
|
|
setDistricts(dists);
|
|||
|
|
setPermissionTemplates(templates);
|
|||
|
|
} catch (e: any) {
|
|||
|
|
setError(e?.message || 'Ошибка загрузки');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
load();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const openPermissionsModal = (u: PortalUserRow) => {
|
|||
|
|
setPermissionsModalUserId(u.id);
|
|||
|
|
setUserPermissions(Array.isArray(u.permissions) ? u.permissions : []);
|
|||
|
|
setUserScope(u.scope === 'own_district' ? 'own_district' : 'all');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const applyTemplateToUser = async (userId: number, t: PermissionTemplateRow) => {
|
|||
|
|
setSaving(true);
|
|||
|
|
try {
|
|||
|
|
await backendApi.updatePortalUser(userId, {
|
|||
|
|
permissions: (t.permissions?.length ?? 0) > 0 ? (t.permissions ?? []) : null,
|
|||
|
|
scope: t.scope || 'all',
|
|||
|
|
role: t.suggestedRole || undefined,
|
|||
|
|
});
|
|||
|
|
setUserPermissions(t.permissions ?? []);
|
|||
|
|
setUserScope(t.scope || 'all');
|
|||
|
|
await load();
|
|||
|
|
} catch (e: any) {
|
|||
|
|
alert(e?.message || 'Ошибка применения шаблона');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const toggleUserSection = (section: string) => {
|
|||
|
|
setUserPermissions((prev) => {
|
|||
|
|
const hasSection = prev.includes(section);
|
|||
|
|
const subs = SECTION_SUBS[section];
|
|||
|
|
const subKeyPrefixes = subs
|
|||
|
|
? subs.flatMap((s) => {
|
|||
|
|
const base = `${section}_${s.id}`;
|
|||
|
|
return [base, `${base}:read`, `${base}:edit`, `${base}:own`];
|
|||
|
|
})
|
|||
|
|
: [];
|
|||
|
|
if (hasSection) {
|
|||
|
|
return prev.filter((p) => p !== section && !subKeyPrefixes.some((prefix) => p === prefix || p.startsWith(prefix + ':')));
|
|||
|
|
} else {
|
|||
|
|
return prev.filter((p) => p !== section && !subKeyPrefixes.some((prefix) => p === prefix || p.startsWith(prefix + ':'))).concat([section]);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const setUserSubLevel = (section: string, subId: string, level: PermissionLevel) => {
|
|||
|
|
setUserPermissions((prev) => {
|
|||
|
|
const base = `${section}_${subId}`;
|
|||
|
|
const toRemove = prev.filter(
|
|||
|
|
(p) => p === base || p === `${base}:read` || p === `${base}:edit` || p === `${base}:own`
|
|||
|
|
);
|
|||
|
|
const next = prev.filter((p) => !toRemove.includes(p));
|
|||
|
|
if (level !== 'none') {
|
|||
|
|
next.push(permissionKey(section, subId, level));
|
|||
|
|
}
|
|||
|
|
return next.filter((p) => p !== section);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const hasUserSection = (section: string) => userPermissions.includes(section);
|
|||
|
|
const getUserSubLevel = (section: string, subId: string) => getPermissionLevel(userPermissions, section, subId);
|
|||
|
|
const hasUserSub = (section: string, subId: string) => getUserSubLevel(section, subId) !== 'none';
|
|||
|
|
|
|||
|
|
const saveUserPermissions = async () => {
|
|||
|
|
if (permissionsModalUserId == null) return;
|
|||
|
|
setSaving(true);
|
|||
|
|
try {
|
|||
|
|
await backendApi.updatePortalUser(permissionsModalUserId, {
|
|||
|
|
permissions: userPermissions.length > 0 ? userPermissions : null,
|
|||
|
|
scope: userScope,
|
|||
|
|
});
|
|||
|
|
setPermissionsModalUserId(null);
|
|||
|
|
await load();
|
|||
|
|
} catch (e: any) {
|
|||
|
|
alert(e?.message || 'Ошибка сохранения');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const currentModalUser = permissionsModalUserId != null ? portalUsers.find((u) => u.id === permissionsModalUserId) : null;
|
|||
|
|
const templatesByPosition = permissionTemplates.filter((t) => t.forPosition && t.forPosition.trim());
|
|||
|
|
|
|||
|
|
const employeeIdsWithUser = new Set(portalUsers.map((u) => u.employeeId));
|
|||
|
|
const employeesWithoutUser = employees.filter((e) => !employeeIdsWithUser.has(e.id));
|
|||
|
|
|
|||
|
|
const handleCreate = async () => {
|
|||
|
|
if (!formEmployeeId || !formLogin.trim()) {
|
|||
|
|
setCreateError('Выберите сотрудника и укажите логин');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setSaving(true);
|
|||
|
|
setCreateError(null);
|
|||
|
|
try {
|
|||
|
|
await backendApi.createPortalUser({
|
|||
|
|
employeeId: formEmployeeId,
|
|||
|
|
login: formLogin.trim(),
|
|||
|
|
password: formPassword.trim() || undefined,
|
|||
|
|
email: formEmail.trim() || undefined,
|
|||
|
|
role: formRole,
|
|||
|
|
});
|
|||
|
|
setShowCreateForm(false);
|
|||
|
|
setFormEmployeeId('');
|
|||
|
|
setFormLogin('');
|
|||
|
|
setFormPassword('');
|
|||
|
|
setFormEmail('');
|
|||
|
|
setFormRole('ENGINEER');
|
|||
|
|
await load();
|
|||
|
|
} catch (e: any) {
|
|||
|
|
setCreateError(e?.message || 'Ошибка создания');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleCreateEmployeeAndUser = async () => {
|
|||
|
|
if (!empName.trim() || !empPosition.trim() || !empPhone.trim() || !empSalary.trim() || !formLogin.trim()) {
|
|||
|
|
setCreateError('Заполните ФИО, должность, телефон, зарплату и логин');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const salaryNum = parseFloat(empSalary.replace(/\s/g, '').replace(',', '.'));
|
|||
|
|
if (isNaN(salaryNum) || salaryNum < 0) {
|
|||
|
|
setCreateError('Укажите корректную зарплату');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setSaving(true);
|
|||
|
|
setCreateError(null);
|
|||
|
|
try {
|
|||
|
|
await backendApi.createEmployeeWithUser({
|
|||
|
|
name: empName.trim(),
|
|||
|
|
position: empPosition.trim(),
|
|||
|
|
phone: empPhone.trim(),
|
|||
|
|
status: 'active',
|
|||
|
|
salary: salaryNum,
|
|||
|
|
assignedDistrictId: empDistrictId.trim() || undefined,
|
|||
|
|
login: formLogin.trim(),
|
|||
|
|
email: formEmail.trim() || undefined,
|
|||
|
|
role: formRole,
|
|||
|
|
});
|
|||
|
|
setShowCreateForm(false);
|
|||
|
|
setCreateMode('select');
|
|||
|
|
setEmpName('');
|
|||
|
|
setEmpPosition('');
|
|||
|
|
setEmpPhone('');
|
|||
|
|
setEmpSalary('');
|
|||
|
|
setEmpDistrictId('');
|
|||
|
|
setFormEmployeeId('');
|
|||
|
|
setFormLogin('');
|
|||
|
|
setFormEmail('');
|
|||
|
|
setFormRole('ENGINEER');
|
|||
|
|
await load();
|
|||
|
|
} catch (e: any) {
|
|||
|
|
setCreateError(e?.message || 'Ошибка создания');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleUpdate = async (id: number, updates: { login?: string; email?: string; role?: string }) => {
|
|||
|
|
setSaving(true);
|
|||
|
|
setCreateError(null);
|
|||
|
|
try {
|
|||
|
|
await backendApi.updatePortalUser(id, updates);
|
|||
|
|
setEditingId(null);
|
|||
|
|
await load();
|
|||
|
|
} catch (e: any) {
|
|||
|
|
setCreateError(e?.message || 'Ошибка сохранения');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDelete = async (id: number) => {
|
|||
|
|
if (!confirm('Удалить пользователя портала? Сотрудник останется в системе.')) return;
|
|||
|
|
setSaving(true);
|
|||
|
|
setCreateError(null);
|
|||
|
|
try {
|
|||
|
|
await backendApi.deletePortalUser(id);
|
|||
|
|
await load();
|
|||
|
|
} catch (e: any) {
|
|||
|
|
setCreateError(e?.message || 'Ошибка удаления');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return (
|
|||
|
|
<div className="flex items-center justify-center py-12">
|
|||
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
<div className="flex justify-between items-center">
|
|||
|
|
<h3 className="text-lg font-bold text-slate-800">Пользователи портала</h3>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => {
|
|||
|
|
setShowCreateForm(true);
|
|||
|
|
setCreateError(null);
|
|||
|
|
setCreateMode(employeesWithoutUser.length === 0 ? 'createNew' : 'select');
|
|||
|
|
setFormEmployeeId(employeesWithoutUser[0]?.id || '');
|
|||
|
|
setFormLogin('');
|
|||
|
|
setFormPassword('');
|
|||
|
|
setFormEmail('');
|
|||
|
|
setFormRole('ENGINEER');
|
|||
|
|
setEmpName('');
|
|||
|
|
setEmpPosition('');
|
|||
|
|
setEmpPhone('');
|
|||
|
|
setEmpSalary('');
|
|||
|
|
setEmpDistrictId('');
|
|||
|
|
}}
|
|||
|
|
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm font-bold rounded-xl hover:bg-primary-700"
|
|||
|
|
>
|
|||
|
|
<UserPlus className="w-4 h-4" /> Создать пользователя
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-sm text-slate-500">
|
|||
|
|
Пользователя можно создать только для сотрудника. Выберите существующего сотрудника или создайте нового — тогда сразу создастся и пользователь портала.
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
{error && (
|
|||
|
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm">{error}</div>
|
|||
|
|
)}
|
|||
|
|
{createError && (
|
|||
|
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm">{createError}</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{showCreateForm && (
|
|||
|
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-6 space-y-4">
|
|||
|
|
<h4 className="font-bold text-slate-800">Новый пользователь</h4>
|
|||
|
|
<div className="flex gap-2 p-1 bg-slate-200/50 rounded-xl">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setCreateMode('select')}
|
|||
|
|
className={`flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${createMode === 'select' ? 'bg-white text-primary-600 shadow' : 'text-slate-600 hover:text-slate-800'}`}
|
|||
|
|
>
|
|||
|
|
<UsersRound className="w-4 h-4" /> Выбрать сотрудника
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setCreateMode('createNew')}
|
|||
|
|
className={`flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${createMode === 'createNew' ? 'bg-white text-primary-600 shadow' : 'text-slate-600 hover:text-slate-800'}`}
|
|||
|
|
>
|
|||
|
|
<UserCog className="w-4 h-4" /> Создать сотрудника и пользователя
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{createMode === 'select' ? (
|
|||
|
|
employeesWithoutUser.length === 0 ? (
|
|||
|
|
<div className="flex items-center gap-3 p-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-800 text-sm">
|
|||
|
|
<UsersRound className="w-5 h-5 flex-shrink-0" />
|
|||
|
|
<div>
|
|||
|
|
<p className="font-medium">Нет сотрудников без пользователя</p>
|
|||
|
|
<p className="mt-1">Переключитесь на вкладку <strong>«Создать сотрудника и пользователя»</strong> — так можно сразу добавить нового сотрудника и учётную запись портала.</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Сотрудник</label>
|
|||
|
|
<select
|
|||
|
|
value={formEmployeeId}
|
|||
|
|
onChange={(e) => setFormEmployeeId(e.target.value)}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
>
|
|||
|
|
<option value="">— Выберите —</option>
|
|||
|
|
{employeesWithoutUser.map((e) => (
|
|||
|
|
<option key={e.id} value={e.id}>{e.name} ({e.position})</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Логин</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formLogin}
|
|||
|
|
onChange={(e) => setFormLogin(e.target.value)}
|
|||
|
|
placeholder="Уникальный логин"
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Пароль (необязательно при создании)</label>
|
|||
|
|
<input
|
|||
|
|
type="password"
|
|||
|
|
value={formPassword}
|
|||
|
|
onChange={(e) => setFormPassword(e.target.value)}
|
|||
|
|
placeholder="Задайте пароль для входа"
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Email (необязательно)</label>
|
|||
|
|
<input
|
|||
|
|
type="email"
|
|||
|
|
value={formEmail}
|
|||
|
|
onChange={(e) => setFormEmail(e.target.value)}
|
|||
|
|
placeholder="email@example.com"
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Роль</label>
|
|||
|
|
<select
|
|||
|
|
value={formRole}
|
|||
|
|
onChange={(e) => setFormRole(e.target.value as UserRole)}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
>
|
|||
|
|
{(Object.keys(ROLE_NAMES) as UserRole[]).map((r) => (
|
|||
|
|
<option key={r} value={r}>{ROLE_NAMES[r]}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={handleCreate}
|
|||
|
|
disabled={saving || !formEmployeeId || !formLogin.trim()}
|
|||
|
|
className="px-4 py-2 bg-primary-600 text-white text-sm font-bold rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{saving ? <Loader2 className="w-4 h-4 animate-spin inline" /> : 'Создать'}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => { setShowCreateForm(false); setCreateError(null); }}
|
|||
|
|
className="px-4 py-2 border border-slate-200 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-50"
|
|||
|
|
>
|
|||
|
|
Отмена
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<p className="text-xs text-slate-600 font-medium">Данные сотрудника</p>
|
|||
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">ФИО</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={empName}
|
|||
|
|
onChange={(e) => setEmpName(e.target.value)}
|
|||
|
|
placeholder="Иванов Иван Иванович"
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Должность</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={empPosition}
|
|||
|
|
onChange={(e) => setEmpPosition(e.target.value)}
|
|||
|
|
placeholder="Мастер участка"
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Телефон</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={empPhone}
|
|||
|
|
onChange={(e) => setEmpPhone(e.target.value)}
|
|||
|
|
placeholder="+7 900 123-45-67"
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Зарплата</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={empSalary}
|
|||
|
|
onChange={(e) => setEmpSalary(e.target.value)}
|
|||
|
|
placeholder="50000"
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="sm:col-span-2">
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Участок (необязательно)</label>
|
|||
|
|
<select
|
|||
|
|
value={empDistrictId}
|
|||
|
|
onChange={(e) => setEmpDistrictId(e.target.value)}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
>
|
|||
|
|
<option value="">— Не выбран —</option>
|
|||
|
|
{districts.map((d) => (
|
|||
|
|
<option key={d.id} value={d.id}>{d.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-xs text-slate-600 font-medium pt-2">Данные пользователя портала</p>
|
|||
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Логин</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={formLogin}
|
|||
|
|
onChange={(e) => setFormLogin(e.target.value)}
|
|||
|
|
placeholder="Уникальный логин"
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Email (необязательно)</label>
|
|||
|
|
<input
|
|||
|
|
type="email"
|
|||
|
|
value={formEmail}
|
|||
|
|
onChange={(e) => setFormEmail(e.target.value)}
|
|||
|
|
placeholder="email@example.com"
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Роль</label>
|
|||
|
|
<select
|
|||
|
|
value={formRole}
|
|||
|
|
onChange={(e) => setFormRole(e.target.value as UserRole)}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
>
|
|||
|
|
{(Object.keys(ROLE_NAMES) as UserRole[]).map((r) => (
|
|||
|
|
<option key={r} value={r}>{ROLE_NAMES[r]}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-2 pt-2">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={handleCreateEmployeeAndUser}
|
|||
|
|
disabled={saving || !empName.trim() || !empPosition.trim() || !empPhone.trim() || !empSalary.trim() || !formLogin.trim()}
|
|||
|
|
className="px-4 py-2 bg-primary-600 text-white text-sm font-bold rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{saving ? <Loader2 className="w-4 h-4 animate-spin inline" /> : 'Создать сотрудника и пользователя'}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => { setShowCreateForm(false); setCreateError(null); }}
|
|||
|
|
className="px-4 py-2 border border-slate-200 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-50"
|
|||
|
|
>
|
|||
|
|
Отмена
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="rounded-xl border border-slate-200 overflow-hidden">
|
|||
|
|
<div className="overflow-x-auto">
|
|||
|
|
<table className="w-full text-sm">
|
|||
|
|
<thead className="bg-slate-50 border-b border-slate-200">
|
|||
|
|
<tr>
|
|||
|
|
<th className="text-left py-3 px-4 font-bold text-slate-700">Сотрудник</th>
|
|||
|
|
<th className="text-left py-3 px-4 font-bold text-slate-700">Логин</th>
|
|||
|
|
<th className="text-left py-3 px-4 font-bold text-slate-700">Роль</th>
|
|||
|
|
<th className="text-right py-3 px-4 font-bold text-slate-700">Действия</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{portalUsers.length === 0 ? (
|
|||
|
|
<tr>
|
|||
|
|
<td colSpan={4} className="py-8 px-4 text-center text-slate-500">
|
|||
|
|
Нет пользователей портала. Создайте первого выше.
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
) : (
|
|||
|
|
portalUsers.map((u) => (
|
|||
|
|
<tr key={u.id} className="border-b border-slate-100 hover:bg-slate-50/50">
|
|||
|
|
<td className="py-3 px-4">
|
|||
|
|
<span className="font-medium text-slate-800">{u.employeeName}</span>
|
|||
|
|
<span className="block text-xs text-slate-500">{u.employeePosition}</span>
|
|||
|
|
</td>
|
|||
|
|
<td className="py-3 px-4">{editingId === u.id ? (
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
defaultValue={u.login}
|
|||
|
|
onBlur={(e) => {
|
|||
|
|
const v = e.target.value.trim();
|
|||
|
|
if (v && v !== u.login) handleUpdate(u.id, { login: v });
|
|||
|
|
}}
|
|||
|
|
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
|||
|
|
className="w-full max-w-[140px] px-2 py-1 border border-slate-200 rounded text-sm"
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
u.login
|
|||
|
|
)}</td>
|
|||
|
|
<td className="py-3 px-4">
|
|||
|
|
{editingId === u.id ? (
|
|||
|
|
<select
|
|||
|
|
defaultValue={u.role}
|
|||
|
|
onChange={(e) => {
|
|||
|
|
const r = e.target.value as UserRole;
|
|||
|
|
if (r !== u.role) handleUpdate(u.id, { role: r });
|
|||
|
|
setEditingId(null);
|
|||
|
|
}}
|
|||
|
|
className="px-2 py-1 border border-slate-200 rounded text-sm"
|
|||
|
|
>
|
|||
|
|
{(Object.keys(ROLE_NAMES) as UserRole[]).map((r) => (
|
|||
|
|
<option key={r} value={r}>{ROLE_NAMES[r]}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
) : (
|
|||
|
|
ROLE_NAMES[u.role] || u.role
|
|||
|
|
)}
|
|||
|
|
</td>
|
|||
|
|
<td className="py-3 px-4 text-right">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => openPermissionsModal(u)}
|
|||
|
|
className="p-2 text-slate-500 hover:text-primary-600"
|
|||
|
|
title="Настроить права"
|
|||
|
|
>
|
|||
|
|
<Shield className="w-4 h-4" />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setEditingId(editingId === u.id ? null : u.id)}
|
|||
|
|
className="p-2 text-slate-500 hover:text-primary-600"
|
|||
|
|
title="Изменить"
|
|||
|
|
>
|
|||
|
|
<Pencil className="w-4 h-4" />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => handleDelete(u.id)}
|
|||
|
|
disabled={saving}
|
|||
|
|
className="p-2 text-slate-500 hover:text-red-600"
|
|||
|
|
title="Удалить"
|
|||
|
|
>
|
|||
|
|
<Trash2 className="w-4 h-4" />
|
|||
|
|
</button>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
))
|
|||
|
|
)}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{permissionsModalUserId != null && (
|
|||
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md" onClick={() => setPermissionsModalUserId(null)}>
|
|||
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto p-6" onClick={(e) => e.stopPropagation()}>
|
|||
|
|
<div className="flex justify-between items-center mb-4">
|
|||
|
|
<h4 className="font-bold text-slate-800">Права доступа</h4>
|
|||
|
|
<button type="button" onClick={() => setPermissionsModalUserId(null)} className="p-2 hover:bg-slate-100 rounded-full">
|
|||
|
|
<X className="w-5 h-5 text-slate-500" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-sm text-slate-500 mb-4">Права задаются шаблоном или вручную. Роль можно подставить из шаблона. Пустой список — права по роли.</p>
|
|||
|
|
{templatesByPosition.length > 0 && currentModalUser && (
|
|||
|
|
<div className="mb-4">
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-2">Применить по должности</label>
|
|||
|
|
<select
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
onChange={(e) => {
|
|||
|
|
const id = Number(e.target.value);
|
|||
|
|
const t = permissionTemplates.find((x) => x.id === id);
|
|||
|
|
if (t && permissionsModalUserId != null) applyTemplateToUser(permissionsModalUserId, t);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<option value="">— Выберите шаблон по должности —</option>
|
|||
|
|
{templatesByPosition.map((t) => (
|
|||
|
|
<option key={t.id} value={t.id}>{t.forPosition} → {t.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
{currentModalUser && (
|
|||
|
|
<p className="text-xs text-slate-500 mt-1">Должность пользователя: {currentModalUser.employeePosition}</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{permissionTemplates.length > 0 && (
|
|||
|
|
<div className="mb-4">
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-2">Применить шаблон</label>
|
|||
|
|
<select
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
onChange={(e) => {
|
|||
|
|
const id = Number(e.target.value);
|
|||
|
|
const t = permissionTemplates.find((x) => x.id === id);
|
|||
|
|
if (t && permissionsModalUserId != null) applyTemplateToUser(permissionsModalUserId, t);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<option value="">— Выберите шаблон —</option>
|
|||
|
|
{permissionTemplates.map((t) => (
|
|||
|
|
<option key={t.id} value={t.id}>{t.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<div className="mb-4">
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Обзор данных (дашборды)</label>
|
|||
|
|
<select
|
|||
|
|
value={userScope}
|
|||
|
|
onChange={(e) => setUserScope(e.target.value as 'all' | 'own_district')}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
>
|
|||
|
|
<option value="all">По всем участкам</option>
|
|||
|
|
<option value="own_district">Только свой участок</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div className="mb-4">
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-2">Разделы и подразделы (вручную)</label>
|
|||
|
|
<div className="space-y-1 max-h-[280px] overflow-y-auto pr-2">
|
|||
|
|
{ALL_SECTION_KEYS.map((section) => {
|
|||
|
|
const subs = SECTION_SUBS[section];
|
|||
|
|
const sectionChecked = hasUserSection(section);
|
|||
|
|
return (
|
|||
|
|
<div key={section} className="rounded-lg border border-slate-200 bg-white overflow-hidden">
|
|||
|
|
<label className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-slate-50">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={sectionChecked}
|
|||
|
|
onChange={() => toggleUserSection(section)}
|
|||
|
|
className="sr-only"
|
|||
|
|
/>
|
|||
|
|
{sectionChecked ? (
|
|||
|
|
<CheckSquare className="w-4 h-4 text-primary-600 shrink-0" />
|
|||
|
|
) : (
|
|||
|
|
<Square className="w-4 h-4 text-slate-400 shrink-0" />
|
|||
|
|
)}
|
|||
|
|
<span className="text-sm font-medium text-slate-800">{SECTION_LABELS[section] || section}</span>
|
|||
|
|
{subs && subs.length > 0 && !sectionChecked && (
|
|||
|
|
<span className="text-xs text-slate-500">
|
|||
|
|
({subs.filter((s) => hasUserSub(section, s.id)).length} из {subs.length})
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</label>
|
|||
|
|
{subs && subs.length > 0 && !sectionChecked && (
|
|||
|
|
<div className="pl-6 pr-3 pb-2 pt-0 space-y-2 border-t border-slate-100">
|
|||
|
|
{subs.map((sub) => (
|
|||
|
|
<div key={sub.id} className="flex items-center justify-between gap-2 py-1">
|
|||
|
|
<span className="text-xs text-slate-600">{sub.label}</span>
|
|||
|
|
<select
|
|||
|
|
value={getUserSubLevel(section, sub.id)}
|
|||
|
|
onChange={(e) => setUserSubLevel(section, sub.id, e.target.value as PermissionLevel)}
|
|||
|
|
className="text-xs border border-slate-200 rounded-lg px-2 py-1 bg-white min-w-[140px]"
|
|||
|
|
>
|
|||
|
|
<option value="none">Нет доступа</option>
|
|||
|
|
<option value="read">{PERMISSION_LEVEL_LABELS.read}</option>
|
|||
|
|
<option value="edit">{PERMISSION_LEVEL_LABELS.edit}</option>
|
|||
|
|
<option value="own">{PERMISSION_LEVEL_LABELS.own}</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={saveUserPermissions}
|
|||
|
|
disabled={saving}
|
|||
|
|
className="px-4 py-2 bg-primary-600 text-white text-sm font-bold rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{saving ? <Loader2 className="w-4 h-4 animate-spin inline" /> : 'Сохранить'}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setPermissionsModalUserId(null)}
|
|||
|
|
className="px-4 py-2 border border-slate-200 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-50"
|
|||
|
|
>
|
|||
|
|
Отмена
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|