first commit
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user