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:
Milton Gonzalez
2026-10-29 15:30:21 -05:00
parent d0f90542e9
commit fabe5b530d
20 changed files with 746 additions and 564 deletions
+27
View File
@@ -145,6 +145,33 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
guardar solo si hubo cambios reales. guardar solo si hubo cambios reales.
- Secciones de una misma entidad (Proyecto: Info/Actividades/Gantt/Calendario/Comentarios/Documentos) - Secciones de una misma entidad (Proyecto: Info/Actividades/Gantt/Calendario/Comentarios/Documentos)
van en pestañas reales, nunca apiladas ni navegando a otra pantalla. 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 ## Flujos criticos
+2 -1
View File
@@ -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) ## 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 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`. médico y por paciente. `DATETIME` se interpreta en hora civil de `America/Guayaquil`.
+20
View File
@@ -1,5 +1,25 @@
# Changelog - project.linkedcorp.net # 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 ## [4.10.0] - 2026-10-29
### Added ### Added
- Conectores de datos externos (contrato v1, `data/conectores/README.md`): API HTTP de solo lectura - Conectores de datos externos (contrato v1, `data/conectores/README.md`): API HTTP de solo lectura
+3
View File
@@ -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 | | 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 | | 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 | | 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 |
+4 -1
View File
@@ -1,6 +1,6 @@
# ESTADO — project.linkedcorp.net # 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 + Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
autorregistro/aprobacion/SMTP. autorregistro/aprobacion/SMTP.
@@ -15,6 +15,9 @@ autorregistro/aprobacion/SMTP.
## Agenda médica (v4.7.0, sin desplegar) ## 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 - 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. 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 - v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en
+10 -1
View File
@@ -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. 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 - 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** 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. **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. 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 - 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 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 cualquier médico. Son errores 409 bloqueantes; la validación se repite bajo bloqueo de filas
+1 -1
View File
@@ -1 +1 @@
4.10.0 4.11.0
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.10.0", "version": "4.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.10.0", "version": "4.11.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.10.0", "version": "4.11.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+5
View File
@@ -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 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. 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) ## Formularios sin recuperación (a propósito)
| Formulario | Motivo | | Formulario | Motivo |
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.10.0", "version": "4.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.10.0", "version": "4.11.0",
"dependencies": { "dependencies": {
"dompurify": "^3.4.16", "dompurify": "^3.4.16",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "4.10.0", "version": "4.11.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+10 -2
View File
@@ -53,7 +53,11 @@ export const messages = {
clinic_kind_other: 'Otro', clinic_kind_other: 'Otro',
clinic_title: 'Título', clinic_title: 'Título',
clinic_staff: 'Personal', 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_all: 'Recepción (todos)',
clinic_scope_own: 'Médico (propia)', clinic_scope_own: 'Médico (propia)',
clinic_is_doctor: 'Es médico', clinic_is_doctor: 'Es médico',
@@ -743,7 +747,11 @@ export const messages = {
clinic_kind_other: 'Other', clinic_kind_other: 'Other',
clinic_title: 'Title', clinic_title: 'Title',
clinic_staff: 'Staff', 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_all: 'Reception (all)',
clinic_scope_own: 'Doctor (own)', clinic_scope_own: 'Doctor (own)',
clinic_is_doctor: 'Is doctor', 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)} />
</>;
}
+23 -1
View File
@@ -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). */ /* 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{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)} .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
});
+2 -2
View File
@@ -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; display: flex; align-items: center; justify-content: center; z-index: 250; padding: 16px;
} }
.dialog-surface { .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); overflow: hidden; box-shadow: 0 20px 48px rgb(0 0 0 / .4);
} }
.dialog-stripe { height: 4px; } .dialog-stripe { height: 4px; }
.dialog-body { padding: 20px; } .dialog-body { padding: 20px; }
.dialog-title { font-size: 1rem; font-weight: 700; margin-bottom: 8px; } .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-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 --- */
.infobar { .infobar {
+1 -1
View File
@@ -14,7 +14,7 @@ const TTL_MS = 7 * 24 * 60 * 60 * 1000;
const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i; const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i;
const DRAFT_FIELDS = { const DRAFT_FIELDS = {
'project-new': ['projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'], '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-type': ['editVersion','name','durationMinutes','color','isActive'],
'clinic-schedule': ['rows'], 'clinic-schedule': ['rows'],
'clinic-appointment': ['editVersion','doctorId','patientId','typeId','startsAt','endsAt','channel'], 'clinic-appointment': ['editVersion','doctorId','patientId','typeId','startsAt','endsAt','channel'],