feat(patient): implement doctor-only patient deletion, alert theme styling, and test auto-cleanup
This commit is contained in:
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user