feat: version 4.12.0 -- Conexiones externas: pacientes de SAGA, empleados de nomina y medicos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"version": "4.11.0",
|
||||
"version": "4.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"version": "4.11.0",
|
||||
"version": "4.12.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.16",
|
||||
"highlight.js": "^11.12.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "4.11.0",
|
||||
"version": "4.12.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -663,6 +663,69 @@ export const messages = {
|
||||
|
||||
org_departments: 'Departamentos',
|
||||
org_users: 'Usuarios',
|
||||
config_integrations: 'Conexiones externas',
|
||||
int_connections: 'Conexiones',
|
||||
int_new_connection: 'Nueva conexión',
|
||||
int_url: 'URL del conector',
|
||||
int_token: 'Token del conector',
|
||||
int_token_keep: 'Déjalo vacío para conservar el token guardado.',
|
||||
int_test: 'Probar conexión',
|
||||
int_test_ok: 'Conexión correcta',
|
||||
int_sources: 'Fuentes de datos',
|
||||
int_purpose_patients: 'Pacientes (Agenda médica)',
|
||||
int_purpose_employees: 'Empleados (nómina)',
|
||||
int_not_configured: 'Sin configurar',
|
||||
int_configure: 'Configurar',
|
||||
int_connection: 'Conexión',
|
||||
int_source: 'Fuente',
|
||||
int_table: 'Tabla',
|
||||
int_mode: 'Modo',
|
||||
int_mode_import: 'Importar (copia en el sistema)',
|
||||
int_mode_live: 'Consulta en vivo',
|
||||
int_preview: 'Vista previa (20 filas)',
|
||||
int_mapping: 'Campos a usar',
|
||||
int_filter: 'Filtro (solo registros con este valor)',
|
||||
int_filter_field: 'Campo',
|
||||
int_filter_value: 'Valor',
|
||||
int_changed_field: 'Campo de última modificación (para traer solo cambios)',
|
||||
int_last_sync: 'Última sincronización',
|
||||
int_sync_now: 'Traer cambios',
|
||||
int_sync_full: 'Sincronización completa',
|
||||
int_schedule_hint: 'Automático: completa de lunes a viernes a las 05:00 y solo cambios cada 15 min de 06:00 a 21:00.',
|
||||
int_received: 'recibidos',
|
||||
int_created: 'nuevos',
|
||||
int_updated: 'actualizados',
|
||||
int_deactivated: 'inactivados',
|
||||
int_target_identification: 'Cédula / identificación',
|
||||
int_target_lastNames: 'Apellidos',
|
||||
int_target_firstNames: 'Nombres',
|
||||
int_target_phone: 'Teléfono',
|
||||
int_target_phone2: 'Teléfono 2',
|
||||
int_target_birthDate: 'Nacimiento',
|
||||
int_target_position: 'Cargo',
|
||||
int_target_department: 'Departamento',
|
||||
int_target_hireDate: 'Ingreso',
|
||||
int_target_leaveDate: 'Salida',
|
||||
int_target_active: 'Activo',
|
||||
int_target_externalId: 'Código en el origen',
|
||||
int_target_fullName: 'Nombre completo',
|
||||
int_target_email: 'Correo',
|
||||
int_required: 'obligatorio',
|
||||
org_employees: 'Empleados',
|
||||
emp_origin: 'Origen',
|
||||
emp_origin_payroll: 'Nómina',
|
||||
emp_origin_provider: 'Proveedor (SAGA)',
|
||||
emp_protected_hint: 'Los empleados con usuario en el sistema no se actualizan ni se inactivan al actualizar.',
|
||||
emp_update: 'Actualizar empleados',
|
||||
emp_never: 'Nunca actualizado',
|
||||
emp_user: 'Usuario del sistema',
|
||||
emp_no_source: 'No hay fuente de nómina configurada (Configuración → Conexiones externas).',
|
||||
user_from_payroll: 'Desde nómina',
|
||||
clinic_origin: 'Origen',
|
||||
clinic_origin_local: 'Creado aquí',
|
||||
clinic_source_name: 'Nombre en el origen',
|
||||
clinic_name_unsplit: 'Nombre traído del origen sin separar: al editar, separa apellidos y nombres.',
|
||||
clinic_external_results: 'Del sistema origen',
|
||||
department_new: 'Nuevo departamento',
|
||||
department_parent: 'Departamento padre',
|
||||
user_new: 'Nuevo usuario',
|
||||
@@ -1357,6 +1420,69 @@ export const messages = {
|
||||
|
||||
org_departments: 'Departments',
|
||||
org_users: 'Users',
|
||||
config_integrations: 'External connections',
|
||||
int_connections: 'Connections',
|
||||
int_new_connection: 'New connection',
|
||||
int_url: 'Connector URL',
|
||||
int_token: 'Connector token',
|
||||
int_token_keep: 'Leave empty to keep the saved token.',
|
||||
int_test: 'Test connection',
|
||||
int_test_ok: 'Connection OK',
|
||||
int_sources: 'Data sources',
|
||||
int_purpose_patients: 'Patients (Medical schedule)',
|
||||
int_purpose_employees: 'Employees (payroll)',
|
||||
int_not_configured: 'Not configured',
|
||||
int_configure: 'Configure',
|
||||
int_connection: 'Connection',
|
||||
int_source: 'Source',
|
||||
int_table: 'Table',
|
||||
int_mode: 'Mode',
|
||||
int_mode_import: 'Import (copy into the system)',
|
||||
int_mode_live: 'Live lookup',
|
||||
int_preview: 'Preview (20 rows)',
|
||||
int_mapping: 'Fields to use',
|
||||
int_filter: 'Filter (only records with this value)',
|
||||
int_filter_field: 'Field',
|
||||
int_filter_value: 'Value',
|
||||
int_changed_field: 'Last-modified field (to fetch only changes)',
|
||||
int_last_sync: 'Last sync',
|
||||
int_sync_now: 'Fetch changes',
|
||||
int_sync_full: 'Full sync',
|
||||
int_schedule_hint: 'Automatic: full sync Monday to Friday at 05:00 and changes only every 15 min from 06:00 to 21:00.',
|
||||
int_received: 'received',
|
||||
int_created: 'new',
|
||||
int_updated: 'updated',
|
||||
int_deactivated: 'deactivated',
|
||||
int_target_identification: 'ID number',
|
||||
int_target_lastNames: 'Last names',
|
||||
int_target_firstNames: 'First names',
|
||||
int_target_phone: 'Phone',
|
||||
int_target_phone2: 'Phone 2',
|
||||
int_target_birthDate: 'Birth date',
|
||||
int_target_position: 'Position',
|
||||
int_target_department: 'Department',
|
||||
int_target_hireDate: 'Hire date',
|
||||
int_target_leaveDate: 'Leave date',
|
||||
int_target_active: 'Active',
|
||||
int_target_externalId: 'Source code',
|
||||
int_target_fullName: 'Full name',
|
||||
int_target_email: 'Email',
|
||||
int_required: 'required',
|
||||
org_employees: 'Employees',
|
||||
emp_origin: 'Source',
|
||||
emp_origin_payroll: 'Payroll',
|
||||
emp_origin_provider: 'Supplier (SAGA)',
|
||||
emp_protected_hint: 'Employees with a system user are not updated or deactivated by the update.',
|
||||
emp_update: 'Update employees',
|
||||
emp_never: 'Never updated',
|
||||
emp_user: 'System user',
|
||||
emp_no_source: 'No payroll source configured (Settings → External connections).',
|
||||
user_from_payroll: 'From payroll',
|
||||
clinic_origin: 'Source',
|
||||
clinic_origin_local: 'Created here',
|
||||
clinic_source_name: 'Name in source',
|
||||
clinic_name_unsplit: 'Name imported without split: when editing, separate last and first names.',
|
||||
clinic_external_results: 'From source system',
|
||||
department_new: 'New department',
|
||||
department_parent: 'Parent department',
|
||||
user_new: 'New user',
|
||||
|
||||
@@ -54,7 +54,10 @@ export default function AgendaMedicaPage() {
|
||||
[patientTotal, setPatientTotal] = useState(0);
|
||||
// Selector de paciente dentro de la cita: estado propio, se reinicia en cada cita.
|
||||
const [apptPatients, setApptPatients] = useState([]),
|
||||
[apptSearch, setApptSearch] = useState('');
|
||||
[apptSearch, setApptSearch] = useState(''),
|
||||
// Pacientes del sistema origen (Conexiones externas, modo consulta en vivo): aún no importados.
|
||||
[externalPatients, setExternalPatients] = useState([]),
|
||||
[patientSource, setPatientSource] = useState(null);
|
||||
const [popup, setPopup] = useState(null),
|
||||
[editing, setEditing] = useState(false),
|
||||
[conflict, setConflict] = useState(''),
|
||||
@@ -98,6 +101,7 @@ export default function AgendaMedicaPage() {
|
||||
api.clinicMe().then(async staff => {
|
||||
setMe(staff);
|
||||
await loadDoctors();
|
||||
if (staff.scope === 'all') api.clinicPatientSource().then(setPatientSource).catch(() => setPatientSource(null));
|
||||
}).catch(e => {
|
||||
if (isAdmin) setTab('config');
|
||||
else setError(e.message);
|
||||
@@ -169,6 +173,7 @@ export default function AgendaMedicaPage() {
|
||||
if (kind === 'appointment' && isReception) {
|
||||
// Búsqueda propia de la cita: arranca vacía en cada cita y no toca la pestaña Pacientes.
|
||||
setApptSearch('');
|
||||
setExternalPatients([]);
|
||||
const current = item ? [{ id: item.patient_id, first_names: item.first_names, last_names: item.last_names }] : extra.patient ? [extra.patient] : [];
|
||||
setApptPatients(current);
|
||||
api.clinicPatients('', 1).then(x => setApptPatients([...current, ...x.items.filter(p => !current.some(c => c.id === p.id))])).catch(() => {});
|
||||
@@ -180,6 +185,9 @@ export default function AgendaMedicaPage() {
|
||||
function searchApptPatients(text) {
|
||||
setApptSearch(text);
|
||||
const seq = ++searchSeq.current;
|
||||
if (patientSource?.mode === 'live' && text.trim().length >= 3) {
|
||||
api.clinicExternalPatients(text).then(rows => { if (seq === searchSeq.current) setExternalPatients(rows.filter(r => !r.local_id)); }).catch(() => setExternalPatients([]));
|
||||
} else setExternalPatients([]);
|
||||
api.clinicPatients(text, 1).then(x => {
|
||||
if (seq !== searchSeq.current) return;
|
||||
setApptPatients(prev => {
|
||||
@@ -188,6 +196,18 @@ export default function AgendaMedicaPage() {
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
// Elegir paciente; si es del sistema origen (ext:código) se trae primero al sistema.
|
||||
async function pickApptPatient(value) {
|
||||
if (!String(value).startsWith('ext:')) return draft.setField('patientId', value);
|
||||
try {
|
||||
const p = await api.clinicImportExternalPatient(String(value).slice(4));
|
||||
setApptPatients(list => [p, ...list.filter(x => x.id !== p.id)]);
|
||||
setExternalPatients(list => list.filter(x => `ext:${x.external_id}` !== value));
|
||||
draft.setField('patientId', p.id);
|
||||
} catch (e) {
|
||||
setConflict(e.message);
|
||||
}
|
||||
}
|
||||
// "Nuevo paciente" desde la cita: la cita queda abierta debajo y se vuelve a ella al guardar,
|
||||
// cancelar o cerrar. Si lo buscado parece una cédula, se precarga.
|
||||
function newPatientFromAppointment() {
|
||||
@@ -403,7 +423,9 @@ export default function AgendaMedicaPage() {
|
||||
] : popup.kind === 'patient' ? [
|
||||
[t('clinic_first_names'), it.first_names], [t('clinic_last_names'), it.last_names],
|
||||
[t('clinic_identification'), it.identification], [t('clinic_phone'), it.phone],
|
||||
[t('clinic_email'), it.email], [t('clinic_birth_date'), it.birth_date], [t('clinic_notes'), it.notes]
|
||||
[t('clinic_email'), it.email], [t('clinic_birth_date'), it.birth_date], [t('clinic_notes'), it.notes],
|
||||
[t('clinic_origin'), it.external_source ? `${t('clinic_external_results')} (${it.external_id})` : t('clinic_origin_local')],
|
||||
[t('clinic_source_name'), it.external_source && it.name_split ? it.source_name : null]
|
||||
] : [
|
||||
[t('clinic_doctor'), doctorName(it.doctor_user_id)], [t('clinic_kind'), t(`clinic_kind_${it.kind}`)],
|
||||
[t('clinic_title'), it.title], [t('clinic_created_by'), it.created_by_name],
|
||||
@@ -520,8 +542,8 @@ export default function AgendaMedicaPage() {
|
||||
<Button variant="danger" icon={Trash2} onClick={deleteBlock}>{t('common_delete')}</Button>
|
||||
</div>}
|
||||
</> : <>
|
||||
{popup.kind === 'appointment' && <AppointmentForm draft={draft} doctors={doctors} types={apptTypes} patients={apptPatients} search={apptSearch} onSearch={searchApptPatients} onNewPatient={newPatientFromAppointment} slots={slots} upcoming={upcoming} />}
|
||||
{popup.kind === 'patient' && <PatientForm draft={draft} />}
|
||||
{popup.kind === 'appointment' && <AppointmentForm draft={draft} doctors={doctors} types={apptTypes} patients={apptPatients} externalPatients={externalPatients} onPatientChange={x => void pickApptPatient(x)} search={apptSearch} onSearch={searchApptPatients} onNewPatient={newPatientFromAppointment} slots={slots} upcoming={upcoming} />}
|
||||
{popup.kind === 'patient' && <PatientForm draft={draft} unsplit={!!it && !it.name_split} />}
|
||||
{popup.kind === 'block' && <BlockForm draft={draft} doctors={doctors} fixedDoctor={!!it} />}
|
||||
<div className="toolbar-left clinic-actions">
|
||||
<Button variant="accent" disabled={!canSave || (!!it && !draft.isDirty)} onClick={() => void save()}>{t('common_save')}</Button>
|
||||
|
||||
@@ -12,9 +12,9 @@ import { plus } from './clinicUtils.js';
|
||||
// Formularios de la Agenda médica con la misma estructura que "Nueva reunión" de Calendario:
|
||||
// campos cortos en pares (FieldRow), texto largo a lo ancho, fecha + hora con DateTimeEditor.
|
||||
|
||||
const patientLabel = (p) => `${p.last_names} ${p.first_names}${p.identification || p.phone ? ` · ${p.identification || p.phone}` : ''}`;
|
||||
const patientLabel = (p) => `${`${p.last_names} ${p.first_names}`.trim()}${p.identification || p.phone ? ` · ${p.identification || p.phone}` : ''}`;
|
||||
|
||||
export function AppointmentForm({ draft, doctors, types, patients, search, onSearch, onNewPatient, slots, upcoming = [] }) {
|
||||
export function AppointmentForm({ draft, doctors, types, patients, externalPatients = [], onPatientChange, search, onSearch, onNewPatient, slots, upcoming = [] }) {
|
||||
const { t } = useUi();
|
||||
const v = draft.values;
|
||||
const duration = (typeId) => types.find((z) => z.id === Number(typeId))?.duration_minutes || 30;
|
||||
@@ -23,7 +23,7 @@ export function AppointmentForm({ draft, doctors, types, patients, search, onSea
|
||||
<Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={(e) => onSearch(e.target.value)} /></Field>
|
||||
<Field label={t('clinic_patient')} dirty={draft.isFieldDirty('patientId')}>
|
||||
<div className="clinic-inline">
|
||||
<ComboBox value={v.patientId} onChange={(x) => draft.setField('patientId', x)} options={[{ value: '', label: t('common_select') }, ...patients.map((p) => ({ value: p.id, label: patientLabel(p) }))]} />
|
||||
<ComboBox value={v.patientId} onChange={onPatientChange} options={[{ value: '', label: t('common_select') }, ...patients.map((p) => ({ value: p.id, label: patientLabel(p) })), ...externalPatients.map((p) => ({ value: `ext:${p.external_id}`, label: `${p.full_name}${p.identification ? ` · ${p.identification}` : ''} — ${t('clinic_external_results')}` }))]} />
|
||||
<Button icon={UserPlus} onClick={onNewPatient} title={t('clinic_new_patient')} aria-label={t('clinic_new_patient')} />
|
||||
</div>
|
||||
</Field>
|
||||
@@ -46,11 +46,12 @@ export function AppointmentForm({ draft, doctors, types, patients, search, onSea
|
||||
</>;
|
||||
}
|
||||
|
||||
export function PatientForm({ draft }) {
|
||||
export function PatientForm({ draft, unsplit = false }) {
|
||||
const { t } = useUi();
|
||||
const v = draft.values;
|
||||
const input = (k, label, extra = {}) => <Field label={t(label)} dirty={draft.isFieldDirty(k)}><input className="input" value={v[k] || ''} onChange={(e) => draft.setField(k, e.target.value)} {...extra} /></Field>;
|
||||
return <>
|
||||
{unsplit && <InfoBar tone="warning">{t('clinic_name_unsplit')}</InfoBar>}
|
||||
<FieldRow>{input('firstNames', 'clinic_first_names', { maxLength: 120 })}{input('lastNames', 'clinic_last_names', { maxLength: 120 })}</FieldRow>
|
||||
<FieldRow>{input('identification', 'clinic_identification', { maxLength: 40 })}{input('phone', 'clinic_phone', { maxLength: 40, type: 'tel' })}</FieldRow>
|
||||
<FieldRow>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw, Bug, Sparkles } from 'lucide-react';
|
||||
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw, Bug, Sparkles, Plug } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { Expander } from '../../components/Expander.jsx';
|
||||
@@ -13,6 +13,7 @@ import AiUsagePanel from './AiUsagePanel.jsx';
|
||||
import CompanySettingsPanel from './CompanySettingsPanel.jsx';
|
||||
import UpdaterPanel from './UpdaterPanel.jsx';
|
||||
import ErrorLogsPanel from './ErrorLogsPanel.jsx';
|
||||
import ExternalConnectionsPanel from './ExternalConnectionsPanel.jsx';
|
||||
|
||||
export default function ConfiguracionPage() {
|
||||
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
|
||||
@@ -117,6 +118,10 @@ export default function ConfiguracionPage() {
|
||||
/>
|
||||
</Expander>
|
||||
|
||||
<Expander icon={Plug} title={t('config_integrations')} draftForms={['ext-connection', 'ext-source']}>
|
||||
<ExternalConnectionsPanel />
|
||||
</Expander>
|
||||
|
||||
<Expander icon={Mail} title={t('config_smtp')} draftForms={['smtp-config']}>
|
||||
<SmtpSettingsPanel />
|
||||
</Expander>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/* Conexiones externas: mismo esquema que los formularios de la Agenda (pares de campos, acciones al pie). */
|
||||
.ext-panel h3:not(.mt-0){margin:20px 0 4px}
|
||||
.ext-panel h3.mt-0{margin-bottom:4px}
|
||||
.ext-hint{font-size:.78rem;margin:8px 0 0}
|
||||
.ext-form{display:grid;gap:12px;align-content:start}
|
||||
.ext-form .field{min-width:0}
|
||||
.ext-detail{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px 16px}
|
||||
.ext-detail p{margin:5px 0;overflow-wrap:anywhere}
|
||||
.ext-actions{margin-top:18px;flex-wrap:wrap}
|
||||
.ext-preview{overflow:auto;max-height:240px;border:1px solid rgb(var(--line));border-radius:var(--r-sm)}
|
||||
.ext-preview .list-table{font-size:.75rem;white-space:nowrap}
|
||||
.ext-subtitle{margin:4px 0 0;font-size:.9rem}
|
||||
.ext-map{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px 12px}
|
||||
@media(max-width:640px){.ext-detail{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Pencil, PlugZap, RefreshCw, DatabaseZap } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||
import * as api from '../../services/integrations.js';
|
||||
import './ExternalConnectionsPanel.css';
|
||||
|
||||
// Conexiones externas (superadmin): conectores de solo lectura y qué tabla alimenta Pacientes y
|
||||
// Empleados. Ver docs/CONEXIONES-EXTERNAS.md. Listado → popup en lectura → Editar.
|
||||
|
||||
const TARGETS = {
|
||||
patients: ['externalId', 'identification', 'fullName', 'phone', 'email'],
|
||||
employees: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active']
|
||||
};
|
||||
const REQUIRED = { patients: ['externalId', 'fullName'], employees: ['identification'] };
|
||||
const connForm = (c) => (c ? { name: c.name, baseUrl: c.base_url, token: '', isActive: c.is_active, editVersion: c.edit_version } : { name: '', baseUrl: '', token: '', isActive: true });
|
||||
const sourceForm = (purpose, s) => {
|
||||
const [filterField = '', filterValue = ''] = Object.entries(s?.filter || {})[0] || [];
|
||||
return s
|
||||
? { connectionId: s.connection_id, sourceKey: s.source_key, tableName: s.table_name, mode: s.mode, fieldMap: s.field_map, filterField, filterValue, changedField: s.changed_field || '', editVersion: s.edit_version }
|
||||
: { connectionId: '', sourceKey: '', tableName: '', mode: 'import', fieldMap: {}, filterField: '', filterValue: '', changedField: '' };
|
||||
};
|
||||
|
||||
export function syncSummary(t, r) {
|
||||
if (!r) return t('emp_never');
|
||||
return `${String(r.at).slice(0, 16)} · ${r.received ?? r.total} ${t('int_received')} · ${r.created} ${t('int_created')} · ${r.updated} ${t('int_updated')}${r.deactivated || r.removed ? ` · ${r.deactivated ?? r.removed} ${t('int_deactivated')}` : ''}`;
|
||||
}
|
||||
|
||||
export default function ExternalConnectionsPanel() {
|
||||
const { t, pushToast } = useUi();
|
||||
const guardedClose = useGuardedClose();
|
||||
const [connections, setConnections] = useState([]), [sources, setSources] = useState([]);
|
||||
const [popup, setPopup] = useState(null), [editing, setEditing] = useState(false), [error, setError] = useState(''), [busy, setBusy] = useState(false);
|
||||
const [remote, setRemote] = useState({ sources: [], tables: [], schema: null, sample: [] });
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
const [c, s] = await Promise.all([api.listConnections(), api.listSources()]);
|
||||
setConnections(c); setSources(s); setError('');
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
useEffect(() => { void reload(); }, []);
|
||||
|
||||
const sourceOf = (purpose) => sources.find((s) => s.purpose === purpose);
|
||||
const initial = !popup ? {} : popup.kind === 'connection' ? connForm(popup.item) : sourceForm(popup.purpose, sourceOf(popup.purpose));
|
||||
const draft = useDraftForm({
|
||||
formId: popup?.kind === 'source' ? 'ext-source' : 'ext-connection',
|
||||
entityId: popup?.kind === 'source' ? popup.purpose : popup?.item?.id,
|
||||
initial,
|
||||
label: popup?.kind === 'source' ? t(`int_purpose_${popup.purpose}`) : t('int_connections'),
|
||||
enabled: !!popup && editing,
|
||||
exclude: ['token'],
|
||||
onDiscard: () => { if (popup?.item || popup?.kind === 'source') setEditing(false); else setPopup(null); }
|
||||
});
|
||||
const v = draft.values;
|
||||
|
||||
// Exploración del conector mientras se edita una fuente: fuentes → tablas → estructura + muestra.
|
||||
useEffect(() => {
|
||||
if (popup?.kind !== 'source' || !editing || !v.connectionId) return undefined;
|
||||
let active = true;
|
||||
api.browseConnection(v.connectionId).then((x) => active && setRemote((r) => ({ ...r, sources: x.sources }))).catch((e) => active && setError(e.message));
|
||||
return () => { active = false; };
|
||||
}, [popup?.kind, editing, v.connectionId]);
|
||||
useEffect(() => {
|
||||
if (popup?.kind !== 'source' || !editing || !v.connectionId || !v.sourceKey) return undefined;
|
||||
let active = true;
|
||||
api.browseConnection(v.connectionId, v.sourceKey).then((x) => active && setRemote((r) => ({ ...r, tables: x.tables }))).catch((e) => active && setError(e.message));
|
||||
return () => { active = false; };
|
||||
}, [popup?.kind, editing, v.connectionId, v.sourceKey]);
|
||||
useEffect(() => {
|
||||
if (popup?.kind !== 'source' || !editing || !v.connectionId || !v.sourceKey || !v.tableName) { setRemote((r) => ({ ...r, schema: null, sample: [] })); return undefined; }
|
||||
let active = true;
|
||||
api.browseConnection(v.connectionId, v.sourceKey, v.tableName).then((x) => active && setRemote((r) => ({ ...r, schema: x.schema, sample: x.sample }))).catch((e) => active && setError(e.message));
|
||||
return () => { active = false; };
|
||||
}, [popup?.kind, editing, v.connectionId, v.sourceKey, v.tableName]);
|
||||
|
||||
function open(kind, item = null, extra = {}) {
|
||||
setError('');
|
||||
setRemote({ sources: [], tables: [], schema: null, sample: [] });
|
||||
setPopup({ kind, item, ...extra });
|
||||
setEditing(kind === 'connection' && !item);
|
||||
}
|
||||
const close = () => void guardedClose(editing ? draft : null, () => { setPopup(null); setEditing(false); });
|
||||
|
||||
async function run(fn, okMessage = t('common_saved')) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await fn();
|
||||
pushToast({ tone: 'success', message: okMessage });
|
||||
return result;
|
||||
} catch (e) { setError(e.message); return null; } finally { setBusy(false); }
|
||||
}
|
||||
async function save() {
|
||||
const saved = await run(async () => {
|
||||
if (popup.kind === 'connection') return api.saveConnection({ ...v, token: v.token || undefined }, popup.item?.id);
|
||||
return api.saveSource(popup.purpose, {
|
||||
connectionId: Number(v.connectionId), sourceKey: v.sourceKey, tableName: v.tableName, mode: v.mode, fieldMap: v.fieldMap,
|
||||
filter: v.filterField && v.filterValue ? { [v.filterField]: v.filterValue } : {}, changedField: v.changedField || null, editVersion: v.editVersion
|
||||
});
|
||||
});
|
||||
if (!saved) return;
|
||||
draft.clear();
|
||||
setPopup(null);
|
||||
setEditing(false);
|
||||
await reload();
|
||||
}
|
||||
const test = (id) => run(() => api.testConnection(id), t('int_test_ok'));
|
||||
async function sync(kind) {
|
||||
const r = await run(() => (kind === 'employees' ? api.syncEmployees() : api.syncPatients(kind === 'full')), t('common_saved'));
|
||||
if (r) { await reload(); setPopup((p) => p && { ...p }); }
|
||||
}
|
||||
|
||||
const fields = remote.schema?.fields?.map((f) => f.name) || [];
|
||||
const fieldOptions = [{ value: '', label: '—' }, ...fields.map((f) => ({ value: f, label: f }))];
|
||||
const canSave = popup?.kind === 'connection'
|
||||
? !!(v.name?.trim() && v.baseUrl?.trim() && (popup.item || v.token))
|
||||
: !!(v.connectionId && v.sourceKey && v.tableName && REQUIRED[popup?.purpose || 'patients'].every((k) => v.fieldMap?.[k]));
|
||||
const src = popup?.kind === 'source' ? sourceOf(popup.purpose) : null;
|
||||
|
||||
return <div className="ext-panel">
|
||||
{error && !popup && <InfoBar tone="error">{error}</InfoBar>}
|
||||
<div className="toolbar"><h3 className="mt-0">{t('int_connections')}</h3><Button variant="accent" icon={Plus} onClick={() => open('connection')}>{t('int_new_connection')}</Button></div>
|
||||
<ListView getRowKey={(c) => c.id} rows={connections} onRowClick={(c) => open('connection', c)}
|
||||
columns={[
|
||||
{ key: 'name', label: t('common_name') },
|
||||
{ key: 'base_url', label: t('int_url') },
|
||||
{ key: 'is_active', label: t('common_status'), render: (c) => <Badge color={c.is_active ? 'success' : 'muted'}>{t(c.is_active ? 'common_active' : 'common_inactive')}</Badge> }
|
||||
]}
|
||||
rowActions={(c) => [{ label: t('int_test'), icon: PlugZap, onClick: () => void test(c.id) }, { label: t('common_edit'), icon: Pencil, onClick: () => { open('connection', c); setEditing(true); } }]} />
|
||||
|
||||
<div className="toolbar"><h3>{t('int_sources')}</h3></div>
|
||||
<ListView getRowKey={(p) => p} rows={['patients', 'employees']} onRowClick={(p) => open('source', null, { purpose: p })}
|
||||
columns={[
|
||||
{ key: 'purpose', label: t('int_source'), sortable: false, render: (p) => t(`int_purpose_${p}`) },
|
||||
{ key: 'table', label: t('int_table'), sortable: false, render: (p) => { const s = sourceOf(p); return s ? `${s.connection_name} · ${s.source_key}/${s.table_name}` : <Badge color="muted">{t('int_not_configured')}</Badge>; } },
|
||||
{ key: 'mode', label: t('int_mode'), sortable: false, render: (p) => { const s = sourceOf(p); return s ? t(`int_mode_${s.mode}`) : '—'; } },
|
||||
{ key: 'sync', label: t('int_last_sync'), sortable: false, render: (p) => { const s = sourceOf(p); return s ? syncSummary(t, s.last_result) : '—'; } }
|
||||
]} />
|
||||
<p className="text-muted ext-hint">{t('int_schedule_hint')}</p>
|
||||
|
||||
<DetailPopup open={!!popup} title={popup?.kind === 'source' ? t(`int_purpose_${popup.purpose}`) : popup?.item ? popup.item.name : t('int_new_connection')} onClose={close} width={popup?.kind === 'source' ? 980 : 760} height={popup?.kind === 'source' ? 720 : 460}>
|
||||
{popup && <div className="ext-form">
|
||||
{error && <InfoBar tone="error">{error}</InfoBar>}
|
||||
{editing && <DraftNotice draft={draft} />}
|
||||
{!editing ? <>
|
||||
{popup.kind === 'connection' && <>
|
||||
<div className="ext-detail">
|
||||
<p><strong>{t('common_name')}:</strong> {popup.item.name}</p>
|
||||
<p><strong>{t('int_url')}:</strong> {popup.item.base_url}</p>
|
||||
<p><strong>{t('common_status')}:</strong> {t(popup.item.is_active ? 'common_active' : 'common_inactive')}</p>
|
||||
</div>
|
||||
<div className="toolbar-left ext-actions"><Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button icon={PlugZap} disabled={busy} onClick={() => void test(popup.item.id)}>{t('int_test')}</Button></div>
|
||||
</>}
|
||||
{popup.kind === 'source' && <>
|
||||
{!src ? <InfoBar tone="warning">{t('int_not_configured')}</InfoBar> : <div className="ext-detail">
|
||||
<p><strong>{t('int_connection')}:</strong> {src.connection_name}</p>
|
||||
<p><strong>{t('int_table')}:</strong> {src.source_key}/{src.table_name}</p>
|
||||
<p><strong>{t('int_mode')}:</strong> {t(`int_mode_${src.mode}`)}</p>
|
||||
{src.field_map && <p><strong>{t('int_mapping')}:</strong> {Object.entries(src.field_map).map(([k, f]) => `${t(`int_target_${k}`)} ← ${f}`).join(' · ')}</p>}
|
||||
{src.filter && Object.keys(src.filter).length > 0 && <p><strong>{t('int_filter')}:</strong> {Object.entries(src.filter).map(([k, x]) => `${k} = ${x}`).join(', ')}</p>}
|
||||
{src.changed_field && <p><strong>{t('int_changed_field')}:</strong> {src.changed_field}</p>}
|
||||
<p><strong>{t('int_last_sync')}:</strong> {syncSummary(t, src.last_result)}</p>
|
||||
</div>}
|
||||
<div className="toolbar-left ext-actions">
|
||||
<Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{src ? t('common_edit') : t('int_configure')}</Button>
|
||||
{src && popup.purpose === 'employees' && <Button icon={RefreshCw} disabled={busy} onClick={() => void sync('employees')}>{t('emp_update')}</Button>}
|
||||
{src && popup.purpose === 'patients' && src.mode === 'import' && <>
|
||||
<Button icon={RefreshCw} disabled={busy} onClick={() => void sync('changes')}>{t('int_sync_now')}</Button>
|
||||
<Button icon={DatabaseZap} disabled={busy} onClick={() => void sync('full')}>{t('int_sync_full')}</Button>
|
||||
</>}
|
||||
</div>
|
||||
</>}
|
||||
</> : <>
|
||||
{popup.kind === 'connection' && <>
|
||||
<FieldRow>
|
||||
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}><input className="input" maxLength={120} value={v.name} onChange={(e) => draft.setField('name', e.target.value)} /></Field>
|
||||
<Field label={t('int_url')} dirty={draft.isFieldDirty('baseUrl')}><input className="input" maxLength={255} placeholder="http://192.168.0.253:8455" value={v.baseUrl} onChange={(e) => draft.setField('baseUrl', e.target.value)} /></Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('int_token')} hint={popup.item ? t('int_token_keep') : undefined}><PasswordInput value={v.token} onChange={(e) => draft.setField('token', e.target.value)} /></Field>
|
||||
<Field label={t('common_status')}><label className="calendar-filter"><input type="checkbox" checked={v.isActive} onChange={(e) => draft.setField('isActive', e.target.checked)} /> {t('common_active')}</label></Field>
|
||||
</FieldRow>
|
||||
</>}
|
||||
{popup.kind === 'source' && <>
|
||||
<FieldRow>
|
||||
<Field label={t('int_connection')} dirty={draft.isFieldDirty('connectionId')}><ComboBox value={v.connectionId} onChange={(x) => draft.setValues((o) => ({ ...o, connectionId: x, sourceKey: '', tableName: '' }))} options={[{ value: '', label: t('common_select') }, ...connections.filter((c) => c.is_active).map((c) => ({ value: c.id, label: c.name }))]} /></Field>
|
||||
{popup.purpose === 'patients'
|
||||
? <Field label={t('int_mode')} dirty={draft.isFieldDirty('mode')}><ComboBox value={v.mode} onChange={(x) => draft.setField('mode', x)} options={['import', 'live'].map((x) => ({ value: x, label: t(`int_mode_${x}`) }))} /></Field>
|
||||
: <div className="field" />}
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('int_source')} dirty={draft.isFieldDirty('sourceKey')}><ComboBox value={v.sourceKey} onChange={(x) => draft.setValues((o) => ({ ...o, sourceKey: x, tableName: '' }))} options={[{ value: '', label: t('common_select') }, ...remote.sources.map((s) => ({ value: s.id, label: s.name }))]} /></Field>
|
||||
<Field label={t('int_table')} dirty={draft.isFieldDirty('tableName')}><ComboBox value={v.tableName} onChange={(x) => draft.setField('tableName', x)} options={[{ value: '', label: t('common_select') }, ...remote.tables.filter((x) => !x.error).map((x) => ({ value: x.name, label: `${x.name} (${x.records})` }))]} /></Field>
|
||||
</FieldRow>
|
||||
{v.tableName && !remote.schema && <p className="text-muted">{t('common_loading')}</p>}
|
||||
{remote.schema && <>
|
||||
<Field label={t('int_preview')}><div className="ext-preview"><table className="list-table"><thead><tr>{fields.map((f) => <th key={f}>{f}</th>)}</tr></thead><tbody>{remote.sample.map((r) => <tr key={r._recno}>{fields.map((f) => <td key={f}>{r[f] === null || r[f] === undefined ? '' : String(r[f])}</td>)}</tr>)}</tbody></table></div></Field>
|
||||
<h4 className="ext-subtitle">{t('int_mapping')}</h4>
|
||||
<div className="ext-map">
|
||||
{TARGETS[popup.purpose].map((k) => <Field key={k} label={`${t(`int_target_${k}`)}${REQUIRED[popup.purpose].includes(k) ? ` (${t('int_required')})` : ''}`}>
|
||||
<ComboBox value={v.fieldMap?.[k] || ''} onChange={(x) => draft.setValues((o) => ({ ...o, fieldMap: { ...o.fieldMap, [k]: x || undefined } }))} options={fieldOptions} />
|
||||
</Field>)}
|
||||
</div>
|
||||
<FieldRow>
|
||||
<Field label={`${t('int_filter')} · ${t('int_filter_field')}`}><ComboBox value={v.filterField} onChange={(x) => draft.setField('filterField', x)} options={fieldOptions} /></Field>
|
||||
<Field label={t('int_filter_value')}><input className="input" maxLength={60} value={v.filterValue} onChange={(e) => draft.setField('filterValue', e.target.value)} /></Field>
|
||||
</FieldRow>
|
||||
{popup.purpose === 'patients' && <FieldRow>
|
||||
<Field label={t('int_changed_field')}><ComboBox value={v.changedField} onChange={(x) => draft.setField('changedField', x)} options={[{ value: '', label: '—' }, ...(remote.schema.fields || []).filter((f) => f.type === 'T').map((f) => ({ value: f.name, label: f.name }))]} /></Field>
|
||||
<div className="field" />
|
||||
</FieldRow>}
|
||||
</>}
|
||||
</>}
|
||||
<div className="toolbar-left ext-actions">
|
||||
<Button variant="accent" disabled={busy || !canSave || (!!(popup.item || src) && !draft.isDirty)} onClick={() => void save()}>{t('common_save')}</Button>
|
||||
<Button onClick={() => draft.discard()}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</>}
|
||||
</div>}
|
||||
</DetailPopup>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
import { listEmployees, listSources, syncEmployees } from '../../services/integrations.js';
|
||||
import { syncSummary } from '../Configuracion/ExternalConnectionsPanel.jsx';
|
||||
|
||||
// Empleados importados de la nómina (Conexiones externas). Solo lectura aquí; "Actualizar
|
||||
// empleados" los vuelve a traer a pedido (admin y superadmin).
|
||||
export default function EmployeesPanel() {
|
||||
const { t, pushToast } = useUi();
|
||||
const [rows, setRows] = useState(null), [source, setSource] = useState(undefined), [search, setSearch] = useState(''), [error, setError] = useState(''), [busy, setBusy] = useState(false);
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
const [e, s] = await Promise.all([listEmployees(), listSources()]);
|
||||
setRows(e); setSource(s.find((x) => x.purpose === 'employees') || null); setError('');
|
||||
} catch (err) { setError(err.message); }
|
||||
}
|
||||
useEffect(() => { void reload(); }, []);
|
||||
|
||||
async function update() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await syncEmployees();
|
||||
await reload();
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
} catch (err) { setError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
if (!rows) return error ? <InfoBar tone="error">{error}</InfoBar> : <div className="text-muted">{t('common_loading')}</div>;
|
||||
return <div>
|
||||
{error && <InfoBar tone="error">{error}</InfoBar>}
|
||||
{source === null && <InfoBar tone="warning">{t('emp_no_source')}</InfoBar>}
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-left"><SearchInput value={search} onChange={setSearch} /><span className="text-muted" style={{ fontSize: '.78rem' }}>{t('int_last_sync')}: {syncSummary(t, source?.last_result)}</span></div>
|
||||
<Button variant="accent" icon={RefreshCw} disabled={busy || !source} onClick={() => void update()}>{t('emp_update')}</Button>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: '.78rem', margin: '0 0 8px' }}>{t('emp_protected_hint')}</p>
|
||||
<ListView getRowKey={(e) => e.id} rows={rows} searchValue={search} getSearchHaystack={(e) => `${e.last_names} ${e.first_names} ${e.identification} ${e.position || ''} ${e.department_name || ''} ${t(`emp_origin_${e.origin}`)}`}
|
||||
initialSort={{ key: 'last_names', dir: 1 }}
|
||||
columns={[
|
||||
{ key: 'last_names', label: t('int_target_lastNames') },
|
||||
{ key: 'first_names', label: t('int_target_firstNames') },
|
||||
{ key: 'identification', label: t('int_target_identification') },
|
||||
{ key: 'position', label: t('int_target_position'), render: (e) => e.position || '—' },
|
||||
{ key: 'department_name', label: t('int_target_department'), render: (e) => e.department_name || '—' },
|
||||
{ key: 'origin', label: t('emp_origin'), render: (e) => t(`emp_origin_${e.origin}`) },
|
||||
{ key: 'is_active', label: t('common_status'), render: (e) => <Badge color={e.is_active ? 'success' : 'muted'}>{t(e.is_active ? 'common_active' : 'common_inactive')}</Badge> },
|
||||
{ key: 'user_name', label: t('emp_user'), render: (e) => e.user_name || '—' }
|
||||
]} />
|
||||
</div>;
|
||||
}
|
||||
@@ -4,12 +4,17 @@ import { Tabs } from '../../components/Tabs.jsx';
|
||||
import { useUserDrafts } from '../../hooks/useDraftForm.js';
|
||||
import DepartmentsPanel from './DepartmentsPanel.jsx';
|
||||
import UsersPanel from './UsersPanel.jsx';
|
||||
import EmployeesPanel from './EmployeesPanel.jsx';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
|
||||
export default function OrganizacionPage() {
|
||||
const { t } = useUi();
|
||||
// Pestaña en la URL (?tab=): retorno tras el login/F5 y aviso al cambiar con cambios pendientes.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') === 'users' ? 'users' : 'departments';
|
||||
const { user } = useAuth();
|
||||
const canManage = ['admin', 'superadmin'].includes(user?.roleCode);
|
||||
const asked = searchParams.get('tab');
|
||||
const tab = asked === 'users' || (asked === 'employees' && canManage) ? asked : 'departments';
|
||||
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
|
||||
const drafts = useUserDrafts();
|
||||
const dirty = (prefix) => drafts.some((d) => d.formId.startsWith(prefix));
|
||||
@@ -21,11 +26,12 @@ export default function OrganizacionPage() {
|
||||
tabs={[
|
||||
{ key: 'departments', label: t('org_departments'), dirty: dirty('department-') },
|
||||
{ key: 'users', label: t('org_users'), dirty: dirty('user-') },
|
||||
...(canManage ? [{ key: 'employees', label: t('org_employees') }] : []),
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
{tab === 'departments' ? <DepartmentsPanel /> : <UsersPanel />}
|
||||
{tab === 'departments' ? <DepartmentsPanel /> : tab === 'users' ? <UsersPanel /> : <EmployeesPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -27,7 +28,10 @@ function flatten(nodes, acc = []) {
|
||||
}
|
||||
|
||||
const STATUS_COLOR = { pending_verification: 'muted', pending_approval: 'warning', active: 'success', invited: 'muted' };
|
||||
const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '' };
|
||||
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();
|
||||
@@ -47,6 +51,7 @@ export default function UsersPanel() {
|
||||
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);
|
||||
@@ -79,6 +84,14 @@ export default function UsersPanel() {
|
||||
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) {
|
||||
@@ -94,7 +107,7 @@ export default function UsersPanel() {
|
||||
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 });
|
||||
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();
|
||||
@@ -208,6 +221,11 @@ export default function UsersPanel() {
|
||||
|
||||
<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 />
|
||||
|
||||
@@ -20,3 +20,6 @@ export const clinicSchedules=(id)=>api.get(`/clinic/config/schedules/${id}`);
|
||||
export const clinicSaveSchedules=(id,data)=>api.put(`/clinic/config/schedules/${id}`,data);
|
||||
export const clinicTypes=()=>api.get('/clinic/config/types');
|
||||
export const clinicSaveType=(data,id)=>id?api.put(`/clinic/config/types/${id}`,data):api.post('/clinic/config/types',data);
|
||||
export const clinicPatientSource=()=>api.get('/clinic/patients/source');
|
||||
export const clinicExternalPatients=(search)=>api.get(`/clinic/patients/external${query({search})}`);
|
||||
export const clinicImportExternalPatient=(externalId)=>api.post(`/clinic/patients/external/${encodeURIComponent(externalId)}/import`);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { api } from './api.js';
|
||||
|
||||
const qs = (data) => `?${new URLSearchParams(Object.entries(data).filter(([, v]) => v !== undefined && v !== null && v !== ''))}`;
|
||||
export const listConnections = () => api.get('/integrations/connections');
|
||||
export const saveConnection = (data, id) => (id ? api.put(`/integrations/connections/${id}`, data) : api.post('/integrations/connections', data));
|
||||
export const testConnection = (id) => api.post(`/integrations/connections/${id}/test`);
|
||||
export const browseConnection = (id, source, table) => api.get(`/integrations/connections/${id}/browse${qs({ source, table })}`);
|
||||
export const listSources = () => api.get('/integrations/sources');
|
||||
export const saveSource = (purpose, data) => api.put(`/integrations/sources/${purpose}`, data);
|
||||
export const syncPatients = (full) => api.post('/integrations/sources/patients/sync', { full });
|
||||
export const listEmployees = (available) => api.get(`/integrations/employees${available ? '?available=1' : ''}`);
|
||||
export const syncEmployees = () => api.post('/integrations/employees/sync');
|
||||
@@ -27,7 +27,9 @@ const DRAFT_FIELDS = {
|
||||
'activity-info': ['editVersion', 'responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours', 'progressPct'],
|
||||
'task-new': [], 'task-edit': ['editVersion'],
|
||||
'department-new': ['parentId'], 'department-edit': ['editVersion', 'parentId'],
|
||||
'user-new': ['roleId', 'departmentId'],
|
||||
'user-new': ['roleId', 'departmentId', 'employeeId'],
|
||||
'ext-connection': ['editVersion', 'name', 'baseUrl', 'isActive'],
|
||||
'ext-source': ['editVersion', 'connectionId', 'sourceKey', 'tableName', 'mode', 'fieldMap', 'filterField', 'filterValue', 'changedField'],
|
||||
'user-edit': ['editVersion', 'roleId', 'departmentId'],
|
||||
'user-approve': ['editVersion', 'roleId', 'departmentId'],
|
||||
'account-password': [],
|
||||
|
||||
Reference in New Issue
Block a user