00aaf1ebef
Barra igual a la del Calendario (Hoy, anterior/siguiente, mes y ano; vista a la derecha), vista Mes por defecto y aviso cuando no hay medicos configurados (antes la vista Dia quedaba vacia). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
779 lines
41 KiB
React
779 lines
41 KiB
React
import { useEffect, useMemo, useState } from 'react';
|
||
import { Stethoscope, Plus, ChevronLeft, ChevronRight } from 'lucide-react';
|
||
import { useUi } from '../../context/ui-context.jsx';
|
||
import { useAuth } from '../../context/auth-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 } from '../../components/Field.jsx';
|
||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||
import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
|
||
import { DateTimeEditor } from '../../components/DateTimeEditor.jsx';
|
||
import { RecurrenceEditor } from '../../components/RecurrenceEditor.jsx';
|
||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||
import { Tabs } from '../../components/Tabs.jsx';
|
||
import { InfoBar } from '../../components/InfoBar.jsx';
|
||
import { DatePicker } from '../../components/DatePicker.jsx';
|
||
import { MonthGrid, monthGridDays } from '../../components/MonthGrid.jsx';
|
||
import * as api from '../../services/clinic.js';
|
||
import { listUsers } from '../../services/users.js';
|
||
import './clinic.css';
|
||
const dayKey = d => `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
|
||
const today = () => {
|
||
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {
|
||
timeZone: 'America/Guayaquil',
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit'
|
||
}).formatToParts(new Date()).map(p => [p.type, p.value]));
|
||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||
};
|
||
const move = (day, n) => dayKey(new Date(new Date(`${day}T12:00:00Z`).getTime() + n * 86400000));
|
||
const at = (day, hour = '09:00') => `${day} ${hour}:00`;
|
||
const plus = (s, n) => {
|
||
const d = new Date(`${s.replace(' ', 'T')}Z`);
|
||
return new Date(d.getTime() + n * 60000).toISOString().slice(0, 19).replace('T', ' ');
|
||
};
|
||
const initialAppointment = (day, doctorId, slot) => ({
|
||
doctorId: doctorId || '',
|
||
patientId: '',
|
||
typeId: '',
|
||
startsAt: slot || at(day),
|
||
endsAt: plus(slot || at(day), 30),
|
||
channel: 'phone',
|
||
notes: ''
|
||
});
|
||
const initialPatient = () => ({
|
||
firstNames: '',
|
||
lastNames: '',
|
||
identification: '',
|
||
phone: '',
|
||
email: '',
|
||
birthDate: '',
|
||
notes: ''
|
||
});
|
||
const initialBlock = (day, doctorId) => ({
|
||
doctorId: doctorId || '',
|
||
kind: 'surgery',
|
||
title: '',
|
||
startsAt: at(day),
|
||
endsAt: plus(at(day), 60),
|
||
recurrence: null
|
||
});
|
||
const formAppointment = a => ({
|
||
doctorId: a.doctor_user_id,
|
||
patientId: a.patient_id,
|
||
typeId: a.type_id,
|
||
startsAt: a.starts_at,
|
||
endsAt: a.ends_at,
|
||
channel: a.channel,
|
||
notes: a.notes || '',
|
||
editVersion: a.edit_version
|
||
});
|
||
const formPatient = p => ({
|
||
firstNames: p.first_names,
|
||
lastNames: p.last_names,
|
||
identification: p.identification || '',
|
||
phone: p.phone,
|
||
email: p.email || '',
|
||
birthDate: p.birth_date || '',
|
||
notes: p.notes || '',
|
||
editVersion: p.edit_version
|
||
});
|
||
const formBlock = b => ({
|
||
doctorId: b.doctor_user_id,
|
||
kind: b.kind,
|
||
title: b.title,
|
||
startsAt: b.starts_at,
|
||
endsAt: b.ends_at,
|
||
recurrence: b.recurrence_json ? JSON.parse(b.recurrence_json) : null,
|
||
editVersion: b.edit_version
|
||
});
|
||
const readStored = (key, fallback) => {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(key)) ?? fallback;
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
};
|
||
export default function AgendaMedicaPage() {
|
||
const {
|
||
t,
|
||
lang,
|
||
openDialog,
|
||
pushToast
|
||
} = useUi();
|
||
const {
|
||
user
|
||
} = useAuth();
|
||
const guardedClose = useGuardedClose();
|
||
const [me, setMe] = useState(null),
|
||
[error, setError] = useState(''),
|
||
[doctors, setDoctors] = useState([]),
|
||
[types, setTypes] = useState([]),
|
||
[allTypes, setAllTypes] = useState([]);
|
||
const [tab, setTab] = useState('agenda'),
|
||
[view, setView] = useState(() => { const v = readStored('plc_clinic_view_v2', 'month'); return ['day', 'week', 'month', 'list'].includes(v) ? v : 'month'; }),
|
||
[day, setDay] = useState(today);
|
||
const [doctorFilter, setDoctorFilter] = useState(() => {
|
||
const value = readStored('plc_clinic_doctors', []);
|
||
return Array.isArray(value) ? value : [];
|
||
});
|
||
const [statusFilter, setStatusFilter] = useState(() => {
|
||
const value = readStored('plc_clinic_status', ['scheduled', 'confirmed', 'attended', 'no_show']);
|
||
return Array.isArray(value) ? value : ['scheduled', 'confirmed', 'attended', 'no_show'];
|
||
});
|
||
const [appointments, setAppointments] = useState([]),
|
||
[blocks, setBlocks] = useState([]),
|
||
[patients, setPatients] = useState([]),
|
||
[search, setSearch] = useState(''),
|
||
[page, setPage] = useState(1),
|
||
[patientTotal, setPatientTotal] = useState(0);
|
||
const [popup, setPopup] = useState(null),
|
||
[editing, setEditing] = useState(false),
|
||
[conflict, setConflict] = useState(''),
|
||
[affected, setAffected] = useState([]),
|
||
[slots, setSlots] = useState([]),
|
||
[cancelReason, setCancelReason] = useState(''),
|
||
[cancelMode, setCancelMode] = useState(false),
|
||
[patientHistory, setPatientHistory] = useState([]),
|
||
[quickPatientId, setQuickPatientId] = useState(null),
|
||
[reprogram, setReprogram] = useState([]);
|
||
const [allUsers, setAllUsers] = useState([]),
|
||
[staffRows, setStaffRows] = useState([]),
|
||
[staffUser, setStaffUser] = useState(''),
|
||
[staffInitial, setStaffInitial] = useState({
|
||
scope: 'own',
|
||
isDoctor: true,
|
||
specialty: '',
|
||
color: '#0078d4',
|
||
isActive: true
|
||
});
|
||
const [typeInitial, setTypeInitial] = useState({
|
||
name: '',
|
||
durationMinutes: 30,
|
||
color: '#0078d4',
|
||
isActive: true
|
||
});
|
||
const [scheduleDoctor, setScheduleDoctor] = useState(''),
|
||
[scheduleInitial, setScheduleInitial] = useState({
|
||
rows: []
|
||
});
|
||
const staffDraft = useDraftForm({
|
||
formId: 'clinic-staff',
|
||
entityId: staffUser || null,
|
||
initial: staffInitial,
|
||
label: t('clinic_staff'),
|
||
enabled: tab === 'config'
|
||
});
|
||
const typeDraft = useDraftForm({
|
||
formId: 'clinic-type',
|
||
entityId: typeInitial.id || null,
|
||
initial: typeInitial,
|
||
label: t('clinic_types'),
|
||
enabled: tab === 'config'
|
||
});
|
||
const scheduleDraft = useDraftForm({
|
||
formId: 'clinic-schedule',
|
||
entityId: scheduleDoctor || null,
|
||
initial: scheduleInitial,
|
||
label: t('clinic_schedules'),
|
||
enabled: tab === 'config'
|
||
});
|
||
const staffForm = staffDraft.values,
|
||
typeForm = typeDraft.values,
|
||
scheduleRows = scheduleDraft.values.rows || [];
|
||
const setStaffForm = staffDraft.setValues,
|
||
setTypeForm = typeDraft.setValues;
|
||
const setScheduleRows = rows => scheduleDraft.setField('rows', rows);
|
||
const isReception = me?.scope === 'all',
|
||
isAdmin = ['admin', 'superadmin'].includes(user?.roleCode);
|
||
const singleDoctor = doctorFilter.length ? doctorFilter[0] : doctors[0]?.id;
|
||
const selectedDoctors = doctors.filter(d => !doctorFilter.length || doctorFilter.includes(d.id));
|
||
const period = useMemo(() => {
|
||
let from = day,
|
||
to = move(day, 1);
|
||
if (view === 'week') {
|
||
const d = new Date(`${day}T12:00:00Z`),
|
||
offset = (d.getUTCDay() + 6) % 7;
|
||
from = move(day, -offset);
|
||
to = move(from, 7);
|
||
}
|
||
if (view === 'month') {
|
||
const cells = monthGridDays(day);
|
||
from = cells[0];
|
||
to = move(cells.at(-1), 1);
|
||
}
|
||
if (view === 'list') to = move(day, 30);
|
||
return {
|
||
from: at(from, '00:00'),
|
||
to: at(to, '00:00')
|
||
};
|
||
}, [day, view]);
|
||
async function load() {
|
||
if (!me) return;
|
||
try {
|
||
const [a, b] = await Promise.all([api.clinicAppointments(period.from, period.to), api.clinicBlocks(period.from, period.to)]);
|
||
setAppointments(a);
|
||
setBlocks(b);
|
||
setError('');
|
||
} catch (e) {
|
||
setError(e.message);
|
||
}
|
||
}
|
||
useEffect(() => {
|
||
if (!user?.modules?.includes('clinic')) return;
|
||
// Admin/superadmin sin fila de personal: solo puede configurar la agenda (pestana Configuracion).
|
||
api.clinicMe().then(async staff => {
|
||
setMe(staff);
|
||
const data = await api.clinicDoctors();
|
||
setDoctors(data.doctors);
|
||
setTypes(data.types);
|
||
}).catch(e => {
|
||
if (isAdmin) setTab('config');
|
||
else setError(e.message);
|
||
});
|
||
}, [user?.id]);
|
||
useEffect(() => {
|
||
void load();
|
||
}, [me?.user_id, period.from, period.to]);
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem('plc_clinic_view_v2', JSON.stringify(view));
|
||
} catch {}
|
||
}, [view]);
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter));
|
||
} catch {}
|
||
}, [doctorFilter]);
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem('plc_clinic_status', JSON.stringify(statusFilter));
|
||
} catch {}
|
||
}, [statusFilter]);
|
||
useEffect(() => {
|
||
if (tab === 'patients' && isReception) api.clinicPatients(search, page).then(x => {
|
||
setPatients(x.items);
|
||
setPatientTotal(x.total);
|
||
}).catch(e => setError(e.message));
|
||
}, [tab, search, page, isReception]);
|
||
useEffect(() => {
|
||
if (tab === 'config' && isAdmin) Promise.all([api.clinicStaff(), listUsers(), api.clinicTypes()]).then(([rows, users, kindRows]) => {
|
||
setStaffRows(rows);
|
||
setAllUsers(users);
|
||
setAllTypes(kindRows);
|
||
}).catch(e => setError(e.message));
|
||
}, [tab, isAdmin]);
|
||
const formId = popup?.kind === 'appointment' ? 'clinic-appointment' : popup?.kind === 'patient' ? 'clinic-patient' : 'clinic-block';
|
||
const initial = popup?.kind === 'appointment' ? popup.item ? formAppointment(popup.item) : initialAppointment(day, popup.doctorId, popup.slot) : popup?.kind === 'patient' ? popup.item ? formPatient(popup.item) : initialPatient() : popup?.kind === 'block' ? popup.item ? formBlock(popup.item) : initialBlock(day, popup.doctorId) : {};
|
||
const draft = useDraftForm({
|
||
formId,
|
||
entityId: popup?.item?.id,
|
||
initial,
|
||
label: t(`clinic_${popup?.kind || 'appointment'}`),
|
||
enabled: !!popup && editing,
|
||
onDiscard: () => setEditing(false)
|
||
});
|
||
const v = draft.values;
|
||
useEffect(() => {
|
||
if (quickPatientId && popup?.kind === 'appointment' && editing) {
|
||
draft.setField('patientId', quickPatientId);
|
||
setQuickPatientId(null);
|
||
}
|
||
}, [quickPatientId, popup?.kind, editing]);
|
||
useEffect(() => {
|
||
if (popup?.kind !== 'appointment' || !editing || !v.doctorId || !v.typeId || !v.startsAt) return;
|
||
api.clinicAvailability(v.doctorId, v.startsAt.slice(0, 10), v.typeId).then(setSlots).catch(() => setSlots([]));
|
||
}, [popup?.kind, editing, v.doctorId, v.typeId, v.startsAt?.slice(0, 10)]);
|
||
function open(kind, item = null, extra = {}) {
|
||
setPopup({
|
||
kind,
|
||
item,
|
||
...extra
|
||
});
|
||
setEditing(!item);
|
||
setConflict('');
|
||
setAffected([]);
|
||
setCancelMode(false);
|
||
if (kind === 'patient' && item) api.clinicPatientHistory(item.id).then(setPatientHistory).catch(e => setConflict(e.message));
|
||
if (kind === 'appointment' && isReception) api.clinicPatients('', 1).then(x => setPatients(x.items)).catch(() => {});
|
||
}
|
||
function close() {
|
||
void guardedClose(editing ? draft : null, () => {
|
||
setPopup(null);
|
||
setEditing(false);
|
||
setConflict('');
|
||
});
|
||
}
|
||
async function save(confirmAffected = false) {
|
||
if (popup.item && !draft.isDirty) return;
|
||
try {
|
||
let result;
|
||
if (popup.kind === 'appointment') result = await api.clinicSaveAppointment(v, popup.item?.id);
|
||
if (popup.kind === 'patient') result = await api.clinicSavePatient(v, popup.item?.id);
|
||
if (popup.kind === 'block') result = await api.clinicSaveBlock({
|
||
...v,
|
||
confirmAffected
|
||
}, popup.item?.id);
|
||
if (result?.requiresConfirmation) {
|
||
setAffected(result.affected);
|
||
return;
|
||
}
|
||
draft.clear();
|
||
if (popup.returnTo) {
|
||
setQuickPatientId(result.id);
|
||
setPopup(popup.returnTo);
|
||
setEditing(true);
|
||
} else {
|
||
setPopup(null);
|
||
setEditing(false);
|
||
}
|
||
setConflict('');
|
||
setAffected([]);
|
||
if (result?.affected?.length) setReprogram(result.affected);
|
||
await load();
|
||
if (tab === 'patients') {
|
||
const list = await api.clinicPatients(search, page);
|
||
setPatients(list.items);
|
||
}
|
||
pushToast({
|
||
tone: 'success',
|
||
message: t('common_saved')
|
||
});
|
||
} catch (e) {
|
||
setConflict(e.message);
|
||
}
|
||
}
|
||
async function actionStatus(status) {
|
||
try {
|
||
await api.clinicAppointmentStatus(popup.item.id, {
|
||
status,
|
||
editVersion: popup.item.edit_version
|
||
});
|
||
setPopup(null);
|
||
await load();
|
||
pushToast({
|
||
tone: 'success',
|
||
message: t('common_saved')
|
||
});
|
||
} catch (e) {
|
||
setConflict(e.message);
|
||
}
|
||
}
|
||
function cancelAppointment() {
|
||
if (!cancelReason.trim()) return;
|
||
openDialog({
|
||
tone: 'warning',
|
||
title: t('clinic_cancel'),
|
||
message: t('clinic_cancel_confirm'),
|
||
actions: [{
|
||
label: t('common_cancel')
|
||
}, {
|
||
label: t('clinic_cancel'),
|
||
variant: 'danger',
|
||
onClick: async () => {
|
||
try {
|
||
await api.clinicCancelAppointment(popup.item.id, {
|
||
reason: cancelReason,
|
||
editVersion: popup.item.edit_version
|
||
});
|
||
setPopup(null);
|
||
setCancelMode(false);
|
||
await load();
|
||
} catch (e) {
|
||
setConflict(e.message);
|
||
}
|
||
}
|
||
}]
|
||
});
|
||
}
|
||
function appointmentMenu(item) {
|
||
const actions = [{
|
||
label: t('common_close')
|
||
}];
|
||
if (isReception) {
|
||
actions.push({
|
||
label: t('clinic_confirm'),
|
||
onClick: () => api.clinicAppointmentStatus(item.id, {
|
||
status: 'confirmed',
|
||
editVersion: item.edit_version
|
||
}).then(load).catch(e => setError(e.message))
|
||
});
|
||
actions.push({
|
||
label: t('clinic_reprogram'),
|
||
onClick: () => {
|
||
open('appointment', item);
|
||
setEditing(true);
|
||
}
|
||
});
|
||
actions.push({
|
||
label: t('clinic_cancel'),
|
||
variant: 'danger',
|
||
onClick: () => {
|
||
open('appointment', item);
|
||
setCancelMode(true);
|
||
}
|
||
});
|
||
}
|
||
for (const status of ['attended', 'no_show']) actions.push({
|
||
label: t(`clinic_status_${status}`),
|
||
onClick: () => api.clinicAppointmentStatus(item.id, {
|
||
status,
|
||
editVersion: item.edit_version
|
||
}).then(load).catch(e => setError(e.message))
|
||
});
|
||
openDialog({
|
||
title: `${item.first_names} ${item.last_names}`,
|
||
message: item.starts_at,
|
||
actions
|
||
});
|
||
}
|
||
const periodTitle = (() => {
|
||
const d = new Date(`${day}T12:00:00`);
|
||
if (view === 'month' || view === 'list') return d.toLocaleDateString(lang, { month: 'long', year: 'numeric' });
|
||
if (view === 'day') return d.toLocaleDateString(lang, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||
const a = new Date(`${period.from.slice(0, 10)}T12:00:00`), b = new Date(`${move(period.to.slice(0, 10), -1)}T12:00:00`);
|
||
return `${a.toLocaleDateString(lang, { day: 'numeric', month: 'short' })} – ${b.toLocaleDateString(lang, { day: 'numeric', month: 'short', year: 'numeric' })}`;
|
||
})();
|
||
const doctorColor = id => doctors.find(d => d.id === id)?.color || 'rgb(var(--accent))';
|
||
const doctorShort = id => (doctors.find(d => d.id === id)?.name || '').split(' ').slice(0, 2).join(' ');
|
||
const weekLabels = [t('calendar_mon'), t('calendar_tue'), t('calendar_wed'), t('calendar_thu'), t('calendar_fri'), t('calendar_sat'), t('calendar_sun')];
|
||
// Anterior/siguiente: un dia, una semana o un mes segun la vista.
|
||
const step = n => {
|
||
if (view === 'week') return move(day, 7 * n);
|
||
if (view !== 'month') return move(day, n);
|
||
const [y, m] = day.split('-').map(Number), d = new Date(Date.UTC(y, m - 1 + n, 1));
|
||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-01`;
|
||
};
|
||
const visible = appointments.filter(a => selectedDoctors.some(d => d.id === a.doctor_user_id) && statusFilter.includes(a.status));
|
||
const visibleBlocks = blocks.filter(b => selectedDoctors.some(d => d.id === b.doctor_user_id));
|
||
const renderEvent = (a, byDoctor = false) => <button key={`a-${a.id}`} className="clinic-event appointment" style={{
|
||
borderLeftColor: byDoctor ? doctorColor(a.doctor_user_id) : a.type_color
|
||
}} onClick={() => open('appointment', a)} onContextMenu={e => {
|
||
e.preventDefault();
|
||
appointmentMenu(a);
|
||
}} title={`${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name} · ${a.type_name} · ${t(`clinic_status_${a.status}`)}`}>{byDoctor ? `${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names.slice(0, 1)}. · ${a.type_name}` : `${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name} · ${a.type_name} · ${t(`clinic_status_${a.status}`)}`}</button>;
|
||
const renderBlock = b => <button key={`b-${b.id}-${b.occurrence_start}`} className="clinic-event block" onClick={() => open('block', b)} title={b.title}>{b.occurrence_start.slice(11, 16)} {doctors.find(d => d.id === b.doctor_user_id)?.name} · {b.title}</button>;
|
||
const detail = popup?.item ? popup.kind === 'appointment' ? [[t('clinic_doctor'), popup.item.doctor_name], [t('clinic_patient'), `${popup.item.first_names} ${popup.item.last_names}`], [t('clinic_type'), popup.item.type_name], [t('calendar_start'), popup.item.starts_at], [t('calendar_end'), popup.item.ends_at], [t('clinic_status'), t(`clinic_status_${popup.item.status}`)], [t('clinic_channel'), t(`clinic_channel_${popup.item.channel}`)], [t('clinic_notes'), popup.item.notes]] : popup.kind === 'patient' ? [[t('clinic_first_names'), popup.item.first_names], [t('clinic_last_names'), popup.item.last_names], [t('clinic_identification'), popup.item.identification], [t('clinic_phone'), popup.item.phone], [t('clinic_email'), popup.item.email], [t('clinic_birth_date'), popup.item.birth_date], [t('clinic_notes'), popup.item.notes]] : [[t('clinic_doctor'), doctors.find(d => d.id === popup.item.doctor_user_id)?.name], [t('clinic_kind'), t(`clinic_kind_${popup.item.kind}`)], [t('clinic_title'), popup.item.title], [t('calendar_start'), popup.item.occurrence_start], [t('calendar_end'), popup.item.occurrence_end]] : [];
|
||
if (error && !me) return <InfoBar tone="error">{error}</InfoBar>;
|
||
// Horas visibles: desde la primera hasta la ultima de atencion de los medicos mostrados, ampliado
|
||
// con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00.
|
||
const hourRange = (() => {
|
||
let lo = 24, hi = 0;
|
||
for (const d of selectedDoctors) for (const seg of d.schedules || []) {
|
||
lo = Math.min(lo, Number(String(seg.starts_time).slice(0, 2)));
|
||
hi = Math.max(hi, Math.ceil(Number(String(seg.ends_time).slice(0, 2)) + Number(String(seg.ends_time).slice(3, 5)) / 60));
|
||
}
|
||
for (const x of [...visible.map(a => [a.starts_at, a.ends_at]), ...visibleBlocks.map(b => [b.occurrence_start, b.occurrence_end])]) {
|
||
lo = Math.min(lo, Number(String(x[0]).slice(11, 13)));
|
||
hi = Math.max(hi, Math.min(24, Math.ceil(Number(String(x[1]).slice(11, 13)) + Number(String(x[1]).slice(14, 16)) / 60)));
|
||
}
|
||
if (lo >= hi) [lo, hi] = [7, 19];
|
||
return Array.from({ length: hi - lo }, (_, i) => lo + i);
|
||
})();
|
||
return <div className="clinic-page"><div className="toolbar"><div className="toolbar-left"><h2 className="page-title mt-0"><Stethoscope size={22} />{t('nav_clinic')}</h2></div><div className="toolbar-left">{isReception && tab === 'agenda' && <Button variant="accent" icon={Plus} onClick={() => open('appointment', null, {
|
||
doctorId: singleDoctor
|
||
})}>{t('clinic_new_appointment')}</Button>}{isReception && tab === 'patients' && <Button variant="accent" icon={Plus} onClick={() => open('patient')}>{t('clinic_new_patient')}</Button>}{tab === 'blocks' && <Button variant="accent" icon={Plus} onClick={() => open('block', null, {
|
||
doctorId: singleDoctor
|
||
})}>{t('clinic_new_block')}</Button>}</div></div>
|
||
{error && <InfoBar tone="error">{error}</InfoBar>}{reprogram.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {reprogram.map(a => `#${a.id} ${a.patient_name} · ${a.starts_at}`).join('; ')}</InfoBar>}
|
||
<Tabs tabs={[...(me ? [['agenda', 'clinic_agenda'], ...(isReception ? [['patients', 'clinic_patients']] : []), ['blocks', 'clinic_blocks']] : []), ...(isAdmin ? [['config', 'clinic_config']] : [])].map(([key, label]) => ({ key, label: t(label) }))} active={tab} onChange={key => {
|
||
const current = popup ? draft : tab === 'config' ? [staffDraft, typeDraft, scheduleDraft].find(x => x.isDirty) : null;
|
||
void guardedClose(current, () => setTab(key));
|
||
}} />
|
||
{tab === 'agenda' && <><div className="toolbar clinic-toolbar"><div className="toolbar-left"><Button onClick={() => setDay(today())}>{t('calendar_today')}</Button><Button icon={ChevronLeft} onClick={() => setDay(step(-1))} aria-label={t('calendar_previous')} /><Button icon={ChevronRight} onClick={() => setDay(step(1))} aria-label={t('calendar_next')} /><strong className="clinic-period">{periodTitle}</strong></div><div className="toolbar-left clinic-controls"><ComboBox value={view} onChange={setView} options={['month', 'week', 'day', 'list'].map(x => ({
|
||
value: x,
|
||
label: t(`clinic_view_${x}`)
|
||
}))} /><CheckListDropdown label={t('clinic_doctors')} options={doctors.map(d => ({
|
||
value: d.id,
|
||
label: d.name
|
||
}))} values={doctorFilter} onChange={setDoctorFilter} /><CheckListDropdown label={t('clinic_status')} options={['scheduled', 'confirmed', 'attended', 'no_show'].map(x => ({
|
||
value: x,
|
||
label: t(`clinic_status_${x}`)
|
||
}))} values={statusFilter} onChange={setStatusFilter} /><DatePicker value={day} onChange={setDay} label={t('calendar_date')} /></div></div>
|
||
{me && doctors.length === 0 && <InfoBar tone="warning">{isAdmin ? t('clinic_no_doctors_admin') : t('clinic_no_doctors')}</InfoBar>}
|
||
{(view === 'week' || view === 'month') && <div className="clinic-legend">{selectedDoctors.map(d => <span key={d.id}><span className="clinic-swatch" style={{ background: d.color || 'rgb(var(--accent))' }} />{d.name}</span>)}<span><span className="clinic-swatch block" />{t('clinic_blocks')}</span></div>}
|
||
{view === 'day' ? <div className="clinic-day-grid">{selectedDoctors.map(d => <div className="clinic-doctor" key={d.id}><h3 style={{
|
||
borderTop: `3px solid ${d.color || 'rgb(var(--accent))'}`
|
||
}}>{d.name}</h3>{hourRange.map(h => {
|
||
const hour = String(h).padStart(2, '0'),
|
||
start = at(day, `${hour}:00`),
|
||
end = plus(start, 60),
|
||
weekday = (new Date(`${day}T12:00:00Z`).getUTCDay() + 6) % 7,
|
||
working = d.schedules?.some(s => s.weekday === weekday && `${day} ${s.starts_time}` <= start && `${day} ${s.ends_time}` >= end);
|
||
const blocked = visibleBlocks.some(b => b.doctor_user_id === d.id && b.occurrence_start < end && b.occurrence_end > start);
|
||
return <div key={hour} className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} onClick={() => working && !blocked && isReception && open('appointment', null, {
|
||
doctorId: d.id,
|
||
slot: start
|
||
})}><small>{hour}:00</small>{visible.filter(a => a.doctor_user_id === d.id && a.starts_at >= start && a.starts_at < end).map(renderEvent)}{visibleBlocks.filter(b => b.doctor_user_id === d.id && b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>;
|
||
})}</div>)}</div> : view === 'week' ? <div className="clinic-week-grid">{Array.from({
|
||
length: 7
|
||
}, (_, i) => move(period.from.slice(0, 10), i)).map(d => <div className="clinic-doctor" key={d}><h3>{weekLabels[(new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7]} {Number(d.slice(8, 10))}</h3>{hourRange.map(h => {
|
||
const start = at(d, `${String(h).padStart(2, '0')}:00`),
|
||
end = plus(start, 60),
|
||
weekday = (new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7,
|
||
// Con varios medicos: la hora es "de atencion" si alguno atiende; el sombreado de bloqueo
|
||
// solo aplica cuando se mira un medico (con varios, cada bloqueo se ve como entrada).
|
||
working = selectedDoctors.some(doc => doc.schedules?.some(seg => seg.weekday === weekday && `${d} ${seg.starts_time}` <= start && `${d} ${seg.ends_time}` >= end)),
|
||
blocked = selectedDoctors.length === 1 && visibleBlocks.some(b => b.occurrence_start < end && b.occurrence_end > start);
|
||
return <div className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} key={h} onClick={() => working && !blocked && isReception && open('appointment', null, {
|
||
doctorId: selectedDoctors.length === 1 ? selectedDoctors[0].id : undefined,
|
||
slot: start
|
||
})}><small>{String(h).padStart(2, '0')}:00</small>{visible.filter(a => a.starts_at >= start && a.starts_at < end).map(a => renderEvent(a, true))}{visibleBlocks.filter(b => b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>;
|
||
})}</div>)}</div> : view === 'month' ? <MonthGrid day={day} weekLabels={weekLabels} todayKey={today()} onDayClick={d => { setDay(d); setView('day'); }} renderDay={d => {
|
||
const items = [...visibleBlocks.filter(b => b.occurrence_start.slice(0, 10) <= d && b.occurrence_end.slice(0, 10) >= d).map(b => ({ start: b.occurrence_start, node: <div key={`b-${b.id}-${b.occurrence_start}`} className="clinic-chip block" style={{ borderLeftColor: doctorColor(b.doctor_user_id) }} title={b.title}>{doctorShort(b.doctor_user_id)} · {b.title}</div> })),
|
||
...visible.filter(a => a.starts_at.slice(0, 10) === d).map(a => ({ start: a.starts_at, node: <div key={`a-${a.id}`} className="clinic-chip" style={{ borderLeftColor: doctorColor(a.doctor_user_id) }} title={`${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name}`}>{a.starts_at.slice(11, 16)} {a.first_names} {a.last_names.slice(0, 1)}.</div> }))].sort((x, y) => x.start.localeCompare(y.start));
|
||
return <>{items.slice(0, 4).map(x => x.node)}{items.length > 4 && <div className="text-muted" style={{ fontSize: '.66rem' }}>+{items.length - 4} {t('clinic_more')}</div>}</>;
|
||
}} /> : <div className="clinic-list">{[...visible.map(a => ({
|
||
start: a.starts_at,
|
||
node: renderEvent(a)
|
||
})), ...visibleBlocks.map(b => ({
|
||
start: b.occurrence_start,
|
||
node: renderBlock(b)
|
||
}))].sort((a, b) => a.start.localeCompare(b.start)).map((x, i) => <div key={i}><strong>{x.start.slice(0, 10)}</strong>{x.node}</div>)}</div>}</>}
|
||
{tab === 'patients' && isReception && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
|
||
setSearch(e.target.value);
|
||
setPage(1);
|
||
}} /></Field><div className="clinic-list">{patients.map(p => <button key={p.id} className="clinic-row" onClick={() => open('patient', p)}>{p.first_names} {p.last_names} · {p.phone}</button>)}</div><div className="toolbar-left"><Button disabled={page <= 1} onClick={() => setPage(page - 1)}>{t('calendar_previous')}</Button>{page} / {Math.max(1, Math.ceil(patientTotal / 20))}<Button disabled={page * 20 >= patientTotal} onClick={() => setPage(page + 1)}>{t('calendar_next')}</Button></div></>}
|
||
{tab === 'blocks' && <div className="clinic-list">{visibleBlocks.map(b => <div key={`${b.id}-${b.occurrence_start}`}>{renderBlock(b)}</div>)}</div>}
|
||
{tab === 'config' && isAdmin && <div className="clinic-config"><h3>{t('clinic_staff')}</h3><ComboBox value={staffUser} onChange={x => {
|
||
setStaffUser(x);
|
||
const s = staffRows.find(r => r.user_id === Number(x));
|
||
setStaffInitial(s ? {
|
||
scope: s.scope,
|
||
isDoctor: !!s.is_doctor,
|
||
specialty: s.specialty || '',
|
||
color: s.color || '#0078d4',
|
||
isActive: !!s.is_active,
|
||
editVersion: s.edit_version
|
||
} : {
|
||
scope: 'own',
|
||
isDoctor: true,
|
||
specialty: '',
|
||
color: '#0078d4',
|
||
isActive: true
|
||
});
|
||
}} options={[{
|
||
value: '',
|
||
label: t('common_select')
|
||
}, ...allUsers.filter(u => u.is_active).map(u => ({
|
||
value: u.id,
|
||
label: u.name
|
||
}))]} /><Field label={t('clinic_user_id')}><input className="input" type="number" value={staffUser} onChange={e => setStaffUser(e.target.value)} /></Field><ComboBox value={staffForm.scope} onChange={x => setStaffForm({
|
||
...staffForm,
|
||
scope: x
|
||
})} options={['all', 'own'].map(x => ({
|
||
value: x,
|
||
label: t(`clinic_scope_${x}`)
|
||
}))} /><label><input type="checkbox" checked={staffForm.isDoctor} onChange={e => setStaffForm({
|
||
...staffForm,
|
||
isDoctor: e.target.checked
|
||
})} />{t('clinic_is_doctor')}</label><label><input type="checkbox" checked={staffForm.isActive} onChange={e => setStaffForm({
|
||
...staffForm,
|
||
isActive: e.target.checked
|
||
})} />{t('clinic_active')}</label><Field label={t('clinic_specialty')}><input className="input" value={staffForm.specialty} onChange={e => setStaffForm({
|
||
...staffForm,
|
||
specialty: e.target.value
|
||
})} /></Field><Field label={t('clinic_color')}><input type="color" value={staffForm.color} onChange={e => setStaffForm({
|
||
...staffForm,
|
||
color: e.target.value
|
||
})} /></Field><Button disabled={!staffUser || !!staffForm.editVersion && !staffDraft.isDirty} onClick={async () => {
|
||
try {
|
||
const saved = await api.clinicSaveStaff(staffUser, staffForm);
|
||
staffDraft.clear();
|
||
setStaffInitial({
|
||
scope: saved.scope,
|
||
isDoctor: !!saved.is_doctor,
|
||
specialty: saved.specialty || '',
|
||
color: saved.color || 'rgb(var(--accent))',
|
||
isActive: !!saved.is_active,
|
||
editVersion: saved.edit_version
|
||
});
|
||
setStaffRows(await api.clinicStaff());
|
||
setDoctors((await api.clinicDoctors()).doctors);
|
||
pushToast({
|
||
tone: 'success',
|
||
message: t('common_saved')
|
||
});
|
||
} catch (e) {
|
||
setError(e.message);
|
||
}
|
||
}}>{t('common_save')}</Button>
|
||
<h3>{t('clinic_types')}</h3><ComboBox value={typeForm.id || ''} onChange={x => {
|
||
const row = allTypes.find(r => r.id === Number(x));
|
||
setTypeInitial(row ? {
|
||
id: row.id,
|
||
name: row.name,
|
||
durationMinutes: row.duration_minutes,
|
||
color: row.color,
|
||
isActive: !!row.is_active,
|
||
editVersion: row.edit_version
|
||
} : {
|
||
name: '',
|
||
durationMinutes: 30,
|
||
color: '#0078d4',
|
||
isActive: true
|
||
});
|
||
}} options={[{
|
||
value: '',
|
||
label: t('clinic_new_type')
|
||
}, ...allTypes.map(x => ({
|
||
value: x.id,
|
||
label: x.name
|
||
}))]} /><Field label={t('clinic_name')}><input className="input" value={typeForm.name} onChange={e => setTypeForm({
|
||
...typeForm,
|
||
name: e.target.value
|
||
})} /></Field><Field label={t('clinic_duration')}><input className="input" type="number" value={typeForm.durationMinutes} onChange={e => setTypeForm({
|
||
...typeForm,
|
||
durationMinutes: Number(e.target.value)
|
||
})} /></Field><input type="color" value={typeForm.color} onChange={e => setTypeForm({
|
||
...typeForm,
|
||
color: e.target.value
|
||
})} /><label><input type="checkbox" checked={typeForm.isActive} onChange={e => setTypeForm({
|
||
...typeForm,
|
||
isActive: e.target.checked
|
||
})} />{t('clinic_active')}</label><Button disabled={!typeForm.name?.trim() || !!typeForm.editVersion && !typeDraft.isDirty} onClick={async () => {
|
||
try {
|
||
const saved = await api.clinicSaveType(typeForm, typeForm.id);
|
||
typeDraft.clear();
|
||
setTypeInitial({
|
||
id: saved.id,
|
||
name: saved.name,
|
||
durationMinutes: saved.duration_minutes,
|
||
color: saved.color,
|
||
isActive: !!saved.is_active,
|
||
editVersion: saved.edit_version
|
||
});
|
||
const data = await api.clinicDoctors();
|
||
setTypes(data.types);
|
||
setAllTypes(await api.clinicTypes());
|
||
pushToast({
|
||
tone: 'success',
|
||
message: t('common_saved')
|
||
});
|
||
} catch (e) {
|
||
setError(e.message);
|
||
}
|
||
}}>{t('common_save')}</Button>
|
||
<h3>{t('clinic_schedules')}</h3><ComboBox value={scheduleDoctor} onChange={async x => {
|
||
setScheduleDoctor(x);
|
||
setScheduleInitial({
|
||
rows: x ? await api.clinicSchedules(x) : []
|
||
});
|
||
}} options={[{
|
||
value: '',
|
||
label: t('common_select')
|
||
}, ...doctors.map(d => ({
|
||
value: d.id,
|
||
label: d.name
|
||
}))]} />{scheduleRows.map((s, i) => <div className="clinic-schedule-row" key={i}><ComboBox value={s.weekday} onChange={x => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
|
||
...r,
|
||
weekday: Number(x)
|
||
} : r))} options={['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map((x, n) => ({
|
||
value: n,
|
||
label: t(`calendar_${x}`)
|
||
}))} /><input type="time" value={s.startsTime || s.starts_time?.slice(0, 5) || ''} onChange={e => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
|
||
...r,
|
||
startsTime: e.target.value
|
||
} : r))} /><input type="time" value={s.endsTime || s.ends_time?.slice(0, 5) || ''} onChange={e => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
|
||
...r,
|
||
endsTime: e.target.value
|
||
} : r))} /><Button onClick={() => setScheduleRows(scheduleRows.filter((_, j) => j !== i))}>{t('common_delete')}</Button></div>)}<Button onClick={() => setScheduleRows([...scheduleRows, {
|
||
weekday: 0,
|
||
startsTime: '09:00',
|
||
endsTime: '17:00'
|
||
}])}>{t('calendar_add')}</Button><Button disabled={!scheduleDoctor || !scheduleDraft.isDirty} onClick={async () => {
|
||
try {
|
||
await api.clinicSaveSchedules(scheduleDoctor, {
|
||
segments: scheduleRows.map(s => ({
|
||
weekday: s.weekday,
|
||
startsTime: s.startsTime || s.starts_time,
|
||
endsTime: s.endsTime || s.ends_time
|
||
}))
|
||
});
|
||
const data = await api.clinicDoctors();
|
||
setDoctors(data.doctors);
|
||
scheduleDraft.clear();
|
||
setScheduleInitial({
|
||
rows: await api.clinicSchedules(scheduleDoctor)
|
||
});
|
||
pushToast({
|
||
tone: 'success',
|
||
message: t('common_saved')
|
||
});
|
||
} catch (e) {
|
||
setError(e.message);
|
||
}
|
||
}}>{t('common_save')}</Button></div>}
|
||
<DetailPopup open={!!popup} title={t(`clinic_${popup?.kind || 'appointment'}`)} onClose={close} width={760} height={680}>{popup && <div className="clinic-form">{conflict && <InfoBar tone="error">{conflict}</InfoBar>}{affected.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {affected.map(a => `#${a.id} ${a.patient_name}`).join(', ')} <Button onClick={() => void save(true)}>{t('clinic_confirm_affected')}</Button></InfoBar>}{editing && <DraftNotice draft={draft} />} {!editing ? <><div className="clinic-detail">{detail.filter(([, val]) => val !== null && val !== undefined && val !== '').map(([label, val]) => <p key={label}><strong>{label}:</strong> {String(val)}</p>)}</div>{cancelMode && <Field label={t('clinic_cancel_reason')}><input className="input" value={cancelReason} onChange={e => setCancelReason(e.target.value)} /></Field>}{cancelMode && <Button variant="danger" disabled={!cancelReason.trim()} onClick={cancelAppointment}>{t('clinic_cancel')}</Button>}{popup.kind === 'appointment' && <div className="toolbar-left">{isReception && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button onClick={() => setEditing(true)}>{t('clinic_reprogram')}</Button><Button disabled={popup.item.status === 'confirmed'} onClick={() => void actionStatus('confirmed')}>{t('clinic_confirm')}</Button><Button onClick={() => setCancelMode(true)}>{t('clinic_cancel')}</Button></>}{['attended', 'no_show'].map(x => <Button key={x} disabled={popup.item.status === x} onClick={() => void actionStatus(x)}>{t(`clinic_status_${x}`)}</Button>)}</div>}{popup.kind === 'patient' && isReception && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><h3>{t('clinic_history')}</h3>{patientHistory.map(a => <p key={a.id}>{a.starts_at} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}</p>)}</>}{popup.kind === 'block' && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button variant="danger" onClick={() => openDialog({
|
||
tone: 'warning',
|
||
title: t('clinic_delete_block'),
|
||
message: t('clinic_delete_block_confirm'),
|
||
actions: [{
|
||
label: t('common_cancel')
|
||
}, {
|
||
label: t('common_delete'),
|
||
variant: 'danger',
|
||
onClick: async () => {
|
||
try {
|
||
await api.clinicDeleteBlock(popup.item.id, popup.item.edit_version);
|
||
setPopup(null);
|
||
await load();
|
||
} catch (e) {
|
||
setConflict(e.message);
|
||
}
|
||
}
|
||
}]
|
||
})}>{t('common_delete')}</Button></>}</> : <>
|
||
{popup.kind === 'appointment' && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
|
||
setSearch(e.target.value);
|
||
api.clinicPatients(e.target.value, 1).then(x => setPatients(x.items)).catch(() => {});
|
||
}} /></Field><ComboBox value={v.patientId} onChange={x => draft.setField('patientId', x)} options={[{
|
||
value: '',
|
||
label: t('common_select')
|
||
}, ...patients.map(p => ({
|
||
value: p.id,
|
||
label: `${p.first_names} ${p.last_names} · ${p.identification || p.phone}`
|
||
}))]} /><Button onClick={() => open('patient', null, {
|
||
returnTo: popup
|
||
})}>{t('clinic_new_patient')}</Button><Field label={t('clinic_doctor')}><ComboBox value={v.doctorId} onChange={x => draft.setField('doctorId', x)} options={doctors.map(d => ({
|
||
value: d.id,
|
||
label: d.name
|
||
}))} /></Field><Field label={t('clinic_type')}><ComboBox value={v.typeId} onChange={x => {
|
||
const ty = types.find(z => z.id === Number(x));
|
||
draft.setValues(old => ({
|
||
...old,
|
||
typeId: x,
|
||
endsAt: plus(old.startsAt, ty?.duration_minutes || 30)
|
||
}));
|
||
}} options={types.map(z => ({
|
||
value: z.id,
|
||
label: z.name
|
||
}))} /></Field><Field label={t('calendar_start')}><DateTimeEditor value={v.startsAt} onChange={x => draft.setValues(old => ({
|
||
...old,
|
||
startsAt: x,
|
||
endsAt: plus(x, types.find(z => z.id === Number(old.typeId))?.duration_minutes || 30)
|
||
}))} label={t('calendar_start')} /></Field><Field label={t('calendar_end')}><DateTimeEditor value={v.endsAt} onChange={x => draft.setField('endsAt', x)} label={t('calendar_end')} /></Field><Field label={t('clinic_channel')}><ComboBox value={v.channel} onChange={x => draft.setField('channel', x)} options={['phone', 'whatsapp', 'in_person', 'other'].map(x => ({
|
||
value: x,
|
||
label: t(`clinic_channel_${x}`)
|
||
}))} /></Field><Field label={t('clinic_notes')}><textarea className="textarea" value={v.notes} onChange={e => draft.setField('notes', e.target.value)} /></Field><Field label={t('clinic_available')}><div className="clinic-slots">{slots.slice(0, 80).map(s => <Button key={s.startsAt} size="sm" onClick={() => draft.setValues(old => ({
|
||
...old,
|
||
startsAt: s.startsAt,
|
||
endsAt: s.endsAt
|
||
}))}>{s.startsAt.slice(11, 16)}</Button>)}</div></Field></>}
|
||
{popup.kind === 'patient' && [['firstNames', 'clinic_first_names'], ['lastNames', 'clinic_last_names'], ['identification', 'clinic_identification'], ['phone', 'clinic_phone'], ['email', 'clinic_email'], ['birthDate', 'clinic_birth_date'], ['notes', 'clinic_notes']].map(([k, label]) => <Field key={k} label={t(label)} dirty={draft.isFieldDirty(k)}><input className="input" value={v[k] || ''} onChange={e => draft.setField(k, e.target.value)} /></Field>)}
|
||
{popup.kind === 'block' && <><Field label={t('clinic_doctor')}>{popup.item ? <strong>{doctors.find(d => d.id === v.doctorId)?.name}</strong> : <ComboBox value={v.doctorId} onChange={x => draft.setField('doctorId', x)} options={doctors.map(d => ({
|
||
value: d.id,
|
||
label: d.name
|
||
}))} />}</Field><Field label={t('clinic_kind')}><ComboBox value={v.kind} onChange={x => draft.setField('kind', x)} options={['surgery', 'vacation', 'other'].map(x => ({
|
||
value: x,
|
||
label: t(`clinic_kind_${x}`)
|
||
}))} /></Field><Field label={t('clinic_title')}><input className="input" value={v.title} onChange={e => draft.setField('title', e.target.value)} /></Field><DateTimeEditor value={v.startsAt} onChange={x => draft.setField('startsAt', x)} label={t('calendar_start')} /><DateTimeEditor value={v.endsAt} onChange={x => draft.setField('endsAt', x)} label={t('calendar_end')} /><RecurrenceEditor value={v.recurrence} start={v.startsAt} onChange={x => draft.setField('recurrence', x)} /></>}
|
||
<div className="toolbar-left"><Button variant="accent" disabled={!!popup.item && !draft.isDirty} onClick={() => void save()}>{t('common_save')}</Button><Button onClick={() => {
|
||
draft.discard();
|
||
if (!popup.item) setPopup(null);else setEditing(false);
|
||
}}>{t('common_cancel')}</Button></div></>}</div>}</DetailPopup></div>;
|
||
}
|