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.

J

JJ Arroyo

5 de marzo de 2026 30 min de lectura

Sistema de Citas Completo con MVVM y SQLite en JavaFX

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

Login del Sistema de Citas

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)

Dashboard con Stat Cards y Próximas Citas

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

Módulo de 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 ObservableList que 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:

  1. PAST — Ya pasó la hora (solo si es el día de hoy)
  2. BUSY — Hay una cita que cubre ese horario (verificando [aptStart, aptStart + duracion))
  3. 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

ConceptoImplementación
MVVMModel, View, ViewModel con data binding vía Properties
SQLiteConexión con sqlite-jdbc, sin ORM, SQL puro
Slot lógicogenerateTimeSlots() — verifica PAST, BUSY, FREE por cada media hora
Datos reactivosObservableList y xxxProperty() para actualizaciones automáticas
ModalesJModal registrado en el StackPane raíz como overlay
Tema Dark/LightClase CSS dark en el nodo raíz + Preferences para persistencia
Login asíncronoJButton.setOnActionAsync() ejecuta en hilo secundario
CredencialesCredentialsManager 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.

forumComentarios

Deja tu comentario

progress_activityCargando comentarios...