Sistema de Citas Completo con MVVM y SQLite en JavaFX
Construye un sistema de gestión de citas profesional con JavaFX aplicando la arquitectura MVVM, base de datos SQLite, slots de horario inteligentes, validación de formularios y la librería jjarroyo-theme.
JJ Arroyo
5 de marzo de 2026 • 30 min de lectura

En este artículo analizamos a fondo un sistema de gestión de citas completo, construido con JavaFX siguiendo la arquitectura MVVM (Model-View-ViewModel) y usando SQLite como base de datos embebida. El proyecto integra la librería jjarroyo-theme para tener componentes premium listos para usar: tablas, modales, selectores, date pickers y más.
Al finalizar, podrás descargar el proyecto completo como un ZIP listo para ejecutar.
¿Qué incluye el proyecto?
- Login con tema claro/oscuro, "recordar credenciales" y validación asíncrona
- Dashboard con estadísticas reactivas (stat cards animadas)
- Gestión de Citas con slots de horario inteligentes (libres/ocupados/pasados)
- Gestión de Clientes con búsqueda, CRUD completo y validaciones
- Gestión de Servicios — catálogo de servicios con duración y precio
- Horarios de Negocio — configura horario mañana/tarde por día de la semana
- Configuración del negocio (nombre, teléfono, email)
- Modo oscuro/claro persistente con
ThemeManager - SQLite como base de datos embebida (sin servidor)
Pantallas del Sistema
Login

La pantalla de login usa un diseño split de dos paneles: el panel izquierdo es la zona de branding y el derecho contiene el formulario. Los campos usan JInput y JPasswordInput de la librería con el modo moderno activado (setModern(true)).
El botón "INGRESAR" usa setOnActionAsync(), que ejecuta la lógica en un hilo separado y regresa el resultado al hilo de UI automáticamente — sin bloquear la interfaz:
loginBtn.setOnActionAsync(
() -> viewModel.login(), // En hilo secundario
success -> { // Regresa al hilo UI
if (success) {
Platform.runLater(() -> {
DashboardViewModel dashVM = new DashboardViewModel(viewModel.getLoggedUser());
DashboardView dashView = new DashboardView(dashVM);
Stage stage = (Stage) root.getScene().getWindow();
root.getScene().setRoot(dashView.getRoot());
stage.setMaximized(true);
});
}
}
);
También tiene el checkbox "Recordar credenciales" que usa CredentialsManager para guardar/leer usuario y contraseña de forma persistente.
Dashboard (Inicio)

El dashboard muestra 3 stat cards animadas que cuentan desde cero hasta el valor real. Debajo, una lista de próximas citas con avatares, nombre del cliente, servicio, fecha/hora y badge de estado. Todo enlazado reactivamente al DashboardViewModel.
Gestión de Citas

El módulo de citas tiene una JTable con búsqueda, chips de estado de colores, y acciones por fila (confirmar, editar, eliminar). Al crear o editar una cita, se abre un modal grande con un selector de slots de horario inteligente.
Estructura del Proyecto
mvvm-citas/
├── src/main/java/com/mvvm_reactive/
│ ├── App.java # Punto de entrada
│ ├── database/
│ │ ├── Database.java # Singleton de conexión SQLite
│ │ └── DatabaseSeeder.java # Crea tablas y datos iniciales
│ ├── model/
│ │ ├── Appointment.java # Modelo con JavaFX Properties
│ │ ├── BusinessHour.java # Horario laboral por día
│ │ ├── Client.java
│ │ ├── Patient.java
│ │ ├── Service.java
│ │ └── User.java
│ ├── repository/ # Acceso a datos (SQL puro)
│ │ ├── AppointmentRepository.java
│ │ ├── BusinessHourRepository.java
│ │ ├── ClientRepository.java
│ │ └── ...
│ ├── view/ # Vistas (solo UI, sin lógica)
│ │ ├── LoginView.java
│ │ ├── DashboardView.java
│ │ ├── HomeView.java
│ │ ├── AppointmentsView.java
│ │ ├── ClientsView.java
│ │ └── ...
│ ├── viewmodel/ # Lógica de presentación y estado
│ │ ├── AppointmentViewModel.java
│ │ ├── DashboardViewModel.java
│ │ ├── ClientViewModel.java
│ │ └── ...
│ └── util/
│ ├── ThemeManager.java # Persistencia del tema claro/oscuro
│ ├── CredentialsManager.java # Guardar credenciales de login
│ └── CSSWatcher.java # Recarga de CSS en vivo
├── src/main/resources/
│ └── css/style.css # Estilos locales mínimos
├── lib/ # dependencias (sqlite-jdbc, jjarroyo)
├── sistema_citas.db # Base de datos SQLite
└── run.bat # Script de compilación y ejecución
Arquitectura MVVM
El proyecto aplica MVVM de forma estricta:
graph TD
A[View] -->|llama| B[ViewModel]
B -->|accede| C[Repository]
C -->|SQL| D[(SQLite DB)]
B -->|expone Properties| A
A -->|data binding| B
- Model: Clases POJO con
JavaFX Properties(StringProperty,IntegerProperty, etc.) que permiten observar cambios. - Repository: Clases que ejecutan SQL directamente con
PreparedStatement. Sin ORM. - ViewModel: Contiene la lógica de negocio, el estado de formularios y las
ObservableListque la vista consume. - View: Solo construye la UI y enlaza propiedades del ViewModel. No tiene lógica de negocio.
Paso 1: Inicialización de la Base de Datos
Database.java es un Singleton que gestiona la conexión SQLite:
public class Database {
private static final String DB_URL = "jdbc:sqlite:sistema_citas.db";
private static Connection connection;
public static Connection getConnection() throws SQLException {
if (connection == null || connection.isClosed()) {
connection = DriverManager.getConnection(DB_URL);
connection.createStatement().execute("PRAGMA foreign_keys = ON");
}
return connection;
}
public static void initialize() {
try {
Class.forName("org.sqlite.JDBC");
Connection conn = getConnection();
DatabaseSeeder.createTables(conn);
DatabaseSeeder.seedUsers(conn);
DatabaseSeeder.seedSettings(conn);
DatabaseSeeder.seedBusinessHours(conn);
DatabaseSeeder.seedClients(conn);
} catch (Exception e) {
e.printStackTrace();
}
}
}
DatabaseSeeder crea todas las tablas con CREATE TABLE IF NOT EXISTS e inserta datos por defecto solo si las tablas están vacías.
Los horarios de negocio se inicializan automáticamente: Lunes a Viernes de 08:00–12:00 y 14:00–18:00.
Paso 2: Los Modelos con JavaFX Properties
El truco central del MVVM en JavaFX es usar Properties en los modelos en lugar de campos simples. Esto permite el data binding reactivo:
public class Appointment {
private final IntegerProperty id = new SimpleIntegerProperty();
private final IntegerProperty clientId = new SimpleIntegerProperty();
private final IntegerProperty serviceId = new SimpleIntegerProperty();
private final StringProperty date = new SimpleStringProperty();
private final StringProperty time = new SimpleStringProperty();
private final IntegerProperty durationMinutes = new SimpleIntegerProperty(30);
private final StringProperty status = new SimpleStringProperty("pending");
private final StringProperty notes = new SimpleStringProperty();
// Campos de display (obtenidos por JOIN en SQL)
private final StringProperty clientName = new SimpleStringProperty();
private final StringProperty serviceName = new SimpleStringProperty();
// Getter de estado legible para el usuario
public String getStatusDisplay() {
return switch (status.get()) {
case "pending" -> "Pendiente";
case "confirmed" -> "Confirmada";
case "cancelled" -> "Cancelada";
case "completed" -> "Completada";
default -> status.get();
};
}
// Patrón JavaFX: getter, setter y xxxProperty()
public String getDate() { return date.get(); }
public void setDate(String v) { date.set(v); }
public StringProperty dateProperty() { return date; }
// ... (repetido para todos los campos)
}
¿Por qué clientName y serviceName están en Appointment? Porque los obtenemos con un JOIN SQL para no requerir carga adicional al mostrarlos en la tabla.
Paso 3: Repositorios — SQL Puro sin ORM
Cada repositorio expone métodos findAll(), save(), update(), delete() y métodos especializados. No se usa ningún ORM, solo JDBC con PreparedStatement:
// AppointmentRepository (fragmento)
public List<Appointment> findAll() {
List<Appointment> list = new ArrayList<>();
String sql = """
SELECT a.*, c.first_name || ' ' || c.last_name AS client_name,
s.name AS service_name
FROM appointments a
JOIN clients c ON a.client_id = c.id
JOIN services s ON a.service_id = s.id
ORDER BY a.date, a.time
""";
try (Connection conn = Database.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
Appointment apt = new Appointment(
rs.getInt("id"),
rs.getInt("client_id"),
rs.getInt("service_id"),
rs.getString("date"),
rs.getString("time"),
rs.getInt("duration_minutes"),
rs.getString("status"),
rs.getString("notes"),
rs.getString("client_name"),
rs.getString("service_name")
);
list.add(apt);
}
} catch (SQLException e) { e.printStackTrace(); }
return list;
}
public List<Appointment> findByDate(String date) {
// Similar pero con WHERE a.date = ?
}
Paso 4: El ViewModel de Citas — Lógica de Slots
El AppointmentViewModel es el más interesante del proyecto. Contiene el método generateTimeSlots() que calcula qué horarios están disponibles, ocupados o pasados para una fecha dada:
public List<TimeSlot> generateTimeSlots(LocalDate date) {
List<TimeSlot> slots = new ArrayList<>();
if (date == null) return slots;
// Obtener horario laboral del día
int dayOfWeek = date.getDayOfWeek().getValue(); // Lun=1 ... Dom=7
BusinessHour bh = businessHourRepo.findAll().stream()
.filter(h -> h.getDayOfWeek() == dayOfWeek)
.findFirst().orElse(null);
if (bh == null || !bh.isWorkingDay()) return slots;
List<Appointment> dayAppointments = appointmentRepo.findByDate(date.toString());
LocalTime now = LocalTime.now();
boolean isToday = date.equals(LocalDate.now());
int editId = editingAppointment != null ? editingAppointment.getId() : -1;
// Genera slots para turno mañana y tarde
generateSlotRange(slots, bh.getMorningStart(), bh.getMorningEnd(),
dayAppointments, isToday, now, editId);
generateSlotRange(slots, bh.getAfternoonStart(), bh.getAfternoonEnd(),
dayAppointments, isToday, now, editId);
return slots;
}
private void generateSlotRange(List<TimeSlot> slots, String startStr, String endStr,
List<Appointment> dayAppts, boolean isToday,
LocalTime now, int editId) {
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("HH:mm");
LocalTime current = LocalTime.parse(startStr, fmt);
LocalTime end = LocalTime.parse(endStr, fmt);
int interval = 30; // slots de 30 minutos
while (current.isBefore(end)) {
String timeStr = current.format(fmt);
SlotStatus status = SlotStatus.FREE;
String info = "";
// ¿Ya pasó? (solo si es hoy)
if (isToday && current.isBefore(now)) {
status = SlotStatus.PAST;
}
// ¿Está ocupado por otra cita?
for (Appointment apt : dayAppts) {
if (apt.getId() == editId) continue; // No bloquear la cita que estamos editando
LocalTime aptStart = LocalTime.parse(apt.getTime(), fmt);
LocalTime aptEnd = aptStart.plusMinutes(apt.getDurationMinutes());
if (!current.isBefore(aptStart) && current.isBefore(aptEnd)) {
status = SlotStatus.BUSY;
info = apt.getClientName();
break;
}
}
slots.add(new TimeSlot(timeStr, status, info));
current = current.plusMinutes(interval);
}
}
// Tipos internos del ViewModel
public enum SlotStatus { FREE, BUSY, PAST }
public record TimeSlot(String time, SlotStatus status, String info) {}
Tres reglas de los slots:
PAST— Ya pasó la hora (solo si es el día de hoy)BUSY— Hay una cita que cubre ese horario (verificando[aptStart, aptStart + duracion))FREE— Disponible para agendar
El detalle clave: cuando editamos una cita existente, su propio slot NO se marca como ocupado (if (apt.getId() == editId) continue).
Paso 5: La Vista de Citas — Slots en la UI
La vista transforma los TimeSlot en botones visuales dentro de un FlowPane. El código usa un Runnable refreshSlots que se re-ejecuta cada vez que cambia la fecha seleccionada:
Runnable refreshSlots = () -> {
slotsGrid.getChildren().clear();
LocalDate date = viewModel.formDateProperty().get();
List<TimeSlot> slots = viewModel.generateTimeSlots(date);
for (TimeSlot slot : slots) {
JButton slotBtn = new JButton(slot.time());
slotBtn.getStyleClass().addAll("btn", "btn-sm");
slotBtn.setMinWidth(65);
switch (slot.status()) {
case FREE -> {
// Azul si es el seleccionado, gris si no
if (slot.time().equals(selectedTime)) {
slotBtn.getStyleClass().add("btn-primary");
} else {
slotBtn.getStyleClass().add("btn-secondary");
}
slotBtn.setOnAction(e -> {
viewModel.formTimeProperty().set(slot.time());
refreshSlots.run(); // Refrescar selección visual
});
}
case BUSY -> {
slotBtn.getStyleClass().add("btn-danger");
slotBtn.setDisable(true);
slotBtn.setOpacity(0.7);
// Tooltip muestra el nombre del cliente que ocupó el slot
Tooltip.install(slotBtn, new Tooltip(slot.info()));
}
case PAST -> {
slotBtn.getStyleClass().add("btn-secondary");
slotBtn.setDisable(true);
slotBtn.setOpacity(0.4);
}
}
slotsGrid.getChildren().add(slotBtn);
}
};
refreshSlots.run();
datePicker.valueProperty().addListener((obs, o, n) -> refreshSlots.run());
Paso 6: La Tabla de Citas con JTable
La JTable de jjarroyo-theme simplifica enormemente la creación de tablas con búsqueda:
table = new JTable<>();
table.setSearchable(true);
table.setSearchPlaceholder("Buscar citas...");
table.setEmptyText("No hay citas registradas");
// Columnas simples (por nombre de propiedad del modelo)
table.addColumn("Fecha", "date");
table.addColumn("Hora", "time");
table.addColumn("Cliente", "clientName");
table.addColumn("Servicio", "serviceName");
// Columna con celda personalizada (chip de color)
table.addColumn("Estado", "status", false, apt -> {
JChip chip = new JChip(apt.getStatusDisplay());
JChip.ChipColor color = switch (apt.getStatus()) {
case "pending" -> JChip.ChipColor.WARNING;
case "confirmed" -> JChip.ChipColor.SUCCESS;
case "cancelled" -> JChip.ChipColor.DANGER;
case "completed" -> JChip.ChipColor.INFO;
default -> JChip.ChipColor.SLATE;
};
chip.setColor(color);
chip.setChipSize(JChip.Size.SM);
return chip;
});
// Botones de acción por fila
table.setRowActions((apt, box) -> {
JButton confirmBtn = new JButton("", JIcon.CHECK);
confirmBtn.getStyleClass().addAll("btn", "btn-sm", "btn-success");
confirmBtn.setOnAction(e -> showConfirmAttendanceModal(apt));
if (!"pending".equals(apt.getStatus())) {
confirmBtn.setDisable(true);
confirmBtn.setOpacity(0.5);
}
JButton editBtn = new JButton("", JIcon.EDIT);
editBtn.getStyleClass().addAll("btn", "btn-sm", "btn-info");
editBtn.setOnAction(e -> openFormModal(apt));
JButton deleteBtn = new JButton("", JIcon.DELETE);
deleteBtn.getStyleClass().addAll("btn", "btn-sm", "btn-danger");
deleteBtn.setOnAction(e -> showDeleteConfirmation(apt));
box.setSpacing(8);
box.getChildren().addAll(confirmBtn, editBtn, deleteBtn);
});
table.setItems(viewModel.getAppointments());
Paso 7: La Vista Principal (DashboardView) y Navegación
DashboardView es el shell principal que contiene JSidebar, JHeader y el área de contenido. La navegación es sencilla: cada JSidebarItem tiene una acción que limpia el contenido y muestra la vista correspondiente:
public class DashboardView {
private void buildUI() {
rootWrapper = new StackPane();
JJArroyo.setModalContainer(rootWrapper); // Los JModal se colocan aquí
JSidebar sidebar = new JSidebar();
sidebar.setBrand(JIcon.APP.view(), "Sistema de Citas");
JHeader header = new JHeader();
header.setUserProfile(
viewModel.getCurrentUser().getFullName(),
"admin",
initials,
() -> System.out.println("Logout...")
);
// Items del sidebar
JSidebarItem homeItem = new JSidebarItem("Inicio", JIcon.HOME.view());
JSidebarItem appointmentsItem = new JSidebarItem("Citas", JIcon.CALENDAR.view());
JSidebarItem clientsItem = new JSidebarItem("Clientes", JIcon.HEART.view());
JSidebarItem servicesItem = new JSidebarItem("Servicios", JIcon.SEARCH.view());
JSidebarItem scheduleItem = new JSidebarItem("Horarios", JIcon.CALENDAR.view());
JSidebarItem settingsItem = new JSidebarItem("Configuración", JIcon.SETTINGS.view());
sidebar.getItems().addAll(homeItem, appointmentsItem, clientsItem,
servicesItem, scheduleItem, settingsItem);
// Acciones de navegación
homeItem.setAction(() -> navigateTo(homeItem, new HomeView(viewModel).getRoot()));
appointmentsItem.setAction(() -> navigateTo(appointmentsItem, new AppointmentsView().getRoot()));
clientsItem.setAction(() -> navigateTo(clientsItem, new ClientsView().getRoot()));
// ...
navigateTo(homeItem, new HomeView(viewModel).getRoot()); // Vista inicial
}
private void navigateTo(JSidebarItem activeItem, Node viewRoot) {
sidebarItems.forEach(item -> item.setActive(false));
activeItem.setActive(true);
contentArea.getChildren().setAll(viewRoot);
}
}
Nota importante: JJArroyo.setModalContainer(rootWrapper) registra el StackPane raíz como el contenedor donde los JModal agregarán su overlay. Debe llamarse después de construir el StackPane.
Paso 8: El HomeView — Dashboard con Stat Cards
public class HomeView {
public HomeView(DashboardViewModel viewModel) {
// Stat Cards reactivas
JStatCard todayCard = new JStatCard();
todayCard.setTitle("Citas para Hoy");
todayCard.setIcon(JIcon.CALENDAR.view(), "info");
todayCard.setValue(viewModel.todayAppointmentsCountProperty().get());
viewModel.todayAppointmentsCountProperty()
.addListener((obs, o, n) -> todayCard.setValue(n.doubleValue()));
JStatCard pendingCard = new JStatCard();
pendingCard.setTitle("Citas Pendientes");
pendingCard.setIcon(JIcon.CLOCK.view(), "warning");
pendingCard.setValue(viewModel.pendingAppointmentsCountProperty().get());
JStatCard clientsCard = new JStatCard();
clientsCard.setTitle("Clientes Totales");
clientsCard.setIcon(JIcon.USERS.view(), "success");
clientsCard.setValue(viewModel.totalClientsCountProperty().get());
// Lista de próximas citas
List<Appointment> upcomingAppts = viewModel.getUpcomingAppointments();
// ... construye filas con JAvatar, Labels y JBadge
}
}
Cada stat card actualiza su valor con un listener a la propiedad del ViewModel. Cuando los datos cambien, la UI se actualiza sola — sin necesidad de refrescar manualmente.
Paso 9: Tema Claro/Oscuro Persistente
ThemeManager usa java.util.prefs.Preferences para persistir la preferencia del tema entre sesiones:
public class ThemeManager {
private static final Preferences prefs =
Preferences.userNodeForPackage(ThemeManager.class);
public static boolean isDarkMode() {
return prefs.getBoolean("darkMode", true); // oscuro por defecto
}
public static void saveTheme(boolean dark) {
prefs.putBoolean("darkMode", dark);
}
}
En la UI, el toggle aplica/quita la clase CSS "dark" al nodo raíz:
themeToggleBtn.setOnAction(e -> {
boolean isDark = rootWrapper.getStyleClass().contains("dark");
if (isDark) {
rootWrapper.getStyleClass().remove("dark");
themeToggleBtn.setIcon(JIcon.MOON);
ThemeManager.saveTheme(false);
} else {
rootWrapper.getStyleClass().add("dark");
themeToggleBtn.setIcon(JIcon.SUN);
ThemeManager.saveTheme(true);
}
});
La librería jjarroyo-theme detecta la clase dark en el nodo raíz y aplica todas las variables CSS del tema oscuro automáticamente.
Paso 10: Script de Compilación (run.bat)
El proyecto se compila y ejecuta con un script run.bat que incluye todas las dependencias del lib/ directamente:
@echo off
setlocal
set MAIN_CLASS=com.mvvm_reactive.App
set SRC_DIR=src\main\java
set OUT_DIR=bin
set LIB_DIR=lib
set CLASSPATH=%LIB_DIR%\*
echo Compilando...
javac -cp "%CLASSPATH%" -d %OUT_DIR% --module-path "%JAVAFX_PATH%" ^
--add-modules javafx.controls,javafx.fxml,javafx.graphics ^
-sourcepath %SRC_DIR% %SRC_DIR%\com\mvvm_reactive\App.java
echo Ejecutando...
java -cp "%OUT_DIR%;%CLASSPATH%" --module-path "%JAVAFX_PATH%" ^
--add-modules javafx.controls,javafx.fxml,javafx.graphics ^
%MAIN_CLASS%
Para ejecutar el proyecto solo necesitas tener javafx configurado como variable de entorno JAVAFX_PATH.
Conceptos Clave del Proyecto
| Concepto | Implementación |
|---|---|
| MVVM | Model, View, ViewModel con data binding vía Properties |
| SQLite | Conexión con sqlite-jdbc, sin ORM, SQL puro |
| Slot lógico | generateTimeSlots() — verifica PAST, BUSY, FREE por cada media hora |
| Datos reactivos | ObservableList y xxxProperty() para actualizaciones automáticas |
| Modales | JModal registrado en el StackPane raíz como overlay |
| Tema Dark/Light | Clase CSS dark en el nodo raíz + Preferences para persistencia |
| Login asíncrono | JButton.setOnActionAsync() ejecuta en hilo secundario |
| Credenciales | CredentialsManager con Preferences de Java |
Descargar el Proyecto
Descarga el proyecto completo con la base de datos SQLite, las dependencias en lib/ y el script run.bat:
Requisitos: Java 17+, JavaFX 21+, tener configurada la variable de entorno
JAVAFX_PATH.