Files
project.linkedcorp.net/frontend/src/pages/Organizacion/UsersPanel.jsx
T
2026-10-29 17:06:10 -05:00

279 lines
15 KiB
React

import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Save, UserX, UserCheck, Check, X as XIcon, Mail, LayoutGrid, Monitor } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser, resendUserEmail, listUserSessions, revokeUserSession, revokeAllUserSessions } from '../../services/users.js';
import { listDepartments } from '../../services/departments.js';
import { listEmployees } from '../../services/integrations.js';
import { ListView } from '../../components/ListView.jsx';
import { SearchInput } from '../../components/SearchInput.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { UserModulesDialog } from './UserModulesDialog.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
import { PasswordInput } from '../../components/PasswordInput.jsx';
import { SessionsList } from '../../components/SessionsList.jsx';
import { EmailChangeRequests } from './EmailChangeRequests.jsx';
function flatten(nodes, acc = []) {
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
return acc;
}
const STATUS_COLOR = { pending_verification: 'muted', pending_approval: 'warning', active: 'success', invited: 'muted' };
const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '', employeeId: '' };
// Nómina viene en MAYÚSCULAS: "RIERA CAJAMARCA" → "Riera Cajamarca".
const titleCase = (s) => String(s || '').toLowerCase().replace(/(^|\s)\p{L}/gu, (m) => m.toUpperCase()).trim();
const fold = (s) => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toUpperCase();
export default function UsersPanel() {
const { t, pushToast, openDialog } = useUi();
const { user: currentUser, logout } = useAuth();
const navigate = useNavigate();
const runAction = useAction();
const { data: users, reload } = useAsync(listUsers, []);
const { data: roles } = useAsync(listRoles, []);
const { data: deptTree } = useAsync(listDepartments, []);
const canManage = ['admin', 'superadmin'].includes(currentUser?.roleCode);
const isSuperadmin = currentUser?.roleCode === 'superadmin';
const [modulesUser, setModulesUser] = useState(null);
const [sessionsUser, setSessionsUser] = useState(null);
const loadSessions = useCallback(() => listUserSessions(sessionsUser.id), [sessionsUser]);
const revokeSession = useCallback((id) => revokeUserSession(sessionsUser.id, id), [sessionsUser]);
const revokeAllSessions = useCallback(() => revokeAllUserSessions(sessionsUser.id), [sessionsUser]);
const [search, setSearch] = useState('');
const [employees, setEmployees] = useState([]);
const [editing, setEditing] = useState(false);
const [selected, setSelected] = useState(null);
const [approving, setApproving] = useState(null);
const guardedClose = useGuardedClose();
// Borrador del usuario (nuevo o edicion). La contraseña nunca se guarda como borrador.
const draft = useDraftForm({
formId: selected ? 'user-edit' : 'user-new', entityId: selected?.id ?? null,
initial: selected
? { name: selected.name, email: selected.email, roleId: selected.role_id, departmentId: selected.department_id || '', position: selected.position || '', password: '', editVersion: selected.edit_version }
: EMPTY_FORM,
label: selected ? `${t('common_edit')}: ${selected.name}` : t('user_new'), enabled: editing, exclude: ['password'],
});
const form = draft.values;
const setForm = draft.setValues;
const collaboratorRole = (roles || []).find((r) => r.code === 'collaborator');
const approveDraft = useDraftForm({
formId: 'user-approve', entityId: approving?.id,
initial: { roleId: approving?.role_id || collaboratorRole?.id || '', departmentId: '', editVersion: approving?.edit_version },
label: `${t('user_approve_title')}: ${approving?.name || ''}`, enabled: !!approving,
});
const approveForm = approveDraft.values;
const setApproveForm = approveDraft.setValues;
const pendingNew = hasDraftFor(useUserDrafts(), 'user-new', null);
useReopenDraft({ newFormId: canManage ? 'user-new' : null, editFormId: canManage ? 'user-edit' : null, items: users, openNew: () => startNew(), openEdit: (u) => startEdit(u) });
useReopenDraft({ editFormId: canManage ? 'user-approve' : null, items: users, openEdit: (u) => setApproving(u) });
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
const roleOptions = (roles || []).map((r) => ({ value: r.id, label: r.name }));
function startNew() {
setSelected(null);
setEditing(true);
// Empleados de nómina que aún no tienen usuario (si hay fuente de nómina configurada).
listEmployees(true).then(setEmployees).catch(() => setEmployees([]));
}
function pickEmployee(id) {
const e = employees.find((x) => x.id === Number(id));
if (!e) return setForm((f) => ({ ...f, employeeId: '' }));
const dept = deptOptions.find((d) => fold(d.label) === fold(e.department_name));
setForm((f) => ({ ...f, employeeId: e.id, name: titleCase(`${e.first_names} ${e.last_names}`), email: e.email || f.email, position: e.position ? titleCase(e.position) : f.position, departmentId: dept ? dept.value : f.departmentId }));
}
function startEdit(user) {
if (!canManage) return;
setSelected(user);
setEditing(true);
}
async function save() {
if (!form.name.trim() || !form.email.trim() || !form.roleId) return;
if (!selected && !form.password) return;
const run = () => runAction(async () => {
if (selected) {
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, editVersion: form.editVersion });
} else {
await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password, employeeId: form.employeeId || undefined });
}
draft.clear();
await reload();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
});
if (selected) await draft.guardSave(run); else await run();
}
async function toggleActive(user) {
await runAction(async () => {
await setUserActive(user.id, !user.is_active);
await reload();
});
}
function startApprove(user) {
setApproving(user);
}
async function confirmApprove() {
await approveDraft.guardSave(() => runAction(async () => {
await approveUser(approving.id, { roleId: approveForm.roleId, departmentId: approveForm.departmentId || null, editVersion: approveForm.editVersion });
approveDraft.clear();
setApproving(null);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
}));
}
function resendEmail(user) {
runAction(async () => {
await resendUserEmail(user.id);
pushToast({ tone: 'success', message: t('user_resend_sent') });
});
}
function confirmReject(user) {
openDialog({
tone: 'warning',
title: t('user_reject'),
message: t('user_reject_confirm_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{ label: t('user_reject'), variant: 'danger', onClick: () => runAction(async () => { await rejectUser(user.id); await reload(); }) },
],
});
}
const columns = [
{ key: 'name', label: t('common_name') },
{ key: 'email', label: t('user_email') },
{ key: 'role_name', label: t('user_role') },
{ key: 'department_name', label: t('common_department'), render: (r) => r.department_name || '—' },
{ key: 'status', label: t('common_status'), sortable: false, render: (r) => (
<Badge color={STATUS_COLOR[r.status] || 'muted'}>{t(`user_status_${r.status}`)}</Badge>
) },
];
if (!users) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<div className="toolbar">
<div className="toolbar-left">
<SearchInput value={search} onChange={setSearch} />
</div>
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}{pendingNew && !editing && <DraftMark />}</Button>}
</div>
{canManage && <EmailChangeRequests />}
<ListView
columns={columns}
rows={users}
searchValue={search}
getSearchHaystack={(u) => `${u.name} ${u.email} ${u.role_name} ${u.department_name || ''}`}
getRowKey={(u) => u.id}
onRowClick={canManage ? startEdit : undefined}
rowActions={canManage ? (u) => {
if (u.status === 'pending_approval') {
return [
{ label: t('user_approve'), icon: Check, onClick: () => startApprove(u) },
{ label: t('user_reject'), icon: XIcon, danger: true, onClick: () => confirmReject(u) },
];
}
if (u.status === 'pending_verification') {
return [
{ label: t('user_resend'), icon: Mail, onClick: () => resendEmail(u) },
{ label: t('user_reject'), icon: XIcon, danger: true, onClick: () => confirmReject(u) },
];
}
if (u.status === 'invited') {
return [{ label: t('user_resend'), icon: Mail, onClick: () => resendEmail(u) }];
}
return [
{ label: t('common_edit'), onClick: () => startEdit(u) },
...(u.role_code !== 'superadmin' || isSuperadmin ? [{ label: t('sessions_title'), icon: Monitor, onClick: () => setSessionsUser(u) }] : []),
...(isSuperadmin && u.role_code !== 'superadmin' ? [{ label: t('user_modules'), icon: LayoutGrid, onClick: () => setModulesUser(u) }] : []),
u.is_active
? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) }
: { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) },
];
} : undefined}
/>
{modulesUser && <UserModulesDialog user={modulesUser} onClose={() => setModulesUser(null)} />}
<DetailPopup open={!!sessionsUser} title={`${t('sessions_title')}: ${sessionsUser?.name || ''}`} onClose={() => setSessionsUser(null)} width={850} height={540}>
{sessionsUser && <SessionsList load={loadSessions} revoke={revokeSession} revokeAll={revokeAllSessions}
allLabel={t('sessions_close_all')} onCurrentRevoked={() => { logout(); navigate('/login'); }} />}
</DetailPopup>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
<DraftNotice draft={draft} />
{!selected && employees.length > 0 && (
<Field label={t('user_from_payroll')} dirty={draft.isFieldDirty('employeeId')}>
<ComboBox options={[{ value: '', label: '—' }, ...employees.map((e) => ({ value: e.id, label: `${e.last_names} ${e.first_names} · ${e.identification}${e.position ? ` · ${e.position}` : ''}` }))]} value={form.employeeId} onChange={pickEmployee} />
</Field>
)}
<FieldRow>
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('user_email')} dirty={draft.isFieldDirty('email')}>
<input className="input" type="email" value={form.email} disabled={!!selected} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('user_role')} dirty={draft.isFieldDirty('roleId')}>
<ComboBox options={roleOptions} value={form.roleId} onChange={(v) => setForm((f) => ({ ...f, roleId: v }))} />
</Field>
<Field label={t('common_department')} dirty={draft.isFieldDirty('departmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm((f) => ({ ...f, departmentId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('user_position')} dirty={draft.isFieldDirty('position')}>
<input className="input" value={form.position} onChange={(e) => setForm((f) => ({ ...f, position: e.target.value }))} />
</Field>
{!selected && (
<Field label={t('user_password')} dirty={draft.isFieldDirty('password')}>
<PasswordInput value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
</Field>
)}
</FieldRow>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
<DetailPopup open={!!approving} title={t('user_approve_title')} onClose={() => guardedClose(approveDraft, () => setApproving(null))} width={420} height={340}>
<DraftNotice draft={approveDraft} />
<FieldRow>
<Field label={t('user_role')} dirty={approveDraft.isFieldDirty('roleId')}>
<ComboBox options={roleOptions} value={approveForm.roleId} onChange={(v) => setApproveForm((f) => ({ ...f, roleId: v }))} />
</Field>
<Field label={t('common_department')} dirty={approveDraft.isFieldDirty('departmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={approveForm.departmentId} onChange={(v) => setApproveForm((f) => ({ ...f, departmentId: v }))} />
</Field>
</FieldRow>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Check} onClick={confirmApprove}>{t('user_approve')}</Button>
<Button variant="standard" onClick={() => { approveDraft.discard(); setApproving(null); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
</div>
);
}