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:
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user