feat(patient): implement doctor-only patient deletion, alert theme styling, and test auto-cleanup

This commit is contained in:
=
2026-09-22 13:41:14 -04:00
parent 73b69722a0
commit 654a7f402d
10 changed files with 616 additions and 16 deletions
+34
View File
@@ -104,6 +104,40 @@ class AppTheme {
thickness: 1,
space: 1,
),
dialogTheme: DialogThemeData(
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 10,
shadowColor: Colors.black.withValues(alpha: 0.16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: AppColors.border, width: 1),
),
titleTextStyle: const TextStyle(
fontFamily: fontFamily,
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
contentTextStyle: const TextStyle(
fontFamily: fontFamily,
fontSize: 13,
color: AppColors.textSecondary,
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.textPrimary,
contentTextStyle: const TextStyle(
fontFamily: fontFamily,
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.white,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
behavior: SnackBarBehavior.floating,
),
);
}
}
@@ -181,6 +181,9 @@ class _AppointmentsModalViewState extends State<AppointmentsModalView> {
widget.onStatusChanged!(patientId, 'atendido');
}
},
onDeleted: (_) {
_fetchAppointments(_selectedDate);
},
),
);
}
@@ -50,25 +50,64 @@ class _DashboardViewState extends State<DashboardView> {
context: context,
barrierDismissible: false,
builder: (dialogContext) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Row(
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: AppColors.border),
),
titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
actionsPadding: const EdgeInsets.fromLTRB(24, 12, 24, 20),
title: Row(
children: [
Icon(Icons.logout_rounded, color: AppColors.error),
SizedBox(width: 10),
Text('Cerrar Sesión', style: TextStyle(fontWeight: FontWeight.bold)),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: const Icon(Icons.logout_rounded, color: AppColors.error, size: 22),
),
const SizedBox(width: 12),
const Text('Cerrar Sesión', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
],
),
content: const Text('¿Está seguro de que desea salir del sistema Hidalyx Med?'),
content: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.primaryContainer.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)),
),
child: const Row(
children: [
Icon(Icons.info_outline_rounded, color: AppColors.primary, size: 20),
SizedBox(width: 10),
Expanded(
child: Text(
'¿Está seguro de que desea salir del sistema Hidalyx Med?',
style: TextStyle(fontSize: 13, color: AppColors.textPrimary, fontWeight: FontWeight.w500),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancelar'),
child: const Text('Cancelar', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w600)),
),
ElevatedButton(
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.error,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: const Icon(Icons.logout_rounded, size: 16),
label: const Text('Salir del Sistema', style: TextStyle(fontWeight: FontWeight.w700)),
onPressed: () async {
Navigator.of(dialogContext).pop();
await AuthService().logout();
@@ -76,7 +115,6 @@ class _DashboardViewState extends State<DashboardView> {
context.go('/login');
}
},
child: const Text('Salir'),
),
],
),
@@ -276,6 +314,7 @@ class _DashboardViewState extends State<DashboardView> {
patientId: patient.id,
isSecretary: _isSecretary,
onSaved: (_) => _store.loadDashboardData(),
onDeleted: (_) => _store.loadDashboardData(),
),
);
}
@@ -61,4 +61,18 @@ class PatientService {
throw Exception('Error al actualizar paciente: $e');
}
}
Future<void> deletePatient(int id) async {
try {
final response = await apiClient.dio.delete('/items/pacientes/$id');
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception('Error en el servidor al eliminar el paciente');
}
} on DioException catch (e) {
final serverMessage = e.response?.data?['errors']?[0]?['message'] ?? e.message;
throw Exception('Error al eliminar paciente: $serverMessage');
} catch (e) {
throw Exception('Error al eliminar paciente: $e');
}
}
}
@@ -23,6 +23,9 @@ abstract class _PatientStoreBase with Store {
@observable
bool isSaving = false;
@observable
bool isDeleting = false;
@observable
String? errorMessage;
@@ -185,6 +188,7 @@ abstract class _PatientStoreBase with Store {
successMessage = null;
isLoading = false;
isSaving = false;
isDeleting = false;
final now = DateTime.now();
fecReg = DateFormat('dd/MM/yyyy').format(now);
@@ -391,4 +395,31 @@ abstract class _PatientStoreBase with Store {
return false;
}
}
@action
Future<bool> deletePatient() async {
if (id == null) {
errorMessage = 'No se puede eliminar un paciente que no ha sido guardado.';
return false;
}
isDeleting = true;
errorMessage = null;
successMessage = null;
try {
await service.deletePatient(id!);
runInAction(() {
isDeleting = false;
successMessage = 'Paciente eliminado exitosamente';
});
return true;
} catch (e) {
runInAction(() {
errorMessage = e.toString().replaceAll('Exception: ', '');
isDeleting = false;
});
return false;
}
}
}
@@ -83,6 +83,24 @@ mixin _$PatientStore on _PatientStoreBase, Store {
});
}
late final _$isDeletingAtom = Atom(
name: '_PatientStoreBase.isDeleting',
context: context,
);
@override
bool get isDeleting {
_$isDeletingAtom.reportRead();
return super.isDeleting;
}
@override
set isDeleting(bool value) {
_$isDeletingAtom.reportWrite(value, super.isDeleting, () {
super.isDeleting = value;
});
}
late final _$errorMessageAtom = Atom(
name: '_PatientStoreBase.errorMessage',
context: context,
@@ -769,6 +787,16 @@ mixin _$PatientStore on _PatientStoreBase, Store {
return _$savePatientAsyncAction.run(() => super.savePatient());
}
late final _$deletePatientAsyncAction = AsyncAction(
'_PatientStoreBase.deletePatient',
context: context,
);
@override
Future<bool> deletePatient() {
return _$deletePatientAsyncAction.run(() => super.deletePatient());
}
late final _$_PatientStoreBaseActionController = ActionController(
name: '_PatientStoreBase',
context: context,
@@ -828,6 +856,7 @@ mixin _$PatientStore on _PatientStoreBase, Store {
id: ${id},
isLoading: ${isLoading},
isSaving: ${isSaving},
isDeleting: ${isDeleting},
errorMessage: ${errorMessage},
successMessage: ${successMessage},
fecReg: ${fecReg},
@@ -18,14 +18,18 @@ class PatientModalView extends StatefulWidget {
final PatientModel? initialPatient;
final int? patientId;
final ValueChanged<PatientModel>? onSaved;
final ValueChanged<int>? onDeleted;
final bool isSecretary;
final PatientStore? store;
const PatientModalView({
super.key,
this.initialPatient,
this.patientId,
this.onSaved,
this.onDeleted,
this.isSecretary = false,
this.store,
});
@override
@@ -39,13 +43,13 @@ class _PatientModalViewState extends State<PatientModalView> {
@override
void initState() {
super.initState();
_store = PatientStore();
_store = widget.store ?? PatientStore();
if (widget.initialPatient != null) {
_store.populateFromModel(widget.initialPatient!);
} else if (widget.patientId != null) {
_store.loadPatient(widget.patientId!);
} else {
} else if (widget.store == null) {
_store.initForCreate();
}
}
@@ -83,6 +87,176 @@ class _PatientModalViewState extends State<PatientModalView> {
}
}
Future<void> _confirmDeletePatient() async {
final patientId = _store.id;
if (patientId == null) return;
final patientName = '${_store.nombre} ${_store.apellido}'.trim();
final cedula = _store.cedula.trim().isNotEmpty ? _store.cedula.trim() : 'No registrada';
final confirmed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogCtx) => StatefulBuilder(
builder: (builderCtx, setDialogState) {
return AlertDialog(
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: AppColors.border),
),
titlePadding: const EdgeInsets.fromLTRB(20, 20, 20, 10),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
actionsPadding: const EdgeInsets.fromLTRB(20, 10, 20, 16),
title: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: const Icon(Icons.delete_forever_rounded, color: AppColors.error, size: 22),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'¿Eliminar Ficha del Paciente?',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
),
),
],
),
content: SizedBox(
width: 440,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.primaryContainer.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
patientName.isNotEmpty ? patientName : 'Paciente sin nombre',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.primaryDark),
),
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'ID: #$patientId',
style: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.w600, color: AppColors.primary),
),
),
const SizedBox(width: 10),
Text('Cédula: $cedula', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
],
),
],
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.error.withValues(alpha: 0.25)),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, color: AppColors.error, size: 20),
SizedBox(width: 10),
Expanded(
child: Text(
'Esta acción es permanente e irreversible. Se eliminará el expediente completo, antecedentes médicos, consultas, recetas y citas asociadas.',
style: TextStyle(
fontSize: 12.5,
color: Color(0xFF991B1B),
height: 1.35,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: _store.isDeleting ? null : () => Navigator.of(dialogCtx).pop(false),
child: const Text('Cancelar', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w600)),
),
ElevatedButton.icon(
onPressed: _store.isDeleting
? null
: () async {
setDialogState(() {});
final success = await _store.deletePatient();
if (dialogCtx.mounted) {
Navigator.of(dialogCtx).pop(success);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.error,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: _store.isDeleting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.delete_outline_rounded, size: 16),
label: Text(
_store.isDeleting ? 'Eliminando...' : 'Eliminar Paciente',
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
],
);
},
),
);
if (!mounted) return;
if (confirmed == true) {
if (widget.onDeleted != null) {
widget.onDeleted!(patientId);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ficha del paciente $patientName eliminada exitosamente.'),
backgroundColor: const Color(0xFF00A65A),
behavior: SnackBarBehavior.floating,
),
);
if (Navigator.canPop(context)) {
Navigator.of(context).pop();
}
}
}
@override
Widget build(BuildContext context) {
return Observer(
@@ -192,7 +366,31 @@ class _PatientModalViewState extends State<PatientModalView> {
),
const SizedBox(width: 8),
],
if (!widget.isSecretary)
if (!widget.isSecretary) ...[
if (_store.isEditing) ...[
OutlinedButton.icon(
onPressed: _store.isDeleting || _store.isSaving
? null
: () => _confirmDeletePatient(),
icon: const Icon(Icons.delete_outline_rounded, size: 16, color: AppColors.error),
label: const Text(
'Eliminar',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.error,
),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 0),
minimumSize: const Size(0, 34),
side: BorderSide(color: AppColors.error.withValues(alpha: 0.35)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
backgroundColor: AppColors.error.withValues(alpha: 0.04),
),
),
const SizedBox(width: 8),
],
ClinicalDocumentsDropdown(
onSelect: (docType) {
if (docType == ClinicalDocumentType.recetaMedica) {
@@ -234,12 +432,13 @@ class _PatientModalViewState extends State<PatientModalView> {
}
},
),
],
],
bottomBar: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
color: Color(0xFFF8FAFC),
border: Border(top: BorderSide(color: Color(0xFFE2E8F0))),
color: Colors.white,
border: Border(top: BorderSide(color: AppColors.border)),
),
child: Row(
children: [