feat: version 4.11.0 -- Agenda medica refactorizada, aviso de citas futuras y correcciones
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -145,6 +145,33 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
|
||||
guardar solo si hubo cambios reales.
|
||||
- Secciones de una misma entidad (Proyecto: Info/Actividades/Gantt/Calendario/Comentarios/Documentos)
|
||||
van en pestañas reales, nunca apiladas ni navegando a otra pantalla.
|
||||
- **Formularios en popup = modelo "Nueva reunión" de Calendario** (`pages/Calendario/CalendarioPage.jsx`):
|
||||
rejilla de 2 columnas (campos cortos en pares, texto largo a lo ancho), fecha + hora con
|
||||
`DateTimeEditor`, botones al pie. Cualquier formulario nuevo o existente que no siga esto se
|
||||
ajusta a ese modelo.
|
||||
- Un popup "Nuevo …" abre limpio con el contexto del clic (día, hora, médico): nada de la apertura
|
||||
anterior (búsquedas, selecciones) se arrastra. Solo vuelve lo que el usuario dejó como borrador
|
||||
("Dejar pendiente"), con el aviso de borrador recuperado.
|
||||
|
||||
## Delegar a Codex (u otro agente) en este proyecto
|
||||
|
||||
Además de la regla del orquestador: Claude escribe en el encargo las reglas que aplican (no "lee los
|
||||
md") y nombra **solo** los archivos/secciones necesarios de esta lista, según la tarea:
|
||||
|
||||
| Si la tarea toca… | Nombrar |
|
||||
|---|---|
|
||||
| Pantallas/formularios | `docs/UI-UX-WINDOWS.md` (sección del componente) + pantalla modelo (formularios: `pages/Calendario/CalendarioPage.jsx`, listados: `pages/Organizacion/UsersPanel.jsx`) |
|
||||
| Formularios con edición | `docs/SESION-Y-BORRADORES.md` § Comportamiento (`useDraftForm`) |
|
||||
| Backend / permisos | `docs/SEGURIDAD-Y-CALIDAD.md` (sección aplicable), `API.md` § del módulo |
|
||||
| Base de datos | `BASE_DE_DATOS.md` § del módulo + última migración |
|
||||
|
||||
Reglas fijas que siempre van escritas en el encargo: rejilla de 2 columnas como "Nueva reunión";
|
||||
listado primero y popup en lectura → Editar; nada se arrastra entre aperturas de un popup; textos en
|
||||
`i18n/messages.js` (es y en); reutilizar componentes de `src/components` sin crear variantes.
|
||||
Si necesita herramientas, se le dicen en el encargo (build: `npx vite build` en `frontend/`; navegador:
|
||||
Playwright global del orquestador; usuarios de prueba de dev: ver `ESTADO.md`).
|
||||
Codex desarrolla y **Claude revisa y prueba siempre** lo que entregue (diff contra estas reglas, build,
|
||||
navegador si cambia pantalla) antes de darlo por bueno.
|
||||
|
||||
## Flujos criticos
|
||||
|
||||
|
||||
+2
-1
@@ -17,7 +17,8 @@ La recurrencia se almacena como JSON de texto y se expande al consultar, sin fil
|
||||
|
||||
## Agenda médica (migración 036, v4.7.0)
|
||||
|
||||
`backend/database/migrations/036_clinic.sql` está creada y **no se ha aplicado** en ninguna BD.
|
||||
`backend/database/migrations/036_clinic.sql` y `037_clinic_traceability.sql`: **aplicadas en dev**
|
||||
(2026-10-28/29), **pendientes en producción** (las aplica el actualizador).
|
||||
Usa `utf8mb4`, FKs hacia `users`, `clinic_staff`, pacientes y tipos, e índices de rangos por
|
||||
médico y por paciente. `DATETIME` se interpreta en hora civil de `America/Guayaquil`.
|
||||
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Changelog - project.linkedcorp.net
|
||||
|
||||
## [4.11.0] - 2026-10-29
|
||||
### Added
|
||||
- Agenda médica: al elegir el paciente de una cita, aviso (no bloquea) con sus citas futuras
|
||||
agendadas/confirmadas, de cualquier fecha y médico.
|
||||
- Agenda médica: "Nueva cita" desde la fila de un paciente; "Nuevo paciente" desde la cita precarga
|
||||
la cédula buscada; historial de citas del paciente en listado.
|
||||
### Changed
|
||||
- Agenda médica refactorizada con el modelo de "Nueva reunión" de Calendario: formularios de cita,
|
||||
paciente y bloqueo en pares de campos con `DateTimeEditor`; Pacientes y Bloqueos como `ListView`;
|
||||
Configuración (personal, tipos, horario) como listados con popup en lectura → Editar (se quitó el
|
||||
campo "ID de usuario"). Código dividido en `ClinicForms.jsx`, `ClinicConfigTab.jsx`, `clinicUtils.js`.
|
||||
### Fixed
|
||||
- Agenda médica: el paciente/cédula de una cita ya no se arrastra a la siguiente (borrador huérfano
|
||||
al cancelar o cerrar "Nuevo paciente"); las notas de la cita se conservan al volver del paciente.
|
||||
- Agenda médica: "Descartar cambios" en una cita, paciente o bloqueo nuevo ya no tumba la página.
|
||||
- Agenda médica: búsquedas y huecos libres ya no se pisan con respuestas viejas; el paciente elegido
|
||||
no desaparece al buscar; el tipo desactivado de una cita se conserva al editarla.
|
||||
- Diálogo "Cambios sin guardar": los botones ya no se salen por la izquierda (pasan a otra línea).
|
||||
Sin migración.
|
||||
|
||||
## [4.10.0] - 2026-10-29
|
||||
### Added
|
||||
- Conectores de datos externos (contrato v1, `data/conectores/README.md`): API HTTP de solo lectura
|
||||
|
||||
@@ -10,3 +10,6 @@
|
||||
| 2 | 2026-10-29 | Datos de sistemas externos vía **conectores** (API HTTP de solo lectura instalada en el servidor origen, contrato v1 en `data/conectores/README.md`); el primero es FoxPro | Más seguro (el sistema no guarda credenciales del origen; el origen decide qué tablas/campos salen) y escalable (otra tecnología = otro conector, mismo contrato) | Conexión directa desde el backend por SMB/SFTP a los DBF o con usuario de BD; ODBC de FoxPro | vigente |
|
||||
| 3 | 2026-10-29 | Conector FoxPro en Node con `dbffile`, `node.exe` portable en `C:\api_project\foxpro`, tarea programada al arranque; lee una copia temporal de DBF+FPT | Mismo stack del proyecto, sin instalar nada en el server, no choca con los bloqueos de SAGA | Servicio .NET/Go; abrir los DBF originales; usar índices CDX | vigente |
|
||||
| 4 | 2026-10-29 | Por fuente, el superadmin elige **importar** o **consulta en vivo**; empleados de nómina siempre se importan con botón "Actualizar empleados" (admin y superadmin) | Pedido del usuario (ver `docs/CONEXIONES-EXTERNAS.md`) | — | vigente |
|
||||
| 5 | 2026-10-29 | Formularios en popup siguen el modelo "Nueva reunión" de Calendario; listados con `ListView` y popup lectura → Editar (aplicado a toda la Agenda médica) | Consistencia pedida por el usuario; la Agenda (hecha por Codex sin guías) tenía un campo por línea y formularios siempre abiertos | Mantener el diseño propio de la Agenda | vigente |
|
||||
| 6 | 2026-10-29 | Citas futuras del mismo paciente: **aviso, no bloqueo**, al elegir el paciente (cualquier fecha/médico) | Pedido del usuario; recepción decide | Bloquear o pedir confirmación al guardar | vigente |
|
||||
| 7 | 2026-10-29 | Al delegar a Codex: reglas escritas en el encargo + solo los `.md`/secciones necesarios + herramientas; Claude revisa y prueba siempre (también en el orquestador) | Lo entregado sin guías salió fuera de las reglas | Delegar con "lee la documentación" | vigente |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ESTADO — project.linkedcorp.net
|
||||
|
||||
Ultima actualizacion: 2026-10-29 (v4.10.0 commit local, sin push ni desplegar)
|
||||
Ultima actualizacion: 2026-10-29 (v4.11.0 commit + push, sin desplegar)
|
||||
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
|
||||
autorregistro/aprobacion/SMTP.
|
||||
|
||||
@@ -15,6 +15,9 @@ autorregistro/aprobacion/SMTP.
|
||||
|
||||
## Agenda médica (v4.7.0, sin desplegar)
|
||||
|
||||
- 2026-10-29 (v4.11.0): refactor de la pantalla en dev. Formularios cita/paciente/bloqueo con el modelo de "Nueva reunión" (`ClinicForms.jsx`), Pacientes y Bloqueos en `ListView`, Configuración como listados + popup lectura/Editar (`ClinicConfigTab.jsx`; se quitó el campo "ID de usuario"). Corregido: paciente/cédula que se arrastraba a la siguiente cita (borrador huérfano al cancelar "Nuevo paciente") y caída al "Descartar cambios" en un registro nuevo. Probado en navegador (recepción y admin temporal), sin errores de consola.
|
||||
- 2026-10-29 (v4.11.0): aviso sin bloqueo al elegir paciente en una cita si tiene citas futuras agendadas/confirmadas (lista hasta 5; excluye la cita que se edita). Diálogo compartido "Cambios sin guardar": botones ya no se desbordan (`app.css`).
|
||||
- 2026-10-29: BD de dev con datos de prueba de la agenda (15-oct a 28-nov): 7 médicos (`own`) y recepción `pruiz`, `jcabrera` (`all`) `@agenda.prueba` + mgonzalez, 60 pacientes (nota `[DATO DE PRUEBA]`), 4 tipos "(prueba)", 569 citas, 6 bloqueos. Script: `seed-agenda.cjs` del scratchpad de la sesión (con `--limpiar` los borra).
|
||||
- v4.9.1: la agenda usa el componente compartido `CalendarBoard` (el mismo del Calendario); siempre
|
||||
abre en Mes (`plc_clinic_view_v3`). En prod se veía vacía por la vista Día guardada y sin médicos.
|
||||
- v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en
|
||||
|
||||
@@ -102,9 +102,18 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
|
||||
y tipos de cita desde la pestaña Configuración de agenda.
|
||||
- Vistas (v4.8.0; desde v4.8.1 abre en Mes con la barra del Calendario; la elegida se recuerda): **Día** con una columna por médico; **Semana** como
|
||||
calendario de 7 días con las citas de todos los médicos filtrados, coloreadas por médico; **Mes**
|
||||
como calendario mensual (hasta 4 entradas por día + "N más"; clic en un día abre su vista Día);
|
||||
como calendario mensual (hasta 5 entradas por día + "N más" que lleva al día; desde v4.9.0 clic en un día u hora libre abre la cita nueva con esa fecha);
|
||||
**Lista** con citas y bloqueos. Leyenda de colores por médico en Semana y Mes.
|
||||
Crear cita desde un hueco precarga médico/hora; el tipo fija la duración por defecto.
|
||||
- Formularios (desde la refactorización posterior a v4.10.0) con el modelo de "Nueva reunión" de
|
||||
Calendario: campos en pares, fecha + hora con `DateTimeEditor`, notas a lo ancho. Pacientes y
|
||||
Bloqueos son listados (`ListView`); Configuración (personal, tipos, horario) son listados con popup
|
||||
en lectura → Editar. El detalle de un paciente muestra su historial de citas.
|
||||
- "Nuevo paciente" desde la cita abre el formulario del paciente encima (precarga la cédula si lo
|
||||
buscado es numérico); guardar, cancelar o cerrar vuelve a la cita. Cada cita nueva abre limpia.
|
||||
- **Aviso, no bloqueo:** al elegir el paciente de una cita, si tiene citas futuras agendadas o
|
||||
confirmadas (cualquier fecha y médico, sin contar la que se edita) se listan como advertencia y
|
||||
se puede agendar igual. Sin citas futuras no se muestra nada.
|
||||
- Una cita no cancelada solo cabe si está completa dentro de un tramo de atención y no cruza
|
||||
otra cita del médico, un bloqueo suyo (también recurrente) ni otra cita del paciente con
|
||||
cualquier médico. Son errores 409 bloqueantes; la validación se repite bajo bloqueo de filas
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -42,6 +42,11 @@ abierta `?activity=&activityTab=`, Organización `?tab=`, Equipos `?tab=&device=
|
||||
pide recargar para revisar la versión actual. En Info de proyecto/actividad, Equipos, SMTP y
|
||||
Empresa además se consulta el servidor justo antes de guardar.
|
||||
|
||||
- **Formulario abierto desde otro** (ej. "Nuevo paciente" desde una cita): guardar, cancelar o
|
||||
cerrar vuelve al formulario de origen con su borrador; nunca se cierra el de origen dejando su
|
||||
borrador huérfano (eso hacía que la siguiente cita nueva apareciera con el paciente anterior).
|
||||
Descartar un registro **nuevo** cierra el popup; uno existente vuelve a lectura.
|
||||
|
||||
## Formularios sin recuperación (a propósito)
|
||||
|
||||
| Formulario | Motivo |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.16",
|
||||
"highlight.js": "^11.12.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -53,7 +53,11 @@ export const messages = {
|
||||
clinic_kind_other: 'Otro',
|
||||
clinic_title: 'Título',
|
||||
clinic_staff: 'Personal',
|
||||
clinic_user_id: 'ID de usuario',
|
||||
clinic_user: 'Usuario',
|
||||
clinic_scope: 'Rol en la agenda',
|
||||
clinic_add_staff: 'Agregar personal',
|
||||
clinic_add_segment: 'Agregar tramo',
|
||||
clinic_patient_upcoming: 'Este paciente ya tiene citas pendientes (puedes agendar igual):',
|
||||
clinic_scope_all: 'Recepción (todos)',
|
||||
clinic_scope_own: 'Médico (propia)',
|
||||
clinic_is_doctor: 'Es médico',
|
||||
@@ -743,7 +747,11 @@ export const messages = {
|
||||
clinic_kind_other: 'Other',
|
||||
clinic_title: 'Title',
|
||||
clinic_staff: 'Staff',
|
||||
clinic_user_id: 'User ID',
|
||||
clinic_user: 'User',
|
||||
clinic_scope: 'Agenda role',
|
||||
clinic_add_staff: 'Add staff',
|
||||
clinic_add_segment: 'Add time slot',
|
||||
clinic_patient_upcoming: 'This patient already has upcoming appointments (you can still book):',
|
||||
clinic_scope_all: 'Reception (all)',
|
||||
clinic_scope_own: 'Doctor (own)',
|
||||
clinic_is_doctor: 'Is doctor',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { UserPlus } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { DateTimeEditor } from '../../components/DateTimeEditor.jsx';
|
||||
import { DatePicker } from '../../components/DatePicker.jsx';
|
||||
import { RecurrenceEditor } from '../../components/RecurrenceEditor.jsx';
|
||||
import { plus } from './clinicUtils.js';
|
||||
|
||||
// Formularios de la Agenda médica con la misma estructura que "Nueva reunión" de Calendario:
|
||||
// campos cortos en pares (FieldRow), texto largo a lo ancho, fecha + hora con DateTimeEditor.
|
||||
|
||||
const patientLabel = (p) => `${p.last_names} ${p.first_names}${p.identification || p.phone ? ` · ${p.identification || p.phone}` : ''}`;
|
||||
|
||||
export function AppointmentForm({ draft, doctors, types, patients, search, onSearch, onNewPatient, slots, upcoming = [] }) {
|
||||
const { t } = useUi();
|
||||
const v = draft.values;
|
||||
const duration = (typeId) => types.find((z) => z.id === Number(typeId))?.duration_minutes || 30;
|
||||
return <>
|
||||
<FieldRow>
|
||||
<Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={(e) => onSearch(e.target.value)} /></Field>
|
||||
<Field label={t('clinic_patient')} dirty={draft.isFieldDirty('patientId')}>
|
||||
<div className="clinic-inline">
|
||||
<ComboBox value={v.patientId} onChange={(x) => draft.setField('patientId', x)} options={[{ value: '', label: t('common_select') }, ...patients.map((p) => ({ value: p.id, label: patientLabel(p) }))]} />
|
||||
<Button icon={UserPlus} onClick={onNewPatient} title={t('clinic_new_patient')} aria-label={t('clinic_new_patient')} />
|
||||
</div>
|
||||
</Field>
|
||||
</FieldRow>
|
||||
{upcoming.length > 0 && <InfoBar tone="warning">{t('clinic_patient_upcoming')}<ul className="clinic-upcoming">{upcoming.slice(0, 5).map((a) => <li key={a.id}>{a.starts_at.slice(0, 16)} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}</li>)}</ul>{upcoming.length > 5 && `+${upcoming.length - 5}`}</InfoBar>}
|
||||
<FieldRow>
|
||||
<Field label={t('clinic_doctor')} dirty={draft.isFieldDirty('doctorId')}><ComboBox value={v.doctorId} onChange={(x) => draft.setField('doctorId', x)} options={[{ value: '', label: t('common_select') }, ...doctors.map((d) => ({ value: d.id, label: d.name }))]} /></Field>
|
||||
<Field label={t('clinic_type')} dirty={draft.isFieldDirty('typeId')}><ComboBox value={v.typeId} onChange={(x) => draft.setValues((old) => ({ ...old, typeId: x, endsAt: plus(old.startsAt, duration(x)) }))} options={[{ value: '', label: t('common_select') }, ...types.map((z) => ({ value: z.id, label: `${z.name} (${z.duration_minutes} min)` }))]} /></Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('calendar_start')} dirty={draft.isFieldDirty('startsAt')}><DateTimeEditor value={v.startsAt} onChange={(x) => draft.setValues((old) => ({ ...old, startsAt: x, endsAt: plus(x, duration(old.typeId)) }))} label={t('calendar_start')} /></Field>
|
||||
<Field label={t('calendar_end')} dirty={draft.isFieldDirty('endsAt')}><DateTimeEditor value={v.endsAt} onChange={(x) => draft.setField('endsAt', x)} label={t('calendar_end')} isEnd /></Field>
|
||||
</FieldRow>
|
||||
{slots.length > 0 && <Field label={t('clinic_available')}><div className="clinic-slots">{slots.slice(0, 80).map((s) => <Button key={s.startsAt} size="sm" variant={s.startsAt === v.startsAt ? 'accent' : undefined} onClick={() => draft.setValues((old) => ({ ...old, startsAt: s.startsAt, endsAt: s.endsAt }))}>{s.startsAt.slice(11, 16)}</Button>)}</div></Field>}
|
||||
<FieldRow>
|
||||
<Field label={t('clinic_channel')} dirty={draft.isFieldDirty('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>
|
||||
<div className="field" />
|
||||
</FieldRow>
|
||||
<Field label={t('clinic_notes')} dirty={draft.isFieldDirty('notes')}><textarea className="textarea" maxLength={500} value={v.notes} onChange={(e) => draft.setField('notes', e.target.value)} /></Field>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function PatientForm({ draft }) {
|
||||
const { t } = useUi();
|
||||
const v = draft.values;
|
||||
const input = (k, label, extra = {}) => <Field label={t(label)} dirty={draft.isFieldDirty(k)}><input className="input" value={v[k] || ''} onChange={(e) => draft.setField(k, e.target.value)} {...extra} /></Field>;
|
||||
return <>
|
||||
<FieldRow>{input('firstNames', 'clinic_first_names', { maxLength: 120 })}{input('lastNames', 'clinic_last_names', { maxLength: 120 })}</FieldRow>
|
||||
<FieldRow>{input('identification', 'clinic_identification', { maxLength: 40 })}{input('phone', 'clinic_phone', { maxLength: 40, type: 'tel' })}</FieldRow>
|
||||
<FieldRow>
|
||||
{input('email', 'clinic_email', { maxLength: 160, type: 'email' })}
|
||||
<Field label={t('clinic_birth_date')} dirty={draft.isFieldDirty('birthDate')}><DatePicker value={v.birthDate || ''} onChange={(x) => draft.setField('birthDate', x || '')} label={t('clinic_birth_date')} /></Field>
|
||||
</FieldRow>
|
||||
<Field label={t('clinic_notes')} dirty={draft.isFieldDirty('notes')}><textarea className="textarea" maxLength={500} value={v.notes || ''} onChange={(e) => draft.setField('notes', e.target.value)} /></Field>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function BlockForm({ draft, doctors, fixedDoctor }) {
|
||||
const { t } = useUi();
|
||||
const v = draft.values;
|
||||
return <>
|
||||
<FieldRow>
|
||||
<Field label={t('clinic_doctor')} dirty={draft.isFieldDirty('doctorId')}>{fixedDoctor ? <input className="input" readOnly value={doctors.find((d) => d.id === v.doctorId)?.name || ''} /> : <ComboBox value={v.doctorId} onChange={(x) => draft.setField('doctorId', x)} options={[{ value: '', label: t('common_select') }, ...doctors.map((d) => ({ value: d.id, label: d.name }))]} />}</Field>
|
||||
<Field label={t('clinic_kind')} dirty={draft.isFieldDirty('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>
|
||||
</FieldRow>
|
||||
<Field label={t('clinic_title')} dirty={draft.isFieldDirty('title')}><input className="input" maxLength={160} value={v.title} onChange={(e) => draft.setField('title', e.target.value)} /></Field>
|
||||
<FieldRow>
|
||||
<Field label={t('calendar_start')} dirty={draft.isFieldDirty('startsAt')}><DateTimeEditor value={v.startsAt} onChange={(x) => draft.setField('startsAt', x)} label={t('calendar_start')} /></Field>
|
||||
<Field label={t('calendar_end')} dirty={draft.isFieldDirty('endsAt')}><DateTimeEditor value={v.endsAt} onChange={(x) => draft.setField('endsAt', x)} label={t('calendar_end')} isEnd /></Field>
|
||||
</FieldRow>
|
||||
<RecurrenceEditor value={v.recurrence} start={v.startsAt} onChange={(x) => draft.setField('recurrence', x)} />
|
||||
</>;
|
||||
}
|
||||
@@ -1,4 +1,26 @@
|
||||
.clinic-page{padding:16px;min-width:0}.clinic-page .page-title{display:flex;align-items:center;gap:8px}.clinic-schedule-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}.clinic-event{display:block;width:100%;border:0;border-left:4px solid rgb(var(--accent));background:rgb(var(--accent)/.16);color:rgb(var(--body));border-radius:4px;text-align:left;padding:6px;margin:2px 0;cursor:pointer;font-size:.78rem}.clinic-event.block{border-left-color:rgb(var(--warning));background:rgb(var(--warning)/.2)}.clinic-list{display:grid;gap:6px;margin:12px 0}.clinic-list>div,.clinic-row{border:1px solid rgb(var(--line));background:rgb(var(--surface));padding:8px;border-radius:var(--r-sm);text-align:left;color:rgb(var(--body))}.clinic-list .clinic-event{display:inline-block;width:auto;min-width:260px;margin-left:12px}.clinic-form{display:grid;gap:12px}.clinic-form .field{min-width:0}.clinic-slots{display:flex;flex-wrap:wrap;gap:4px}.clinic-detail{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:4px 12px}.clinic-detail p{margin:5px 0;overflow-wrap:anywhere}.clinic-config{display:grid;gap:8px;max-width:720px}.clinic-config h3{margin-top:24px}@media(max-width:640px){.clinic-detail{grid-template-columns:1fr}}
|
||||
.clinic-page{padding:16px;min-width:0}
|
||||
.clinic-page .page-title{display:flex;align-items:center;gap:8px}
|
||||
/* Formularios: misma estructura que "Nueva reunión" (FieldRow en pares, acciones al pie). */
|
||||
.clinic-form{display:grid;gap:12px;align-content:start}
|
||||
.clinic-form .field{min-width:0}
|
||||
.clinic-inline{display:flex;gap:8px;align-items:center}
|
||||
.clinic-inline>:first-child{flex:1;min-width:0}
|
||||
.clinic-actions{margin-top:18px;flex-wrap:wrap}
|
||||
.clinic-checks{display:flex;gap:24px;flex-wrap:wrap}
|
||||
.clinic-color{padding:2px;height:36px;max-width:120px}
|
||||
.clinic-upcoming{margin:6px 0 0;padding-left:18px}
|
||||
.clinic-slots{display:flex;flex-wrap:wrap;gap:4px}
|
||||
.clinic-detail{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px 16px}
|
||||
.clinic-detail p{margin:5px 0;overflow-wrap:anywhere}
|
||||
.clinic-name{display:inline-flex;align-items:center;gap:8px}
|
||||
.clinic-name .calendar-swatch,.clinic-detail .calendar-swatch{display:inline-block;width:10px;height:10px;border-radius:3px;flex:none}
|
||||
.clinic-pager{margin-top:12px;align-items:center}
|
||||
.clinic-config{display:grid;gap:8px}
|
||||
.clinic-config h3:not(.mt-0){margin:20px 0 4px}
|
||||
.clinic-config h3.mt-0{margin-bottom:4px}
|
||||
.clinic-schedule-row{display:grid;grid-template-columns:minmax(120px,1fr) minmax(110px,1fr) minmax(110px,1fr) auto;gap:8px;align-items:end}
|
||||
.clinic-schedule-row>.btn{margin-bottom:2px}
|
||||
@media(max-width:640px){.clinic-detail{grid-template-columns:1fr}.clinic-schedule-row{grid-template-columns:1fr 1fr}}
|
||||
/* Entradas de la agenda en el calendario compartido: color del medico (--doctor). */
|
||||
.meeting-calendar .calendar-entry.clinic-appt{color:rgb(var(--body));background:color-mix(in srgb,var(--doctor) 18%,transparent);box-shadow:inset 3px 0 0 var(--doctor);padding-left:8px}
|
||||
.meeting-calendar .calendar-entry.clinic-appt.deleted{background:transparent;outline:1px dashed color-mix(in srgb,var(--doctor) 60%,transparent)}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Utilidades de la Agenda médica (fechas "YYYY-MM-DD HH:MM:SS" en hora de Ecuador, sin zona).
|
||||
export const at = (day, hour = '09:00') => `${day} ${hour}:00`;
|
||||
export 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', ' ');
|
||||
};
|
||||
export const readStored = (key, fallback) => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key)) ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
export const WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||
|
||||
export const initialAppointment = (day, doctorId, slot, patientId) => ({
|
||||
doctorId: doctorId || '',
|
||||
patientId: patientId || '',
|
||||
typeId: '',
|
||||
startsAt: slot || at(day),
|
||||
endsAt: plus(slot || at(day), 30),
|
||||
channel: 'phone',
|
||||
notes: ''
|
||||
});
|
||||
export const initialPatient = (identification = '') => ({
|
||||
firstNames: '',
|
||||
lastNames: '',
|
||||
identification,
|
||||
phone: '',
|
||||
email: '',
|
||||
birthDate: '',
|
||||
notes: ''
|
||||
});
|
||||
export const initialBlock = (day, doctorId) => ({
|
||||
doctorId: doctorId || '',
|
||||
kind: 'surgery',
|
||||
title: '',
|
||||
startsAt: at(day),
|
||||
endsAt: plus(at(day), 60),
|
||||
recurrence: null
|
||||
});
|
||||
export 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
|
||||
});
|
||||
export 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
|
||||
});
|
||||
export 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
|
||||
});
|
||||
@@ -303,14 +303,14 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
||||
display: flex; align-items: center; justify-content: center; z-index: 250; padding: 16px;
|
||||
}
|
||||
.dialog-surface {
|
||||
width: 420px; max-width: 100%; background: rgb(var(--surface)); border-radius: var(--r-lg);
|
||||
width: 460px; max-width: 100%; background: rgb(var(--surface)); border-radius: var(--r-lg);
|
||||
overflow: hidden; box-shadow: 0 20px 48px rgb(0 0 0 / .4);
|
||||
}
|
||||
.dialog-stripe { height: 4px; }
|
||||
.dialog-body { padding: 20px; }
|
||||
.dialog-title { font-size: 1rem; font-weight: 700; margin-bottom: 8px; }
|
||||
.dialog-message { font-size: .84rem; color: rgb(var(--muted)); line-height: 1.5; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid rgb(var(--line)); }
|
||||
.dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid rgb(var(--line)); }
|
||||
|
||||
/* --- InfoBar --- */
|
||||
.infobar {
|
||||
|
||||
@@ -14,7 +14,7 @@ const TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i;
|
||||
const DRAFT_FIELDS = {
|
||||
'project-new': ['projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
|
||||
'clinic-staff': ['editVersion','scope','isDoctor','specialty','color','isActive'],
|
||||
'clinic-staff': ['editVersion','userId','scope','isDoctor','specialty','color','isActive'],
|
||||
'clinic-type': ['editVersion','name','durationMinutes','color','isActive'],
|
||||
'clinic-schedule': ['rows'],
|
||||
'clinic-appointment': ['editVersion','doctorId','patientId','typeId','startsAt','endsAt','channel'],
|
||||
|
||||
Reference in New Issue
Block a user