first commit

This commit is contained in:
=
2026-09-21 16:33:51 -04:00
commit a899479dc9
204 changed files with 21132 additions and 0 deletions
@@ -0,0 +1,39 @@
class DashboardMetricsModel {
final int nuevosPacientes;
final int consultadosHoy;
final int citasHoy;
final int pacientesMasculinos;
final int pacientesFemeninos;
final int totalPacientes;
const DashboardMetricsModel({
this.nuevosPacientes = 0,
this.consultadosHoy = 0,
this.citasHoy = 0,
this.pacientesMasculinos = 11078,
this.pacientesFemeninos = 2634,
this.totalPacientes = 13712,
});
factory DashboardMetricsModel.fromJson(Map<String, dynamic> json) {
return DashboardMetricsModel(
nuevosPacientes: json['nuevos_pacientes'] as int? ?? 0,
consultadosHoy: json['consultados_hoy'] as int? ?? 0,
citasHoy: json['citas_hoy'] as int? ?? 0,
pacientesMasculinos: json['pacientes_masculinos'] as int? ?? 0,
pacientesFemeninos: json['pacientes_femeninos'] as int? ?? 0,
totalPacientes: json['total_pacientes'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'nuevos_pacientes': nuevosPacientes,
'consultados_hoy': consultadosHoy,
'citas_hoy': citasHoy,
'pacientes_masculinos': pacientesMasculinos,
'pacientes_femeninos': pacientesFemeninos,
'total_pacientes': totalPacientes,
};
}
}
@@ -0,0 +1,57 @@
class DoctorInfoModel {
final int? id;
final String nombreDoctor;
final String especialidad;
final String descripEspecialidad;
final String descripGeneral;
const DoctorInfoModel({
this.id,
required this.nombreDoctor,
required this.especialidad,
required this.descripEspecialidad,
required this.descripGeneral,
});
factory DoctorInfoModel.fromJson(Map<String, dynamic> json) {
final doctor = (json['nombre_doctor']?.toString() ?? 'Dr. Luis A. Castro Sosa').trim();
final esp = (json['especialidad']?.toString() ?? 'Cirujano Urólogo').trim();
return DoctorInfoModel(
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id']?.toString() ?? ''),
nombreDoctor: doctor.isEmpty ? 'Dr. Luis A. Castro Sosa' : doctor,
especialidad: esp.isEmpty ? 'Cirujano Urólogo' : esp,
descripEspecialidad: json['descrip_especialidad']?.toString() ?? '',
descripGeneral: json['descrip_general']?.toString() ?? '',
);
}
Map<String, dynamic> toJson({bool includeId = false}) {
final map = <String, dynamic>{
'nombre_doctor': nombreDoctor,
'especialidad': especialidad,
'descrip_especialidad': descripEspecialidad,
'descrip_general': descripGeneral,
};
if (includeId && id != null) {
map['id'] = id;
}
return map;
}
DoctorInfoModel copyWith({
int? id,
String? nombreDoctor,
String? especialidad,
String? descripEspecialidad,
String? descripGeneral,
}) {
return DoctorInfoModel(
id: id ?? this.id,
nombreDoctor: nombreDoctor ?? this.nombreDoctor,
especialidad: especialidad ?? this.especialidad,
descripEspecialidad: descripEspecialidad ?? this.descripEspecialidad,
descripGeneral: descripGeneral ?? this.descripGeneral,
);
}
}
@@ -0,0 +1,109 @@
class PatientSummaryModel {
final int id;
final String nombre;
final String apellido;
final String cedula;
final String telefono;
final String fecReg;
final String fecProxCita;
final String seguro;
final String motCons;
final String estadoEspera;
const PatientSummaryModel({
required this.id,
required this.nombre,
required this.apellido,
required this.cedula,
required this.telefono,
required this.fecReg,
required this.fecProxCita,
required this.seguro,
required this.motCons,
this.estadoEspera = 'no_presente',
});
String get fullName => '$nombre $apellido'.trim();
bool get isNoPresente => estadoEspera == 'no_presente' || estadoEspera.isEmpty;
bool get isEnEspera => estadoEspera == 'en_espera';
bool get isEnConsulta => estadoEspera == 'en_consulta';
bool get isAtendido => estadoEspera == 'atendido';
String get estadoEsperaLabel {
switch (estadoEspera) {
case 'en_espera':
return 'En Sala de Espera';
case 'en_consulta':
return 'En Consulta';
case 'atendido':
return 'Atendido';
case 'no_presente':
default:
return 'No Presente';
}
}
PatientSummaryModel copyWith({
int? id,
String? nombre,
String? apellido,
String? cedula,
String? telefono,
String? fecReg,
String? fecProxCita,
String? seguro,
String? motCons,
String? estadoEspera,
}) {
return PatientSummaryModel(
id: id ?? this.id,
nombre: nombre ?? this.nombre,
apellido: apellido ?? this.apellido,
cedula: cedula ?? this.cedula,
telefono: telefono ?? this.telefono,
fecReg: fecReg ?? this.fecReg,
fecProxCita: fecProxCita ?? this.fecProxCita,
seguro: seguro ?? this.seguro,
motCons: motCons ?? this.motCons,
estadoEspera: estadoEspera ?? this.estadoEspera,
);
}
factory PatientSummaryModel.fromJson(Map<String, dynamic> json) {
int parsedId = 0;
if (json['id'] is int) {
parsedId = json['id'] as int;
} else if (json['id'] != null) {
parsedId = int.tryParse(json['id'].toString()) ?? 0;
}
return PatientSummaryModel(
id: parsedId,
nombre: json['nombre']?.toString() ?? '',
apellido: json['apellido']?.toString() ?? '',
cedula: json['cedula']?.toString() ?? '',
telefono: json['telefono']?.toString() ?? '',
fecReg: json['fec_reg']?.toString() ?? '',
fecProxCita: json['fec_proxcita']?.toString() ?? '',
seguro: json['seguro']?.toString() ?? '',
motCons: json['mot_cons']?.toString() ?? '',
estadoEspera: json['estado_espera']?.toString() ?? 'no_presente',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'nombre': nombre,
'apellido': apellido,
'cedula': cedula,
'telefono': telefono,
'fec_reg': fecReg,
'fec_proxcita': fecProxCita,
'seguro': seguro,
'mot_cons': motCons,
'estado_espera': estadoEspera,
};
}
}
@@ -0,0 +1,188 @@
import '../../../../core/network/api_client.dart';
import '../models/dashboard_metrics_model.dart';
import '../models/doctor_info_model.dart';
import '../models/patient_summary_model.dart';
class DashboardService {
final ApiClient apiClient;
DashboardService({ApiClient? client}) : apiClient = client ?? ApiClient();
String _formatTwoDigits(int n) => n.toString().padLeft(2, '0');
Future<DoctorInfoModel> getDoctorConfig() async {
try {
final response = await apiClient.dio.get('/items/configuracion');
if (response.statusCode == 200 && response.data != null) {
final data = response.data['data'] as Map<String, dynamic>;
return DoctorInfoModel.fromJson(data);
}
return const DoctorInfoModel(
nombreDoctor: 'Dr. Luis A. Castro Sosa',
especialidad: 'Cirujano Urólogo',
descripEspecialidad: '',
descripGeneral: '',
);
} catch (_) {
return const DoctorInfoModel(
nombreDoctor: 'Dr. Luis A. Castro Sosa',
especialidad: 'Cirujano Urólogo',
descripEspecialidad: '',
descripGeneral: '',
);
}
}
Future<DashboardMetricsModel> getDashboardMetrics() async {
try {
final now = DateTime.now();
final day = _formatTwoDigits(now.day);
final month = _formatTwoDigits(now.month);
final year = now.year.toString();
final dmy = '$day/$month/$year';
final iso = '$year-$month-$day';
// Consultas concurrentes optimizadas a Directus
final results = await Future.wait([
// 0: Desglose por sexo
apiClient.dio.get('/items/pacientes?aggregate[count]=*&groupBy[]=sexo'),
// 1: Citas para hoy (condición estricta: fec_proxcita igual a la fecha del día actual)
apiClient.dio.get(
'/items/pacientes?aggregate[count]=*&filter[_or][0][fec_proxcita][_eq]=$dmy&filter[_or][1][fec_proxcita][_starts_with]=$dmy&filter[_or][2][fec_proxcita][_starts_with]=$iso',
),
// 2: Pacientes nuevos registrados hoy
apiClient.dio.get(
'/items/pacientes?aggregate[count]=*&filter[_or][0][fec_reg][_contains]=$dmy&filter[_or][1][fec_reg][_contains]=$iso',
),
// 3: Pacientes consultados hoy
apiClient.dio.get(
'/items/pacientes?aggregate[count]=*&filter[_or][0][fec_ultcon][_contains]=$dmy&filter[_or][1][fec_ultcon][_contains]=$iso',
),
// 4: Total de pacientes
apiClient.dio.get('/items/pacientes?aggregate[count]=*'),
]);
int masc = 11078;
int fem = 2634;
int citasHoy = 0;
int nuevosHoy = 0;
int consultadosHoy = 0;
int total = 13712;
// 0. Desglose por sexo
if (results[0].statusCode == 200 && results[0].data != null) {
final sexoData = results[0].data['data'] as List<dynamic>? ?? [];
for (final item in sexoData) {
final s = item['sexo']?.toString().toLowerCase() ?? '';
final count = int.tryParse(item['count'].toString()) ?? 0;
if (s.contains('masc')) masc = count;
if (s.contains('fem')) fem = count;
}
}
// 1. Citas para hoy
if (results[1].statusCode == 200 && results[1].data != null) {
final data = results[1].data['data'] as List<dynamic>? ?? [];
if (data.isNotEmpty) {
citasHoy = int.tryParse(data[0]['count'].toString()) ?? 0;
}
}
// 2. Nuevos hoy
if (results[2].statusCode == 200 && results[2].data != null) {
final data = results[2].data['data'] as List<dynamic>? ?? [];
if (data.isNotEmpty) {
nuevosHoy = int.tryParse(data[0]['count'].toString()) ?? 0;
}
}
// 3. Consultados hoy
if (results[3].statusCode == 200 && results[3].data != null) {
final data = results[3].data['data'] as List<dynamic>? ?? [];
if (data.isNotEmpty) {
consultadosHoy = int.tryParse(data[0]['count'].toString()) ?? 0;
}
}
// 4. Total pacientes
if (results[4].statusCode == 200 && results[4].data != null) {
final data = results[4].data['data'] as List<dynamic>? ?? [];
if (data.isNotEmpty) {
total = int.tryParse(data[0]['count'].toString()) ?? 13712;
}
}
return DashboardMetricsModel(
nuevosPacientes: nuevosHoy,
consultadosHoy: consultadosHoy,
citasHoy: citasHoy,
pacientesMasculinos: masc,
pacientesFemeninos: fem,
totalPacientes: total,
);
} catch (_) {
return const DashboardMetricsModel();
}
}
Future<List<PatientSummaryModel>> getRecentPatients({int limit = 5}) async {
try {
final response = await apiClient.dio.get(
'/items/pacientes?sort=-id&limit=$limit&fields=id,nombre,apellido,cedula,telefono,fec_reg,fec_proxcita,seguro,mot_cons,estado_espera',
);
if (response.statusCode == 200 && response.data != null) {
final data = response.data['data'] as List<dynamic>? ?? [];
return data
.map((item) => PatientSummaryModel.fromJson(item as Map<String, dynamic>))
.toList();
}
return [];
} catch (_) {
return [];
}
}
Future<List<PatientSummaryModel>> getAppointmentsByDate(DateTime date, {int limit = 100}) async {
try {
final day = _formatTwoDigits(date.day);
final month = _formatTwoDigits(date.month);
final year = date.year.toString();
final dmy = '$day/$month/$year';
final iso = '$year-$month-$day';
final response = await apiClient.dio.get(
'/items/pacientes?filter[_or][0][fec_proxcita][_eq]=$dmy&filter[_or][1][fec_proxcita][_starts_with]=$dmy&filter[_or][2][fec_proxcita][_starts_with]=$iso&sort=-id&limit=$limit&fields=id,nombre,apellido,cedula,telefono,fec_proxcita,mot_cons,estado_espera',
);
final data = response.data?['data'] as List<dynamic>? ?? [];
return data
.map((item) => PatientSummaryModel.fromJson(item as Map<String, dynamic>))
.toList();
} catch (_) {
return [];
}
}
Future<List<PatientSummaryModel>> getTodayAppointments({int limit = 50}) async {
return getAppointmentsByDate(DateTime.now(), limit: limit);
}
Future<bool> updatePatientWaitingStatus(int patientId, String nuevoEstado) async {
try {
final response = await apiClient.dio.patch(
'/items/pacientes/$patientId',
data: {'estado_espera': nuevoEstado},
);
return response.statusCode == 200;
} catch (_) {
return false;
}
}
}
@@ -0,0 +1,90 @@
import 'package:mobx/mobx.dart';
import '../../data/models/dashboard_metrics_model.dart';
import '../../data/models/doctor_info_model.dart';
import '../../data/models/patient_summary_model.dart';
import '../../data/services/dashboard_service.dart';
part 'dashboard_store.g.dart';
// ignore: library_private_types_in_public_api
class DashboardStore = _DashboardStoreBase with _$DashboardStore;
abstract class _DashboardStoreBase with Store {
final DashboardService service;
_DashboardStoreBase({DashboardService? dashboardService})
: service = dashboardService ?? DashboardService();
@observable
bool isLoading = true;
@observable
DoctorInfoModel doctorInfo = const DoctorInfoModel(
nombreDoctor: 'Dr. Luis A. Castro Sosa',
especialidad: 'Cirujano Urólogo',
descripEspecialidad: '',
descripGeneral: '',
);
@observable
DashboardMetricsModel metrics = const DashboardMetricsModel();
@observable
ObservableList<PatientSummaryModel> recentPatients = ObservableList<PatientSummaryModel>();
@observable
ObservableList<PatientSummaryModel> todayAppointments = ObservableList<PatientSummaryModel>();
@observable
String? errorMessage;
int get enEsperaCount =>
todayAppointments.where((a) => a.isEnEspera).length;
@action
Future<void> loadDashboardData() async {
isLoading = true;
errorMessage = null;
try {
final config = await service.getDoctorConfig();
final metricsData = await service.getDashboardMetrics();
final recents = await service.getRecentPatients();
final appts = await service.getTodayAppointments();
runInAction(() {
doctorInfo = config;
metrics = metricsData;
recentPatients = ObservableList.of(recents);
todayAppointments = ObservableList.of(appts);
isLoading = false;
});
} catch (e) {
runInAction(() {
errorMessage = e.toString();
isLoading = false;
});
}
}
@action
Future<bool> changeWaitingStatus(int patientId, String nuevoEstado) async {
final success = await service.updatePatientWaitingStatus(patientId, nuevoEstado);
if (success) {
runInAction(() {
final index = todayAppointments.indexWhere((p) => p.id == patientId);
if (index != -1) {
todayAppointments[index] =
todayAppointments[index].copyWith(estadoEspera: nuevoEstado);
}
final recentIndex = recentPatients.indexWhere((p) => p.id == patientId);
if (recentIndex != -1) {
recentPatients[recentIndex] =
recentPatients[recentIndex].copyWith(estadoEspera: nuevoEstado);
}
});
}
return success;
}
}
@@ -0,0 +1,153 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dashboard_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$DashboardStore on _DashboardStoreBase, Store {
late final _$isLoadingAtom = Atom(
name: '_DashboardStoreBase.isLoading',
context: context,
);
@override
bool get isLoading {
_$isLoadingAtom.reportRead();
return super.isLoading;
}
@override
set isLoading(bool value) {
_$isLoadingAtom.reportWrite(value, super.isLoading, () {
super.isLoading = value;
});
}
late final _$doctorInfoAtom = Atom(
name: '_DashboardStoreBase.doctorInfo',
context: context,
);
@override
DoctorInfoModel get doctorInfo {
_$doctorInfoAtom.reportRead();
return super.doctorInfo;
}
@override
set doctorInfo(DoctorInfoModel value) {
_$doctorInfoAtom.reportWrite(value, super.doctorInfo, () {
super.doctorInfo = value;
});
}
late final _$metricsAtom = Atom(
name: '_DashboardStoreBase.metrics',
context: context,
);
@override
DashboardMetricsModel get metrics {
_$metricsAtom.reportRead();
return super.metrics;
}
@override
set metrics(DashboardMetricsModel value) {
_$metricsAtom.reportWrite(value, super.metrics, () {
super.metrics = value;
});
}
late final _$recentPatientsAtom = Atom(
name: '_DashboardStoreBase.recentPatients',
context: context,
);
@override
ObservableList<PatientSummaryModel> get recentPatients {
_$recentPatientsAtom.reportRead();
return super.recentPatients;
}
@override
set recentPatients(ObservableList<PatientSummaryModel> value) {
_$recentPatientsAtom.reportWrite(value, super.recentPatients, () {
super.recentPatients = value;
});
}
late final _$todayAppointmentsAtom = Atom(
name: '_DashboardStoreBase.todayAppointments',
context: context,
);
@override
ObservableList<PatientSummaryModel> get todayAppointments {
_$todayAppointmentsAtom.reportRead();
return super.todayAppointments;
}
@override
set todayAppointments(ObservableList<PatientSummaryModel> value) {
_$todayAppointmentsAtom.reportWrite(value, super.todayAppointments, () {
super.todayAppointments = value;
});
}
late final _$errorMessageAtom = Atom(
name: '_DashboardStoreBase.errorMessage',
context: context,
);
@override
String? get errorMessage {
_$errorMessageAtom.reportRead();
return super.errorMessage;
}
@override
set errorMessage(String? value) {
_$errorMessageAtom.reportWrite(value, super.errorMessage, () {
super.errorMessage = value;
});
}
late final _$loadDashboardDataAsyncAction = AsyncAction(
'_DashboardStoreBase.loadDashboardData',
context: context,
);
@override
Future<void> loadDashboardData() {
return _$loadDashboardDataAsyncAction.run(() => super.loadDashboardData());
}
late final _$changeWaitingStatusAsyncAction = AsyncAction(
'_DashboardStoreBase.changeWaitingStatus',
context: context,
);
@override
Future<bool> changeWaitingStatus(int patientId, String nuevoEstado) {
return _$changeWaitingStatusAsyncAction.run(
() => super.changeWaitingStatus(patientId, nuevoEstado),
);
}
@override
String toString() {
return '''
isLoading: ${isLoading},
doctorInfo: ${doctorInfo},
metrics: ${metrics},
recentPatients: ${recentPatients},
todayAppointments: ${todayAppointments},
errorMessage: ${errorMessage}
''';
}
}
@@ -0,0 +1,960 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/medical_modal.dart';
import '../../../patient/presentation/views/patient_modal_view.dart';
import '../../data/models/patient_summary_model.dart';
import '../../data/services/dashboard_service.dart';
class AppointmentsModalView extends StatefulWidget {
final DateTime? initialDate;
final DashboardService? service;
final bool isSecretary;
final void Function(int patientId, String newStatus)? onStatusChanged;
const AppointmentsModalView({
super.key,
this.initialDate,
this.service,
this.isSecretary = false,
this.onStatusChanged,
});
@override
State<AppointmentsModalView> createState() => _AppointmentsModalViewState();
}
class _AppointmentsModalViewState extends State<AppointmentsModalView> {
late final DashboardService _service;
late DateTime _selectedDate;
bool _isLoading = false;
String? _errorMessage;
List<PatientSummaryModel> _appointments = [];
String _waitingFilter = 'todos'; // 'todos', 'en_espera', 'en_consulta', 'atendido', 'no_presente'
@override
void initState() {
super.initState();
_service = widget.service ?? DashboardService();
_selectedDate = widget.initialDate ?? DateTime.now();
_fetchAppointments(_selectedDate);
}
Future<void> _handleStatusChange(PatientSummaryModel item, String newStatus) async {
final success = await _service.updatePatientWaitingStatus(item.id, newStatus);
if (success && mounted) {
setState(() {
final index = _appointments.indexWhere((p) => p.id == item.id);
if (index != -1) {
_appointments[index] = _appointments[index].copyWith(estadoEspera: newStatus);
}
});
if (widget.onStatusChanged != null) {
widget.onStatusChanged!(item.id, newStatus);
}
}
}
List<PatientSummaryModel> get _filteredAppointments {
if (_waitingFilter == 'todos') return _appointments;
if (_waitingFilter == 'en_espera') return _appointments.where((a) => a.isEnEspera).toList();
if (_waitingFilter == 'en_consulta') return _appointments.where((a) => a.isEnConsulta).toList();
if (_waitingFilter == 'atendido') return _appointments.where((a) => a.isAtendido).toList();
if (_waitingFilter == 'no_presente') return _appointments.where((a) => a.isNoPresente).toList();
return _appointments;
}
bool _isToday(DateTime date) {
final now = DateTime.now();
return date.year == now.year && date.month == now.month && date.day == now.day;
}
String _formatDate(DateTime date) {
final day = date.day.toString().padLeft(2, '0');
final month = date.month.toString().padLeft(2, '0');
final year = date.year.toString();
return '$day/$month/$year';
}
String _formatDateHuman(DateTime date) {
const weekdays = [
'Lunes',
'Martes',
'Miércoles',
'Jueves',
'Viernes',
'Sábado',
'Domingo'
];
const months = [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre'
];
final weekdayName = weekdays[date.weekday - 1];
final monthName = months[date.month - 1];
return '$weekdayName, ${date.day} de $monthName de ${date.year}';
}
Future<void> _fetchAppointments(DateTime date) async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final results = await _service.getAppointmentsByDate(date);
if (mounted) {
setState(() {
_appointments = results;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Error al cargar las citas para la fecha seleccionada: $e';
_isLoading = false;
});
}
}
}
void _onDateSelected(DateTime newDate) {
if (newDate.year == _selectedDate.year &&
newDate.month == _selectedDate.month &&
newDate.day == _selectedDate.day) {
return;
}
setState(() {
_selectedDate = newDate;
});
_fetchAppointments(newDate);
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime(2050),
helpText: 'SELECCIONAR FECHA DE CITAS',
confirmText: 'FILTRAR',
cancelText: 'CANCELAR',
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
onSurface: AppColors.textPrimary,
),
),
child: child!,
);
},
);
if (picked != null) {
_onDateSelected(picked);
}
}
void _openPatient(int patientId) {
showMedicalModal(
context: context,
builder: (_) => PatientModalView(
patientId: patientId,
isSecretary: widget.isSecretary,
onSaved: (_) {
_fetchAppointments(_selectedDate);
if (widget.onStatusChanged != null) {
widget.onStatusChanged!(patientId, 'atendido');
}
},
),
);
}
@override
Widget build(BuildContext context) {
final isFilteringToday = _isToday(_selectedDate);
return MedicalModalDialog(
titleIcon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.event_available_rounded,
color: AppColors.warning,
size: 22,
),
),
title: 'Agenda de Citas Médicas',
subtitle: const Text(
'Consulte y gestione los pacientes citados por fecha de consulta',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
maxWidth: 820,
maxHeight: 700,
bottomBar: _buildBottomBar(),
child: Column(
children: [
// Barra de Filtro de Fecha
_buildDateFilterBar(isFilteringToday),
// Divisor
const Divider(height: 1, color: Color(0xFFE2E8F0)),
// Contenido Principal (Lista de Citas)
Expanded(
child: _buildBody(isFilteringToday),
),
],
),
);
}
Widget _buildDateFilterBar(bool isFilteringToday) {
final filterText = isFilteringToday
? 'Hoy (${_formatDate(_selectedDate)})'
: _formatDate(_selectedDate);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
color: Color(0xFFF8FAFC),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 12,
runSpacing: 8,
children: [
// Indicador dinámico de filtro activo (Hoy vs Fecha Específica)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isFilteringToday
? AppColors.primaryContainer
: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isFilteringToday
? AppColors.primary.withValues(alpha: 0.3)
: const Color(0xFF93C5FD),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isFilteringToday ? Icons.today_rounded : Icons.date_range_rounded,
size: 16,
color: isFilteringToday ? AppColors.primary : const Color(0xFF2563EB),
),
const SizedBox(width: 8),
Text.rich(
TextSpan(
children: [
const TextSpan(
text: 'Filtro: ',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
TextSpan(
text: filterText,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: isFilteringToday
? AppColors.primary
: const Color(0xFF1D4ED8),
),
),
],
),
),
],
),
),
// Botones de Navegación Rápida
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Botón Día Anterior
IconButton(
icon: const Icon(Icons.chevron_left_rounded, size: 20),
tooltip: 'Día anterior',
splashRadius: 18,
onPressed: _isLoading
? null
: () => _onDateSelected(_selectedDate.subtract(const Duration(days: 1))),
),
// Botón acceso directo a "Hoy"
InkWell(
onTap: _isLoading || isFilteringToday
? null
: () => _onDateSelected(DateTime.now()),
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isFilteringToday
? AppColors.primary
: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isFilteringToday
? AppColors.primary
: const Color(0xFFCBD5E1),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.refresh_rounded,
size: 14,
color: isFilteringToday ? Colors.white : AppColors.textPrimary,
),
const SizedBox(width: 4),
Text(
'Hoy',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isFilteringToday ? Colors.white : AppColors.textPrimary,
),
),
],
),
),
),
// Botón Día Siguiente
IconButton(
icon: const Icon(Icons.chevron_right_rounded, size: 20),
tooltip: 'Día siguiente',
splashRadius: 18,
onPressed: _isLoading
? null
: () => _onDateSelected(_selectedDate.add(const Duration(days: 1))),
),
const SizedBox(width: 6),
// Botón Selector de Fecha
ElevatedButton.icon(
onPressed: _isLoading ? null : _pickDate,
icon: const Icon(Icons.calendar_month_outlined, size: 16),
label: const Text(
'Cambiar Fecha',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.textPrimary,
elevation: 0,
side: const BorderSide(color: Color(0xFFCBD5E1)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
),
),
],
),
],
),
const SizedBox(height: 6),
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 12,
runSpacing: 6,
children: [
Text(
_formatDateHuman(_selectedDate),
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textMuted,
fontWeight: FontWeight.w500,
),
),
// Píldoras de Filtro de Sala de Espera
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_buildFilterChip('todos', 'Todos (${_appointments.length})', null),
_buildFilterChip(
'en_espera',
'En Espera (${_appointments.where((a) => a.isEnEspera).length})',
const Color(0xFF15803D),
),
_buildFilterChip(
'en_consulta',
'En Consulta (${_appointments.where((a) => a.isEnConsulta).length})',
const Color(0xFF0369A1),
),
_buildFilterChip(
'atendido',
'Atendidos (${_appointments.where((a) => a.isAtendido).length})',
const Color(0xFF64748B),
),
],
),
],
),
],
),
);
}
Widget _buildFilterChip(String value, String label, Color? highlightColor) {
final isSelected = _waitingFilter == value;
return InkWell(
onTap: () {
setState(() {
_waitingFilter = value;
});
},
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: isSelected
? (highlightColor ?? AppColors.primary).withValues(alpha: 0.15)
: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? (highlightColor ?? AppColors.primary)
: const Color(0xFFE2E8F0),
width: isSelected ? 1.5 : 1,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? (highlightColor ?? AppColors.primary)
: AppColors.textSecondary,
),
),
),
);
}
Widget _buildBody(bool isFilteringToday) {
if (_isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.all(40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 14),
Text(
'Consultando agenda médica...',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
],
),
),
);
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline_rounded, color: AppColors.error, size: 40),
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: AppColors.error),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => _fetchAppointments(_selectedDate),
icon: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Reintentar'),
),
],
),
),
);
}
if (_appointments.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(50),
),
child: const Icon(
Icons.event_busy_rounded,
size: 42,
color: AppColors.textMuted,
),
),
const SizedBox(height: 16),
Text(
isFilteringToday
? 'No hay citas programadas para el día de hoy'
: 'No hay citas registradas para el ${_formatDate(_selectedDate)}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6),
Text(
isFilteringToday
? 'Puede consultar otra fecha utilizando el selector de calendario superior.'
: 'Intente seleccionar otro día o presione "Hoy" para volver a la agenda actual.',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
),
);
}
final list = _filteredAppointments;
if (list.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.filter_list_off_rounded, size: 38, color: AppColors.textMuted),
const SizedBox(height: 12),
Text(
'No hay pacientes con el filtro seleccionado',
style: const TextStyle(fontWeight: FontWeight.w600, color: AppColors.textSecondary),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => setState(() => _waitingFilter = 'todos'),
child: const Text('Mostrar todos'),
),
],
),
),
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
itemCount: list.length,
separatorBuilder: (_, index) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final item = list[index];
return _buildAppointmentTile(item);
},
);
}
Widget _buildAppointmentTile(PatientSummaryModel item) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: item.isEnEspera ? const Color(0xFF86EFAC) : const Color(0xFFE2E8F0),
width: item.isEnEspera ? 1.5 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: item.id > 0 ? () => _openPatient(item.id) : null,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
// Icono de Cita
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: item.isEnEspera
? const Color(0xFFDCFCE7)
: AppColors.teal.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
item.isEnEspera ? Icons.chair_rounded : Icons.calendar_today_rounded,
color: item.isEnEspera ? const Color(0xFF15803D) : AppColors.teal,
size: 20,
),
),
const SizedBox(width: 14),
// Datos del Paciente y Motivo
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.fullName,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
),
if (item.cedula.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.cedula,
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
if (item.telefono.isNotEmpty) ...[
const Icon(Icons.phone_outlined, size: 13, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
item.telefono,
style: const TextStyle(fontSize: 11.5, color: AppColors.textSecondary),
),
const SizedBox(width: 12),
],
const Icon(Icons.medical_information_outlined, size: 13, color: AppColors.textMuted),
const SizedBox(width: 4),
Expanded(
child: Text(
item.motCons.isNotEmpty
? item.motCons.replaceAll('\n', ' ').trim()
: 'Consulta médica programada',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11.5, color: AppColors.textMuted),
),
),
],
),
],
),
),
const SizedBox(width: 12),
// Acciones de Sala de Espera / Estado
_buildAppointmentActions(item),
const SizedBox(width: 8),
// Botón Ver Ficha / Abrir Historial
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.arrow_forward_ios_rounded,
color: AppColors.primary,
size: 14,
),
),
],
),
),
),
),
);
}
Widget _buildAppointmentActions(PatientSummaryModel item) {
const double uniformHeight = 28.0;
const double uniformRadius = 6.0;
if (item.isEnEspera) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(uniformRadius),
border: Border.all(color: const Color(0xFF86EFAC)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hourglass_top_rounded, size: 12, color: Color(0xFF15803D)),
SizedBox(width: 4),
Text(
'En Espera',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF15803D),
),
),
],
),
),
if (!widget.isSecretary) ...[
const SizedBox(width: 6),
SizedBox(
height: uniformHeight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(uniformRadius)),
visualDensity: VisualDensity.compact,
),
onPressed: () async {
await _handleStatusChange(item, 'en_consulta');
_openPatient(item.id);
},
icon: const Icon(Icons.arrow_forward_rounded, size: 12),
label: const Text('Atender', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700)),
),
),
] else ...[
const SizedBox(width: 4),
Tooltip(
message: 'Quitar de la sala de espera',
child: InkWell(
onTap: () => _handleStatusChange(item, 'no_presente'),
borderRadius: BorderRadius.circular(uniformRadius),
child: Container(
height: uniformHeight,
width: uniformHeight,
alignment: Alignment.center,
child: const Icon(Icons.close_rounded, size: 15, color: AppColors.textMuted),
),
),
),
],
],
);
}
if (item.isEnConsulta) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => _openPatient(item.id),
borderRadius: BorderRadius.circular(uniformRadius),
child: Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: const Color(0xFFE0F2FE),
borderRadius: BorderRadius.circular(uniformRadius),
border: Border.all(color: const Color(0xFF7DD3FC)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.medical_services_rounded, size: 12, color: Color(0xFF0369A1)),
SizedBox(width: 4),
Text(
'En Consulta',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF0369A1)),
),
],
),
),
),
if (!widget.isSecretary) ...[
const SizedBox(width: 6),
SizedBox(
height: uniformHeight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0284C7),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(uniformRadius)),
visualDensity: VisualDensity.compact,
),
onPressed: () => _handleStatusChange(item, 'atendido'),
icon: const Icon(Icons.check_rounded, size: 13),
label: const Text('Finalizar', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700)),
),
),
],
],
);
}
if (item.isAtendido) {
return Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 9),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(uniformRadius),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_rounded, size: 12, color: Color(0xFF64748B)),
SizedBox(width: 4),
Text(
'Atendido',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF64748B)),
),
],
),
);
}
// no_presente
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.isSecretary)
SizedBox(
height: uniformHeight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(uniformRadius)),
visualDensity: VisualDensity.compact,
),
onPressed: () => _handleStatusChange(item, 'en_espera'),
icon: const Icon(Icons.how_to_reg_rounded, size: 13),
label: const Text('Llegó', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700)),
),
),
],
);
}
Widget _buildBottomBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
color: Color(0xFFF8FAFC),
border: Border(top: BorderSide(color: Color(0xFFE2E8F0))),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.keyboard_outlined, size: 12, color: AppColors.textMuted),
SizedBox(width: 4),
Text(
'ESC para salir',
style: TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500),
),
],
),
),
const Spacer(),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text(
'Cerrar',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
),
],
),
);
}
}
@@ -0,0 +1,318 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../auth/data/services/auth_service.dart';
import '../store/dashboard_store.dart';
import '../widgets/dashboard_app_bar.dart';
import '../widgets/dashboard_metrics_cards.dart';
import '../widgets/quick_actions_dropdown.dart';
import '../widgets/recent_patients_card.dart';
import '../widgets/today_appointments_card.dart';
import '../../../patient_search/presentation/views/patient_search_modal_view.dart';
import '../../../patient/presentation/views/patient_modal_view.dart';
import '../../../config/presentation/views/config_modal_view.dart';
import 'appointments_modal_view.dart';
import '../../../../core/widgets/medical_modal.dart';
class DashboardView extends StatefulWidget {
final DashboardStore? store;
const DashboardView({super.key, this.store});
@override
State<DashboardView> createState() => _DashboardViewState();
}
class _DashboardViewState extends State<DashboardView> {
late final DashboardStore _store;
bool _isSecretary = false;
@override
void initState() {
super.initState();
_store = widget.store ?? DashboardStore();
_store.loadDashboardData();
_loadUserRole();
}
Future<void> _loadUserRole() async {
final session = await AuthService().loadSavedSession();
if (mounted && session != null) {
setState(() {
_isSecretary = session.isSecretary;
});
}
}
void _handleLogout() {
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Row(
children: [
Icon(Icons.logout_rounded, color: AppColors.error),
SizedBox(width: 10),
Text('Cerrar Sesión', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
content: const Text('¿Está seguro de que desea salir del sistema Hidalyx Med?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.error,
foregroundColor: Colors.white,
),
onPressed: () async {
Navigator.of(dialogContext).pop();
await AuthService().logout();
if (mounted) {
context.go('/login');
}
},
child: const Text('Salir'),
),
],
),
);
}
void _handleQuickAction(QuickActionType action) {
switch (action) {
case QuickActionType.nuevoPaciente:
showMedicalModal(
context: context,
builder: (_) => PatientModalView(
isSecretary: _isSecretary,
onSaved: (_) {
_store.loadDashboardData();
},
),
);
break;
case QuickActionType.buscarPaciente:
showMedicalModal(
context: context,
builder: (_) => PatientSearchModalView(isSecretary: _isSecretary),
);
break;
case QuickActionType.configuracion:
showMedicalModal(
context: context,
builder: (_) => ConfigModalView(
onSaved: (updatedConfig) {
_store.doctorInfo = updatedConfig;
},
),
);
break;
}
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: DashboardAppBar(
doctorName: _store.doctorInfo.nombreDoctor,
specialty: _store.doctorInfo.especialidad,
isSecretary: _isSecretary,
onLogoutPressed: _handleLogout,
onQuickActionSelected: _handleQuickAction,
),
body: _store.isLoading
? const Center(
child: CircularProgressIndicator(color: AppColors.primary),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1400),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Encabezado de Bienvenida
Row(
children: [
Container(
width: 4,
height: 28,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isSecretary ? 'Recepción de Pacientes' : 'Panel Central - Consultorio',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
_isSecretary
? 'Módulo de recepción, registro y sala de espera.'
: 'Bienvenido, ${_store.doctorInfo.nombreDoctor}. Sistema clínico listo.',
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
),
),
],
),
),
// Indicador en Vivo de Sala de Espera
if (_store.enEsperaCount > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF86EFAC)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.chair_rounded,
size: 18,
color: Color(0xFF15803D),
),
const SizedBox(width: 8),
Text(
'${_store.enEsperaCount} en Sala de Espera',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Color(0xFF15803D),
),
),
],
),
),
],
),
const SizedBox(height: 24),
// Mensaje de Error / Reintento si aplica
if (_store.errorMessage != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.warning.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.info_outline_rounded, color: AppColors.warning),
const SizedBox(width: 12),
Expanded(
child: Text(
'Aviso de sincronización: ${_store.errorMessage}',
style: const TextStyle(color: AppColors.textPrimary, fontSize: 13),
),
),
TextButton(
onPressed: () => _store.loadDashboardData(),
child: const Text('Reintentar'),
),
],
),
),
const SizedBox(height: 16),
],
// Las 4 Tarjetas de Métricas Solicitadas
DashboardMetricsCards(metrics: _store.metrics),
const SizedBox(height: 24),
// Paneles de Detalle: Pacientes Nuevos y Citas para Hoy
LayoutBuilder(
builder: (context, constraints) {
final recentCard = RecentPatientsCard(
patients: _store.recentPatients,
);
final appointmentsCard = TodayAppointmentsCard(
appointments: _store.todayAppointments,
isSecretary: _isSecretary,
onStatusChange: (patient, newStatus) async {
await _store.changeWaitingStatus(patient.id, newStatus);
},
onSchedulePressed: () {
showMedicalModal(
context: context,
builder: (_) => AppointmentsModalView(
isSecretary: _isSecretary,
onStatusChanged: (patientId, newStatus) {
_store.changeWaitingStatus(patientId, newStatus);
},
),
);
},
onPatientTap: (patient) {
if (patient.id > 0) {
showMedicalModal(
context: context,
builder: (_) => PatientModalView(
patientId: patient.id,
isSecretary: _isSecretary,
onSaved: (_) => _store.loadDashboardData(),
),
);
}
},
);
if (constraints.maxWidth >= 1000) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: recentCard),
const SizedBox(width: 16),
Expanded(child: appointmentsCard),
],
),
);
}
return Column(
children: [
recentCard,
const SizedBox(height: 16),
appointmentsCard,
],
);
},
),
],
),
),
),
),
);
},
);
}
}
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../../../core/theme/app_colors.dart';
import '../widgets/quick_actions_dropdown.dart';
class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
final String doctorName;
final String specialty;
final bool isSecretary;
final VoidCallback? onLogoutPressed;
final ValueChanged<QuickActionType>? onQuickActionSelected;
const DashboardAppBar({
super.key,
required this.doctorName,
required this.specialty,
this.isSecretary = false,
this.onLogoutPressed,
this.onQuickActionSelected,
});
@override
Size get preferredSize => const Size.fromHeight(70);
@override
Widget build(BuildContext context) {
return Container(
height: preferredSize.height,
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(
bottom: BorderSide(color: AppColors.border, width: 1),
),
),
child: SafeArea(
bottom: false,
child: Row(
children: [
// 1. Parte Izquierda: Nombre del Doctor y Especialidad (Flexible)
Expanded(
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.medical_services_rounded,
color: AppColors.primary,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
doctorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 15,
color: AppColors.textPrimary,
),
),
Row(
children: [
Flexible(
child: Text(
specialty,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
),
if (isSecretary) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: const Color(0xFF00A65A).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'RECEPCIÓN',
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.w700,
color: Color(0xFF00A65A),
letterSpacing: 0.4,
),
),
),
],
],
),
],
),
),
],
),
),
// 2. Parte Central: Logo de Hidalyx
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SvgPicture.asset(
'assets/images/hidalyx_logo_blue.svg',
height: 32,
fit: BoxFit.contain,
),
),
// 3. Parte Derecha: Dropdown de Acciones Rápidas + Botón Cerrar Sesión
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
QuickActionsDropdown(
onActionSelected: onQuickActionSelected,
isSecretary: isSecretary,
),
const SizedBox(width: 10),
Tooltip(
message: 'Cerrar sesión',
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onLogoutPressed,
child: Container(
height: 36,
width: 36,
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: const Icon(
Icons.logout_rounded,
color: AppColors.textSecondary,
size: 18,
),
),
),
),
),
],
),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,266 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../data/models/dashboard_metrics_model.dart';
class DashboardMetricsCards extends StatelessWidget {
final DashboardMetricsModel metrics;
const DashboardMetricsCards({super.key, required this.metrics});
String _formatNumber(int n) {
return n.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
}
@override
Widget build(BuildContext context) {
final cards = [
// 1. Total de Nuevos Pacientes
_buildMetricCard(
title: 'Total Nuevos Pacientes',
value: _formatNumber(metrics.nuevosPacientes),
subtitle: 'Registrados en la plataforma',
icon: Icons.person_add_alt_1_rounded,
iconColor: AppColors.primary,
bgColor: AppColors.primaryContainer,
),
// 2. Total de Consultados Hoy
_buildMetricCard(
title: 'Total Consultados Hoy',
value: _formatNumber(metrics.consultadosHoy),
subtitle: 'Atenciones médicas realizadas',
icon: Icons.assignment_turned_in_rounded,
iconColor: AppColors.teal,
bgColor: AppColors.teal.withValues(alpha: 0.12),
),
// 3. Total de Citas para Hoy
_buildMetricCard(
title: 'Total Citas para Hoy',
value: _formatNumber(metrics.citasHoy),
subtitle: 'Pacientes agendados',
icon: Icons.event_available_rounded,
iconColor: AppColors.warning,
bgColor: AppColors.warning.withValues(alpha: 0.12),
),
// 4. Total de Pacientes por Sexo
_buildGenderCard(),
];
return LayoutBuilder(
builder: (context, constraints) {
const spacing = 16.0;
// Distribución equitativa en 4 columnas (pantallas amplias)
if (constraints.maxWidth >= 1000) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: cards[0]),
const SizedBox(width: spacing),
Expanded(child: cards[1]),
const SizedBox(width: spacing),
Expanded(child: cards[2]),
const SizedBox(width: spacing),
Expanded(child: cards[3]),
],
),
);
}
// Distribución equitativa en 2 columnas (pantallas medianas)
if (constraints.maxWidth >= 550) {
return Column(
children: [
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: cards[0]),
const SizedBox(width: spacing),
Expanded(child: cards[1]),
],
),
),
const SizedBox(height: spacing),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: cards[2]),
const SizedBox(width: spacing),
Expanded(child: cards[3]),
],
),
),
],
);
}
// 1 columna ocupando 100% del ancho (pantallas móviles / compactas)
return Column(
children: [
cards[0],
const SizedBox(height: spacing),
cards[1],
const SizedBox(height: spacing),
cards[2],
const SizedBox(height: spacing),
cards[3],
],
);
},
);
}
Widget _buildMetricCard({
required String title,
String? value,
Widget? valueWidget,
required String subtitle,
required IconData icon,
required Color iconColor,
required Color bgColor,
}) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
valueWidget ??
Text(
value ?? '',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
),
],
),
);
}
Widget _buildGenderCard() {
final total = metrics.pacientesMasculinos + metrics.pacientesFemeninos;
final mascPct = total > 0 ? ((metrics.pacientesMasculinos / total) * 100).toInt() : 81;
final femPct = total > 0 ? ((metrics.pacientesFemeninos / total) * 100).toInt() : 19;
return _buildMetricCard(
title: 'Pacientes por Sexo',
valueWidget: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
_formatNumber(metrics.pacientesMasculinos),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
const Text(
' M',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
const SizedBox(width: 8),
const Text(
'/',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: AppColors.border,
),
),
const SizedBox(width: 8),
Text(
_formatNumber(metrics.pacientesFemeninos),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Color(0xFFEC4899),
),
),
const Text(
' F',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFFEC4899),
),
),
],
),
),
subtitle: '$mascPct% Masc · $femPct% Fem',
icon: Icons.people_alt_rounded,
iconColor: AppColors.secondary,
bgColor: AppColors.secondary.withValues(alpha: 0.12),
);
}
}
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
enum QuickActionType {
nuevoPaciente,
buscarPaciente,
configuracion,
}
typedef QuickActionBar = QuickActionsDropdown;
class QuickActionsDropdown extends StatelessWidget {
final ValueChanged<QuickActionType>? onActionSelected;
final bool isSecretary;
const QuickActionsDropdown({
super.key,
this.onActionSelected,
this.isSecretary = false,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// 1. Botón Buscar Paciente (Acción Secundaria / Buscador Rápido)
Tooltip(
message: 'Buscar paciente por nombre, cédula o teléfono',
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => onActionSelected?.call(QuickActionType.buscarPaciente),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search_rounded,
color: AppColors.textSecondary,
size: 17,
),
SizedBox(width: 7),
Text(
'Buscar Paciente',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
],
),
),
),
),
),
const SizedBox(width: 8),
// 2. Botón Nuevo Paciente (Acción Primaria Destacada de 1 clic)
Tooltip(
message: 'Registrar nuevo paciente',
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => onActionSelected?.call(QuickActionType.nuevoPaciente),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.22),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.person_add_rounded,
color: Colors.white,
size: 17,
),
SizedBox(width: 7),
Text(
'Nuevo Paciente',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
),
),
// 3. Botón Configuración (Solo Doctor / Administrador, oculto para Secretaria)
if (!isSecretary) ...[
const SizedBox(width: 8),
Tooltip(
message: 'Configuración del Consultorio',
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => onActionSelected?.call(QuickActionType.configuracion),
child: Container(
height: 36,
width: 36,
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: const Icon(
Icons.settings_outlined,
color: AppColors.textSecondary,
size: 18,
),
),
),
),
),
],
],
);
}
}
@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../data/models/patient_summary_model.dart';
class RecentPatientsCard extends StatelessWidget {
final List<PatientSummaryModel> patients;
const RecentPatientsCard({
super.key,
required this.patients,
});
String _getInitials(String name, String lastName) {
final n = name.trim().isNotEmpty ? name.trim()[0] : '';
final l = lastName.trim().isNotEmpty ? lastName.trim()[0] : '';
final res = '$n$l'.toUpperCase();
return res.isNotEmpty ? res : 'P';
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Encabezado
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.person_add_alt_1_rounded,
color: AppColors.primary,
size: 20,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pacientes Nuevos',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
'Últimos registros ingresados al sistema',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
const SizedBox(height: 18),
const Divider(color: AppColors.divider, height: 1),
const SizedBox(height: 14),
// Contenido con Scroll Interno delimitado
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 330),
child: Scrollbar(
child: SingleChildScrollView(
child: patients.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Column(
children: [
Icon(Icons.people_outline_rounded, size: 40, color: AppColors.textMuted),
SizedBox(height: 8),
Text(
'No se encontraron pacientes recientes',
style: TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
],
),
),
)
: Padding(
padding: const EdgeInsets.only(right: 6),
child: Column(
children: [
for (var i = 0; i < patients.length; i++) ...[
if (i > 0) const Divider(color: AppColors.divider, height: 16),
Row(
children: [
// Avatar con iniciales
CircleAvatar(
radius: 19,
backgroundColor: AppColors.primaryContainer,
child: Text(
_getInitials(patients[i].nombre, patients[i].apellido),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
const SizedBox(width: 14),
// Nombre y Detalles
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
patients[i].fullName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Wrap(
spacing: 8,
runSpacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (patients[i].telefono.isNotEmpty)
Text(
patients[i].telefono,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
),
),
Text(
patients[i].cedula.isNotEmpty
? patients[i].cedula
: (patients[i].seguro.isNotEmpty ? patients[i].seguro : 'Particular'),
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
],
),
),
const SizedBox(width: 10),
// Badge de Fecha de Registro
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.calendar_today_rounded,
size: 11,
color: AppColors.textSecondary,
),
const SizedBox(width: 5),
Text(
patients[i].fecReg.isNotEmpty
? patients[i].fecReg.split(' ').first
: 'Reciente',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
],
],
),
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,470 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../data/models/patient_summary_model.dart';
class TodayAppointmentsCard extends StatelessWidget {
final List<PatientSummaryModel> appointments;
final VoidCallback? onSchedulePressed;
final ValueChanged<PatientSummaryModel>? onPatientTap;
final bool isSecretary;
final Future<void> Function(PatientSummaryModel patient, String newStatus)? onStatusChange;
const TodayAppointmentsCard({
super.key,
required this.appointments,
this.onSchedulePressed,
this.onPatientTap,
this.isSecretary = false,
this.onStatusChange,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Encabezado
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.event_available_rounded,
color: AppColors.warning,
size: 20,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Citas para Hoy',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
'Agenda y turnos de consulta médica',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
if (onSchedulePressed != null)
TextButton(
onPressed: onSchedulePressed,
child: const Text(
'Ver todos',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
),
],
),
const SizedBox(height: 18),
const Divider(color: AppColors.divider, height: 1),
const SizedBox(height: 14),
// Contenido de Citas con Scroll Interno delimitado
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 330),
child: Scrollbar(
child: SingleChildScrollView(
child: appointments.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.event_note_rounded,
size: 40,
color: AppColors.textMuted.withValues(alpha: 0.6),
),
const SizedBox(height: 8),
const Text(
'No hay citas registradas para el día de hoy',
style: TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
const SizedBox(height: 4),
const Text(
'Los nuevos turnos agendados aparecerán aquí',
style: TextStyle(color: AppColors.textMuted, fontSize: 11),
),
],
),
),
)
: Padding(
padding: const EdgeInsets.only(right: 6),
child: Column(
children: [
for (var i = 0; i < appointments.length; i++) ...[
if (i > 0) const Divider(color: AppColors.divider, height: 16),
Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onPatientTap != null && appointments[i].id > 0
? () => onPatientTap!(appointments[i])
: null,
borderRadius: BorderRadius.circular(10),
hoverColor: AppColors.primary.withValues(alpha: 0.05),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6),
child: Row(
children: [
// Icono de Cita
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: AppColors.teal.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.access_time_rounded,
color: AppColors.teal,
size: 20,
),
),
const SizedBox(width: 14),
// Paciente y Motivo de Consulta
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appointments[i].fullName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Wrap(
spacing: 8,
runSpacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (appointments[i].telefono.isNotEmpty)
Text(
appointments[i].telefono,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
),
),
Text(
appointments[i].motCons.isNotEmpty
? appointments[i].motCons.replaceAll('\n', ' ').trim()
: 'Consulta de rutina',
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
],
),
),
const SizedBox(width: 10),
// Estado de Sala de Espera y Acciones Rápidas
_buildStatusAndAction(context, appointments[i]),
],
),
),
),
),
],
],
),
),
),
),
),
],
),
);
}
Widget _buildStatusAndAction(BuildContext context, PatientSummaryModel patient) {
const double uniformHeight = 28.0;
const double uniformRadius = 6.0;
if (patient.isEnEspera) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Badge En Espera
Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(uniformRadius),
border: Border.all(color: const Color(0xFF86EFAC)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hourglass_top_rounded, size: 12, color: Color(0xFF15803D)),
SizedBox(width: 4),
Text(
'En Espera',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF15803D),
),
),
],
),
),
// Botón Doctor: Llamar a Consulta
if (!isSecretary) ...[
const SizedBox(width: 6),
Tooltip(
message: 'Llamar a consulta médica ahora',
child: SizedBox(
height: uniformHeight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(uniformRadius),
),
visualDensity: VisualDensity.compact,
),
onPressed: () async {
if (onStatusChange != null) {
await onStatusChange!(patient, 'en_consulta');
}
if (onPatientTap != null) {
onPatientTap!(patient);
}
},
icon: const Icon(Icons.arrow_forward_rounded, size: 12),
label: const Text(
'Atender',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
),
),
),
),
],
// Botón Secretaria: Revertir si fue error
if (isSecretary && onStatusChange != null) ...[
const SizedBox(width: 4),
Tooltip(
message: 'Quitar de la sala de espera',
child: InkWell(
onTap: () => onStatusChange!(patient, 'no_presente'),
borderRadius: BorderRadius.circular(uniformRadius),
child: Container(
height: uniformHeight,
width: uniformHeight,
alignment: Alignment.center,
child: const Icon(Icons.close_rounded, size: 15, color: AppColors.textMuted),
),
),
),
],
],
);
}
if (patient.isEnConsulta) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onPatientTap != null ? () => onPatientTap!(patient) : null,
borderRadius: BorderRadius.circular(uniformRadius),
child: Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: const Color(0xFFE0F2FE),
borderRadius: BorderRadius.circular(uniformRadius),
border: Border.all(color: const Color(0xFF7DD3FC)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.medical_services_rounded, size: 12, color: Color(0xFF0369A1)),
SizedBox(width: 4),
Text(
'En Consulta',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF0369A1),
),
),
],
),
),
),
if (!isSecretary && onStatusChange != null) ...[
const SizedBox(width: 6),
Tooltip(
message: 'Finalizar consulta médica y marcar como Atendido',
child: SizedBox(
height: uniformHeight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0284C7),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(uniformRadius),
),
visualDensity: VisualDensity.compact,
),
onPressed: () => onStatusChange!(patient, 'atendido'),
icon: const Icon(Icons.check_rounded, size: 13),
label: const Text(
'Finalizar',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
),
),
),
),
],
],
);
}
if (patient.isAtendido) {
return Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 9),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(uniformRadius),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_rounded, size: 12, color: Color(0xFF64748B)),
SizedBox(width: 4),
Text(
'Atendido',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
],
),
);
}
// Estado no_presente (No ha llegado aún)
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSecretary && onStatusChange != null)
SizedBox(
height: uniformHeight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(uniformRadius),
),
visualDensity: VisualDensity.compact,
),
onPressed: () => onStatusChange!(patient, 'en_espera'),
icon: const Icon(Icons.how_to_reg_rounded, size: 13),
label: const Text(
'Llegó',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
),
),
)
else
Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(uniformRadius),
),
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.calendar_month_rounded,
size: 11,
color: AppColors.primary,
),
const SizedBox(width: 4),
Text(
patient.fecProxCita.isNotEmpty
? patient.fecProxCita.split(' ').first
: 'Hoy',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
],
),
),
],
);
}
}