feat: version 4.13.0 -- Agregar medicos desde proveedores de SAGA (solo por API)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-29 17:15:12 -05:00
parent 0ee9ed65bf
commit d85a7196f0
21 changed files with 206 additions and 23 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "project-linkedcorp-frontend",
"version": "4.12.0",
"version": "4.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project-linkedcorp-frontend",
"version": "4.12.0",
"version": "4.13.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.12.0",
"version": "4.13.0",
"type": "module",
"scripts": {
"dev": "vite",
+12
View File
@@ -674,6 +674,12 @@ export const messages = {
int_sources: 'Fuentes de datos',
int_purpose_patients: 'Pacientes (Agenda médica)',
int_purpose_employees: 'Empleados (nómina)',
int_purpose_providers: 'Proveedores (médicos que facturan)',
emp_from_provider: 'Agregar desde proveedores',
emp_provider_search: 'Buscar por nombre, apellido o cédula (mínimo 3 letras)',
emp_provider_pick: 'Revisa apellidos y nombres: en el origen el orden no siempre es el mismo.',
emp_already: 'Ya es empleado',
emp_no_provider_source: 'No hay fuente de proveedores configurada (Configuración → Conexiones externas).',
int_not_configured: 'Sin configurar',
int_configure: 'Configurar',
int_connection: 'Conexión',
@@ -1431,6 +1437,12 @@ export const messages = {
int_sources: 'Data sources',
int_purpose_patients: 'Patients (Medical schedule)',
int_purpose_employees: 'Employees (payroll)',
int_purpose_providers: 'Suppliers (billing doctors)',
emp_from_provider: 'Add from suppliers',
emp_provider_search: 'Search by name, last name or ID (at least 3 letters)',
emp_provider_pick: 'Check last and first names: the source does not always use the same order.',
emp_already: 'Already an employee',
emp_no_provider_source: 'No supplier source configured (Settings → External connections).',
int_not_configured: 'Not configured',
int_configure: 'Configure',
int_connection: 'Connection',
@@ -20,9 +20,10 @@ import './ExternalConnectionsPanel.css';
const TARGETS = {
patients: ['externalId', 'identification', 'fullName', 'phone', 'email'],
employees: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active']
employees: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active'],
providers: ['externalId', 'identification', 'fullName', 'phone', 'email']
};
const REQUIRED = { patients: ['externalId', 'fullName'], employees: ['identification'] };
const REQUIRED = { patients: ['externalId', 'fullName'], employees: ['identification'], providers: ['externalId', 'fullName'] };
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] || [];
@@ -139,7 +140,7 @@ export default function ExternalConnectionsPanel() {
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 })}
<ListView getRowKey={(p) => p} rows={['patients', 'employees', 'providers']} 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>; } },
@@ -1,24 +1,42 @@
import { useEffect, useState } from 'react';
import { RefreshCw } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { RefreshCw, UserPlus } from 'lucide-react';
import { useDraftForm } from '../../hooks/useDraftForm.js';
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
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 { listEmployees, listSources, syncEmployees, searchProviders, importProvider } 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 guardedClose = useGuardedClose();
// "Agregar desde proveedores": buscar en el origen, elegir y confirmar apellidos/nombres/cargo.
const [picking, setPicking] = useState(false), [provSearch, setProvSearch] = useState(''), [found, setFound] = useState([]), [picked, setPicked] = useState(null), [pickError, setPickError] = useState('');
const [providerSource, setProviderSource] = useState(undefined);
const searchSeq = useRef(0);
const words = picked ? picked.fullName.split(/\s+/) : [];
const draft = useDraftForm({
formId: 'employee-from-provider', entityId: picked?.code ?? null,
initial: picked ? { lastNames: words.slice(0, 2).join(' '), firstNames: words.slice(2).join(' '), position: 'MEDICO' } : {},
label: t('emp_from_provider'), enabled: !!picked,
onDiscard: () => setPicked(null)
});
const pv = draft.values;
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('');
setRows(e); setSource(s.find((x) => x.purpose === 'employees') || null); setProviderSource(s.find((x) => x.purpose === 'providers') || null); setError('');
} catch (err) { setError(err.message); }
}
useEffect(() => { void reload(); }, []);
@@ -32,13 +50,40 @@ export default function EmployeesPanel() {
} catch (err) { setError(err.message); } finally { setBusy(false); }
}
function searchProv(text) {
setProvSearch(text);
const seq = ++searchSeq.current;
if (text.trim().length < 3) { setFound([]); return; }
setTimeout(() => {
if (seq !== searchSeq.current) return;
searchProviders(text).then((r) => seq === searchSeq.current && setFound(r)).catch((e) => setPickError(e.message));
}, 400);
}
function openPicker() {
setPicking(true); setPicked(null); setProvSearch(''); setFound([]); setPickError('');
}
async function saveProvider() {
try {
await importProvider(picked.code, pv);
draft.clear();
setPicked(null);
setPicking(false);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
} catch (e) { setPickError(e.message); }
}
const closePicker = () => void guardedClose(picked ? draft : null, () => { setPicked(null); setPicking(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 className="toolbar-left">
<Button icon={UserPlus} disabled={!providerSource} title={providerSource ? undefined : t('emp_no_provider_source')} onClick={openPicker}>{t('emp_from_provider')}</Button>
<Button variant="accent" icon={RefreshCw} disabled={busy || !source} onClick={() => void update()}>{t('emp_update')}</Button>
</div>
</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}`)}`}
@@ -53,5 +98,37 @@ export default function EmployeesPanel() {
{ 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 || '—' }
]} />
<DetailPopup open={picking} title={t('emp_from_provider')} onClose={closePicker} width={900} height={620}>
<div className="emp-pick">
{pickError && <InfoBar tone="error">{pickError}</InfoBar>}
{!picked ? <>
<Field label={t('emp_provider_search')}><input className="input" autoFocus value={provSearch} onChange={(e) => searchProv(e.target.value)} /></Field>
{found.length > 0 && <ListView getRowKey={(p) => p.code} rows={found} onRowClick={(p) => { if (!p.employee_id) { setPicked(p); setPickError(''); } }}
columns={[
{ key: 'fullName', label: t('common_name') },
{ key: 'identification', label: t('int_target_identification'), render: (p) => p.identification || '—' },
{ key: 'phone', label: t('int_target_phone'), render: (p) => p.phone || '—' },
{ key: 'email', label: t('int_target_email'), render: (p) => p.email || '—' },
{ key: 'employee_id', label: t('common_status'), sortable: false, render: (p) => (p.employee_id ? <Badge color="muted">{t('emp_already')}</Badge> : '') }
]} />}
</> : <>
<DraftNotice draft={draft} />
<InfoBar tone="info">{picked.fullName} · {picked.identification} — {t('emp_provider_pick')}</InfoBar>
<FieldRow>
<Field label={t('int_target_lastNames')} dirty={draft.isFieldDirty('lastNames')}><input className="input" maxLength={120} value={pv.lastNames || ''} onChange={(e) => draft.setField('lastNames', e.target.value)} /></Field>
<Field label={t('int_target_firstNames')} dirty={draft.isFieldDirty('firstNames')}><input className="input" maxLength={120} value={pv.firstNames || ''} onChange={(e) => draft.setField('firstNames', e.target.value)} /></Field>
</FieldRow>
<FieldRow>
<Field label={t('int_target_position')} dirty={draft.isFieldDirty('position')}><input className="input" maxLength={120} value={pv.position || ''} onChange={(e) => draft.setField('position', e.target.value)} /></Field>
<Field label={t('int_target_identification')}><input className="input" readOnly value={picked.identification || ''} /></Field>
</FieldRow>
<div className="toolbar-left" style={{ marginTop: 18 }}>
<Button variant="accent" disabled={!pv.lastNames?.trim() || !pv.firstNames?.trim()} onClick={() => void saveProvider()}>{t('common_save')}</Button>
<Button onClick={() => draft.discard()}>{t('common_cancel')}</Button>
</div>
</>}
</div>
</DetailPopup>
</div>;
}
+2
View File
@@ -10,3 +10,5 @@ export const saveSource = (purpose, data) => api.put(`/integrations/sources/${pu
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');
export const searchProviders = (search) => api.get(`/integrations/providers${qs({ search })}`);
export const importProvider = (code, data) => api.post(`/integrations/providers/${encodeURIComponent(code)}/import`, data);
+3
View File
@@ -592,3 +592,6 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
.busy-spinner { width: 28px; height: 28px; flex: none; border-radius: 50%; border: 3px solid rgb(var(--accent) / .25); border-top-color: rgb(var(--accent)); animation: update-spin .9s linear infinite; }
.busy-message { font-weight: 600; }
.busy-hint { font-size: .78rem; margin-top: 2px; }
/* Organización → Empleados → Agregar desde proveedores */
.emp-pick { display: grid; gap: 12px; align-content: start; }
+1
View File
@@ -28,6 +28,7 @@ const DRAFT_FIELDS = {
'task-new': [], 'task-edit': ['editVersion'],
'department-new': ['parentId'], 'department-edit': ['editVersion', 'parentId'],
'user-new': ['roleId', 'departmentId', 'employeeId'],
'employee-from-provider': ['lastNames', 'firstNames', 'position'],
'ext-connection': ['editVersion', 'name', 'baseUrl', 'isActive'],
'ext-source': ['editVersion', 'connectionId', 'sourceKey', 'tableName', 'mode', 'fieldMap', 'filterField', 'filterValue', 'changedField'],
'user-edit': ['editVersion', 'roleId', 'departmentId'],