Files
project.linkedcorp.net/frontend/src/pages/AgendaMedica/ClinicConfigTab.jsx
T
2026-10-29 15:30:21 -05:00

170 lines
14 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2 } 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 * as api from '../../services/clinic.js';
import { listUsers } from '../../services/users.js';
import { WEEKDAYS } from './clinicUtils.js';
// Configuración de la agenda (admin/superadmin): Personal, Tipos de cita y Horario de atención.
// Cada sección es un listado; "Agregar"/clic en fila abre un popup en lectura y "Editar" habilita.
const staffForm = (s) => s ? { userId: s.user_id, scope: s.scope, isDoctor: !!s.is_doctor, specialty: s.specialty || '', color: s.color || '#0078d4', isActive: !!s.is_active, editVersion: s.edit_version } : { userId: '', scope: 'own', isDoctor: true, specialty: '', color: '#0078d4', isActive: true };
const typeForm = (x) => x ? { name: x.name, durationMinutes: x.duration_minutes, color: x.color, isActive: !!x.is_active, editVersion: x.edit_version } : { name: '', durationMinutes: 30, color: '#0078d4', isActive: true };
const scheduleForm = (rows) => ({ rows: rows.map((s) => ({ weekday: s.weekday, startsTime: String(s.starts_time).slice(0, 5), endsTime: String(s.ends_time).slice(0, 5) })) });
const swatch = (color) => <span className="calendar-swatch" style={{ background: color || 'rgb(var(--accent))' }} />;
export function ClinicConfigTab({ onChanged }) {
const { t, pushToast } = useUi();
const guardedClose = useGuardedClose();
const [staff, setStaff] = useState([]), [scheduleDoctors, setScheduleDoctors] = useState([]), [users, setUsers] = useState([]), [types, setTypes] = useState([]);
const [popup, setPopup] = useState(null), [editing, setEditing] = useState(false), [error, setError] = useState(''), [loadError, setLoadError] = useState('');
async function reload() {
try {
const [s, u, ty] = await Promise.all([api.clinicStaff(), listUsers(), api.clinicTypes()]);
// Horario por médico (todos los médicos del personal, aunque quien configura no sea personal).
const docs = s.filter((x) => x.is_doctor);
const segs = await Promise.all(docs.map((d) => api.clinicSchedules(d.user_id)));
setStaff(s); setUsers(u); setTypes(ty);
setScheduleDoctors(docs.map((d, i) => ({ id: d.user_id, name: d.name, color: d.color, schedules: segs[i] })));
setLoadError('');
} catch (e) { setLoadError(e.message); }
}
useEffect(() => { void reload(); }, []);
const initial = !popup ? {} : popup.kind === 'staff' ? staffForm(popup.item) : popup.kind === 'type' ? typeForm(popup.item) : scheduleForm(popup.rows || []);
const draft = useDraftForm({
formId: popup?.kind === 'staff' ? 'clinic-staff' : popup?.kind === 'type' ? 'clinic-type' : 'clinic-schedule',
entityId: popup?.kind === 'staff' ? popup.item?.user_id : popup?.kind === 'type' ? popup.item?.id : popup?.doctor?.id,
initial,
label: t(popup?.kind === 'staff' ? 'clinic_staff' : popup?.kind === 'type' ? 'clinic_types' : 'clinic_schedules'),
enabled: !!popup && editing,
onDiscard: () => { if (popup?.item || popup?.kind === 'schedule') setEditing(false); else setPopup(null); }
});
const v = draft.values;
async function open(kind, item = null, extra = {}) {
setError('');
if (kind === 'schedule') extra.rows = extra.doctor.schedules;
setPopup({ kind, item, ...extra });
setEditing(!item && kind !== 'schedule');
}
function close() { void guardedClose(editing ? draft : null, () => { setPopup(null); setEditing(false); }); }
const cancelEdit = () => draft.discard();
async function save() {
try {
if (popup.kind === 'staff') await api.clinicSaveStaff(v.userId, v);
if (popup.kind === 'type') await api.clinicSaveType(v, popup.item?.id);
if (popup.kind === 'schedule') await api.clinicSaveSchedules(popup.doctor.id, { segments: rows });
} catch (e) { setError(e.message); return; }
draft.clear();
setPopup(null);
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
// Guardado ya hecho: si falla el refresco, se avisa fuera del popup (que ya está cerrado).
try { await reload(); await onChanged(); } catch (e) { setLoadError(e.message); }
}
const staffIds = new Set(staff.map((s) => s.user_id));
const scheduleText = (d) => (d.schedules || []).map((s) => `${t(`calendar_${WEEKDAYS[s.weekday]}`)} ${String(s.starts_time).slice(0, 5)}–${String(s.ends_time).slice(0, 5)}`).join(' · ') || '—';
const rows = (v?.rows || []).map((r) => ({ weekday: Number(r.weekday), startsTime: r.startsTime ?? String(r.starts_time || '').slice(0, 5), endsTime: r.endsTime ?? String(r.ends_time || '').slice(0, 5) }));
const setRows = (next) => draft.setField('rows', next);
const canSave = popup?.kind === 'staff' ? !!v.userId : popup?.kind === 'type' ? !!v.name?.trim() && Number(v.durationMinutes) > 0 : true;
const detail = !popup?.item ? [] : popup.kind === 'staff' ? [
[t('clinic_user'), popup.item.name], [t('clinic_scope'), t(`clinic_scope_${popup.item.scope}`)], [t('clinic_is_doctor'), t(popup.item.is_doctor ? 'common_yes' : 'common_no')],
[t('clinic_specialty'), popup.item.specialty || '—'], [t('clinic_color'), popup.item.color ? <>{swatch(popup.item.color)} {popup.item.color}</> : '—'], [t('common_status'), t(popup.item.is_active ? 'common_active' : 'common_inactive')]
] : popup.kind === 'type' ? [
[t('clinic_name'), popup.item.name], [t('clinic_duration'), popup.item.duration_minutes], [t('clinic_color'), <>{swatch(popup.item.color)} {popup.item.color}</>], [t('common_status'), t(popup.item.is_active ? 'common_active' : 'common_inactive')]
] : [];
return <div className="clinic-config">
{loadError && <InfoBar tone="error">{loadError}</InfoBar>}
<div className="toolbar"><h3 className="mt-0">{t('clinic_staff')}</h3><Button variant="accent" icon={Plus} onClick={() => void open('staff')}>{t('clinic_add_staff')}</Button></div>
<ListView getRowKey={(s) => s.user_id} rows={staff} onRowClick={(s) => void open('staff', s)}
columns={[
{ key: 'name', label: t('clinic_user'), render: (s) => <span className="clinic-name">{s.is_doctor ? swatch(s.color) : null}{s.name}</span> },
{ key: 'scope', label: t('clinic_scope'), render: (s) => t(`clinic_scope_${s.scope}`) },
{ key: 'specialty', label: t('clinic_specialty'), render: (s) => s.specialty || '—' },
{ key: 'is_active', label: t('common_status'), render: (s) => <Badge color={s.is_active ? 'success' : 'muted'}>{t(s.is_active ? 'common_active' : 'common_inactive')}</Badge> }
]}
rowActions={(s) => [{ label: t('common_edit'), icon: Pencil, onClick: () => { void open('staff', s).then(() => setEditing(true)); } }]} />
<div className="toolbar"><h3>{t('clinic_types')}</h3><Button variant="accent" icon={Plus} onClick={() => void open('type')}>{t('clinic_new_type')}</Button></div>
<ListView getRowKey={(x) => x.id} rows={types} onRowClick={(x) => void open('type', x)}
columns={[
{ key: 'name', label: t('clinic_name'), render: (x) => <span className="clinic-name">{swatch(x.color)}{x.name}</span> },
{ key: 'duration_minutes', label: t('clinic_duration') },
{ key: 'is_active', label: t('common_status'), render: (x) => <Badge color={x.is_active ? 'success' : 'muted'}>{t(x.is_active ? 'common_active' : 'common_inactive')}</Badge> }
]}
rowActions={(x) => [{ label: t('common_edit'), icon: Pencil, onClick: () => { void open('type', x).then(() => setEditing(true)); } }]} />
<h3>{t('clinic_schedules')}</h3>
<ListView getRowKey={(d) => d.id} rows={scheduleDoctors} onRowClick={(d) => void open('schedule', null, { doctor: d })}
columns={[
{ key: 'name', label: t('clinic_doctor'), render: (d) => <span className="clinic-name">{swatch(d.color)}{d.name}</span> },
{ key: 'schedules', label: t('clinic_schedules'), sortable: false, render: scheduleText }
]} />
<DetailPopup open={!!popup} title={popup?.kind === 'schedule' ? `${t('clinic_schedules')}: ${popup.doctor.name}` : popup?.kind === 'staff' ? (popup.item ? popup.item.name : t('clinic_add_staff')) : (popup?.item ? popup.item.name : t('clinic_new_type'))} onClose={close} width={760} height={popup?.kind === 'schedule' ? 680 : 480}>
{popup && <div className="clinic-form">
{error && <InfoBar tone="error">{error}</InfoBar>}
{editing && <DraftNotice draft={draft} />}
{!editing ? <>
{popup.kind === 'schedule'
? <div className="clinic-detail">{(popup.rows || []).length ? popup.rows.map((s) => <p key={s.id}><strong>{t(`calendar_${WEEKDAYS[s.weekday]}`)}:</strong> {String(s.starts_time).slice(0, 5)}–{String(s.ends_time).slice(0, 5)}</p>) : <p>—</p>}</div>
: <div className="clinic-detail">{detail.map(([label, val]) => <p key={label}><strong>{label}:</strong> {val}</p>)}</div>}
<div className="toolbar-left clinic-actions"><Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button></div>
</> : <>
{popup.kind === 'staff' && <>
<FieldRow>
<Field label={t('clinic_user')} dirty={draft.isFieldDirty('userId')}>{popup.item ? <input className="input" readOnly value={popup.item.name} /> : <ComboBox value={v.userId} onChange={(x) => draft.setField('userId', x)} options={[{ value: '', label: t('common_select') }, ...users.filter((u) => u.is_active && !staffIds.has(u.id)).map((u) => ({ value: u.id, label: u.name }))]} />}</Field>
<Field label={t('clinic_scope')} dirty={draft.isFieldDirty('scope')}><ComboBox value={v.scope} onChange={(x) => draft.setValues((old) => ({ ...old, scope: x, isDoctor: x === 'own' ? true : old.isDoctor }))} options={['all', 'own'].map((x) => ({ value: x, label: t(`clinic_scope_${x}`) }))} /></Field>
</FieldRow>
<FieldRow>
<Field label={t('clinic_specialty')} dirty={draft.isFieldDirty('specialty')}><input className="input" maxLength={120} value={v.specialty} disabled={!v.isDoctor} onChange={(e) => draft.setField('specialty', e.target.value)} /></Field>
<Field label={t('clinic_color')} dirty={draft.isFieldDirty('color')}><input className="input clinic-color" type="color" value={v.color} disabled={!v.isDoctor} onChange={(e) => draft.setField('color', e.target.value)} /></Field>
</FieldRow>
<div className="clinic-checks">
<label className="calendar-filter"><input type="checkbox" checked={v.isDoctor} disabled={v.scope === 'own'} onChange={(e) => draft.setField('isDoctor', e.target.checked)} /> {t('clinic_is_doctor')}</label>
<label className="calendar-filter"><input type="checkbox" checked={v.isActive} onChange={(e) => draft.setField('isActive', e.target.checked)} /> {t('clinic_active')}</label>
</div>
</>}
{popup.kind === 'type' && <>
<FieldRow>
<Field label={t('clinic_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('clinic_duration')} dirty={draft.isFieldDirty('durationMinutes')}><input className="input" type="number" min={5} max={480} step={5} value={v.durationMinutes} onChange={(e) => draft.setField('durationMinutes', Number(e.target.value))} /></Field>
</FieldRow>
<FieldRow>
<Field label={t('clinic_color')} dirty={draft.isFieldDirty('color')}><input className="input clinic-color" type="color" value={v.color} onChange={(e) => draft.setField('color', 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('clinic_active')}</label></Field>
</FieldRow>
</>}
{popup.kind === 'schedule' && <>
{rows.map((s, i) => <div className="clinic-schedule-row" key={i}>
<Field label={t('calendar_day')}><ComboBox value={s.weekday} onChange={(x) => setRows(rows.map((r, j) => j === i ? { ...r, weekday: Number(x) } : r))} options={WEEKDAYS.map((x, n) => ({ value: n, label: t(`calendar_${x}`) }))} /></Field>
<Field label={t('calendar_start')}><input className="input" type="time" value={s.startsTime} onChange={(e) => setRows(rows.map((r, j) => j === i ? { ...r, startsTime: e.target.value } : r))} /></Field>
<Field label={t('calendar_end')}><input className="input" type="time" value={s.endsTime} onChange={(e) => setRows(rows.map((r, j) => j === i ? { ...r, endsTime: e.target.value } : r))} /></Field>
<Button icon={Trash2} variant="danger" aria-label={t('common_delete')} title={t('common_delete')} onClick={() => setRows(rows.filter((_, j) => j !== i))} />
</div>)}
<div><Button icon={Plus} onClick={() => setRows([...rows, { weekday: 0, startsTime: '09:00', endsTime: '17:00' }])}>{t('clinic_add_segment')}</Button></div>
</>}
<div className="toolbar-left clinic-actions"><Button variant="accent" disabled={!canSave || (!!popup.item || popup.kind === 'schedule') && !draft.isDirty} onClick={() => void save()}>{t('common_save')}</Button><Button onClick={cancelEdit}>{t('common_cancel')}</Button></div>
</>}
</div>}
</DetailPopup>
</div>;
}