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:
Milton Gonzalez
2026-10-29 17:06:10 -05:00
parent fabe5b530d
commit 0ee9ed65bf
43 changed files with 1402 additions and 69 deletions
+2 -2
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "4.11.0",
"version": "4.12.0",
"type": "module",
"scripts": {
"dev": "vite",
+126
View File
@@ -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>
);
}
+20 -2
View File
@@ -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 />
+3
View File
@@ -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`);
+12
View File
@@ -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');
+3 -1
View File
@@ -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': [],