fabe5b530d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
14 KiB
React
170 lines
14 KiB
React
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>;
|
||
}
|