feat(patient): implement doctor-only patient deletion, alert theme styling, and test auto-cleanup
This commit is contained in:
@@ -104,6 +104,40 @@ class AppTheme {
|
|||||||
thickness: 1,
|
thickness: 1,
|
||||||
space: 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');
|
widget.onStatusChanged!(patientId, 'atendido');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onDeleted: (_) {
|
||||||
|
_fetchAppointments(_selectedDate);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,25 +50,64 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
backgroundColor: Colors.white,
|
||||||
title: const Row(
|
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: [
|
children: [
|
||||||
Icon(Icons.logout_rounded, color: AppColors.error),
|
Container(
|
||||||
SizedBox(width: 10),
|
padding: const EdgeInsets.all(8),
|
||||||
Text('Cerrar Sesión', style: TextStyle(fontWeight: FontWeight.bold)),
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
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(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.error,
|
backgroundColor: AppColors.error,
|
||||||
foregroundColor: Colors.white,
|
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 {
|
onPressed: () async {
|
||||||
Navigator.of(dialogContext).pop();
|
Navigator.of(dialogContext).pop();
|
||||||
await AuthService().logout();
|
await AuthService().logout();
|
||||||
@@ -76,7 +115,6 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
context.go('/login');
|
context.go('/login');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Salir'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -276,6 +314,7 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
patientId: patient.id,
|
patientId: patient.id,
|
||||||
isSecretary: _isSecretary,
|
isSecretary: _isSecretary,
|
||||||
onSaved: (_) => _store.loadDashboardData(),
|
onSaved: (_) => _store.loadDashboardData(),
|
||||||
|
onDeleted: (_) => _store.loadDashboardData(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,18 @@ class PatientService {
|
|||||||
throw Exception('Error al actualizar paciente: $e');
|
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
|
@observable
|
||||||
bool isSaving = false;
|
bool isSaving = false;
|
||||||
|
|
||||||
|
@observable
|
||||||
|
bool isDeleting = false;
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
String? errorMessage;
|
String? errorMessage;
|
||||||
|
|
||||||
@@ -185,6 +188,7 @@ abstract class _PatientStoreBase with Store {
|
|||||||
successMessage = null;
|
successMessage = null;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
isSaving = false;
|
isSaving = false;
|
||||||
|
isDeleting = false;
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
fecReg = DateFormat('dd/MM/yyyy').format(now);
|
fecReg = DateFormat('dd/MM/yyyy').format(now);
|
||||||
@@ -391,4 +395,31 @@ abstract class _PatientStoreBase with Store {
|
|||||||
return false;
|
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(
|
late final _$errorMessageAtom = Atom(
|
||||||
name: '_PatientStoreBase.errorMessage',
|
name: '_PatientStoreBase.errorMessage',
|
||||||
context: context,
|
context: context,
|
||||||
@@ -769,6 +787,16 @@ mixin _$PatientStore on _PatientStoreBase, Store {
|
|||||||
return _$savePatientAsyncAction.run(() => super.savePatient());
|
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(
|
late final _$_PatientStoreBaseActionController = ActionController(
|
||||||
name: '_PatientStoreBase',
|
name: '_PatientStoreBase',
|
||||||
context: context,
|
context: context,
|
||||||
@@ -828,6 +856,7 @@ mixin _$PatientStore on _PatientStoreBase, Store {
|
|||||||
id: ${id},
|
id: ${id},
|
||||||
isLoading: ${isLoading},
|
isLoading: ${isLoading},
|
||||||
isSaving: ${isSaving},
|
isSaving: ${isSaving},
|
||||||
|
isDeleting: ${isDeleting},
|
||||||
errorMessage: ${errorMessage},
|
errorMessage: ${errorMessage},
|
||||||
successMessage: ${successMessage},
|
successMessage: ${successMessage},
|
||||||
fecReg: ${fecReg},
|
fecReg: ${fecReg},
|
||||||
|
|||||||
@@ -18,14 +18,18 @@ class PatientModalView extends StatefulWidget {
|
|||||||
final PatientModel? initialPatient;
|
final PatientModel? initialPatient;
|
||||||
final int? patientId;
|
final int? patientId;
|
||||||
final ValueChanged<PatientModel>? onSaved;
|
final ValueChanged<PatientModel>? onSaved;
|
||||||
|
final ValueChanged<int>? onDeleted;
|
||||||
final bool isSecretary;
|
final bool isSecretary;
|
||||||
|
final PatientStore? store;
|
||||||
|
|
||||||
const PatientModalView({
|
const PatientModalView({
|
||||||
super.key,
|
super.key,
|
||||||
this.initialPatient,
|
this.initialPatient,
|
||||||
this.patientId,
|
this.patientId,
|
||||||
this.onSaved,
|
this.onSaved,
|
||||||
|
this.onDeleted,
|
||||||
this.isSecretary = false,
|
this.isSecretary = false,
|
||||||
|
this.store,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -39,13 +43,13 @@ class _PatientModalViewState extends State<PatientModalView> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_store = PatientStore();
|
_store = widget.store ?? PatientStore();
|
||||||
|
|
||||||
if (widget.initialPatient != null) {
|
if (widget.initialPatient != null) {
|
||||||
_store.populateFromModel(widget.initialPatient!);
|
_store.populateFromModel(widget.initialPatient!);
|
||||||
} else if (widget.patientId != null) {
|
} else if (widget.patientId != null) {
|
||||||
_store.loadPatient(widget.patientId!);
|
_store.loadPatient(widget.patientId!);
|
||||||
} else {
|
} else if (widget.store == null) {
|
||||||
_store.initForCreate();
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Observer(
|
return Observer(
|
||||||
@@ -192,7 +366,31 @@ class _PatientModalViewState extends State<PatientModalView> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
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(
|
ClinicalDocumentsDropdown(
|
||||||
onSelect: (docType) {
|
onSelect: (docType) {
|
||||||
if (docType == ClinicalDocumentType.recetaMedica) {
|
if (docType == ClinicalDocumentType.recetaMedica) {
|
||||||
@@ -234,12 +432,13 @@ class _PatientModalViewState extends State<PatientModalView> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
bottomBar: Container(
|
bottomBar: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Color(0xFFF8FAFC),
|
color: Colors.white,
|
||||||
border: Border(top: BorderSide(color: Color(0xFFE2E8F0))),
|
border: Border(top: BorderSide(color: AppColors.border)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ void main() {
|
|||||||
expect(created.id, isNotNull);
|
expect(created.id, isNotNull);
|
||||||
expect(created.id! > 0, isTrue);
|
expect(created.id! > 0, isTrue);
|
||||||
|
|
||||||
|
// Rutina de limpieza automática para no dejar registros basura en la BD
|
||||||
|
addTearDown(() async {
|
||||||
|
if (created.id != null) {
|
||||||
|
try {
|
||||||
|
await apiClient.dio.delete('/items/pacientes/${created.id}');
|
||||||
|
print('🧹 [DIRECTUS TEST] Paciente de prueba ${created.id} eliminado correctamente tras la verificación.');
|
||||||
|
} catch (e) {
|
||||||
|
print('⚠️ [DIRECTUS TEST] No se pudo eliminar el paciente de prueba: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 4. Verificar obtención directa por ID
|
// 4. Verificar obtención directa por ID
|
||||||
print('\n[DIRECTUS TEST] 4. Verificando getPatientById(${created.id})...');
|
print('\n[DIRECTUS TEST] 4. Verificando getPatientById(${created.id})...');
|
||||||
final fetched = await patientService.getPatientById(created.id!);
|
final fetched = await patientService.getPatientById(created.id!);
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hidalyx_drluiscastro/features/patient/data/models/patient_model.dart';
|
||||||
|
import 'package:hidalyx_drluiscastro/features/patient/data/services/patient_service.dart';
|
||||||
|
import 'package:hidalyx_drluiscastro/features/patient/presentation/store/patient_store.dart';
|
||||||
|
import 'package:hidalyx_drluiscastro/features/patient/presentation/views/patient_modal_view.dart';
|
||||||
|
|
||||||
|
class MockPatientServiceForDelete extends PatientService {
|
||||||
|
int? deletedPatientId;
|
||||||
|
bool shouldFail = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deletePatient(int id) async {
|
||||||
|
if (shouldFail) {
|
||||||
|
throw Exception('Error simulado al eliminar paciente');
|
||||||
|
}
|
||||||
|
deletedPatientId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Patient Deletion Unit & Store Tests', () {
|
||||||
|
test('PatientStore.deletePatient deletes patient and updates state', () async {
|
||||||
|
final mockService = MockPatientServiceForDelete();
|
||||||
|
final store = PatientStore(patientService: mockService);
|
||||||
|
|
||||||
|
// Attempt to delete with null id
|
||||||
|
final nullResult = await store.deletePatient();
|
||||||
|
expect(nullResult, isFalse);
|
||||||
|
expect(store.errorMessage, contains('No se puede eliminar un paciente que no ha sido guardado'));
|
||||||
|
|
||||||
|
// Populate patient
|
||||||
|
store.populateFromModel(const PatientModel(
|
||||||
|
id: 777,
|
||||||
|
nombre: 'CARLOS',
|
||||||
|
apellido: 'MARTINEZ',
|
||||||
|
));
|
||||||
|
expect(store.id, 777);
|
||||||
|
|
||||||
|
// Execute deletion
|
||||||
|
final success = await store.deletePatient();
|
||||||
|
expect(success, isTrue);
|
||||||
|
expect(mockService.deletedPatientId, 777);
|
||||||
|
expect(store.isDeleting, isFalse);
|
||||||
|
expect(store.successMessage, 'Paciente eliminado exitosamente');
|
||||||
|
expect(store.errorMessage, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PatientStore.deletePatient handles service errors gracefully', () async {
|
||||||
|
final mockService = MockPatientServiceForDelete()..shouldFail = true;
|
||||||
|
final store = PatientStore(patientService: mockService);
|
||||||
|
|
||||||
|
store.populateFromModel(const PatientModel(
|
||||||
|
id: 888,
|
||||||
|
nombre: 'LUCIA',
|
||||||
|
apellido: 'MENDEZ',
|
||||||
|
));
|
||||||
|
|
||||||
|
final success = await store.deletePatient();
|
||||||
|
expect(success, isFalse);
|
||||||
|
expect(store.isDeleting, isFalse);
|
||||||
|
expect(store.errorMessage, contains('Error simulado al eliminar paciente'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Patient Deletion UI & Role Tests', () {
|
||||||
|
testWidgets('Doctor sees "Eliminar" button when editing an existing patient', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1400, 1000);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
const patient = PatientModel(
|
||||||
|
id: 555,
|
||||||
|
nombre: 'JUAN',
|
||||||
|
apellido: 'PEREZ',
|
||||||
|
cedula: '001-9999999-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: PatientModalView(
|
||||||
|
initialPatient: patient,
|
||||||
|
isSecretary: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Eliminar'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Doctor does NOT see "Eliminar" button when creating a new patient', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1400, 1000);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: PatientModalView(
|
||||||
|
isSecretary: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Eliminar'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Secretary NEVER sees "Eliminar" button even when editing an existing patient', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1400, 1000);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
const patient = PatientModel(
|
||||||
|
id: 555,
|
||||||
|
nombre: 'JUAN',
|
||||||
|
apellido: 'PEREZ',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: PatientModalView(
|
||||||
|
initialPatient: patient,
|
||||||
|
isSecretary: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Eliminar'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Clicking "Eliminar" opens confirmation dialog, Cancel does not delete', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1400, 1000);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
final mockService = MockPatientServiceForDelete();
|
||||||
|
final store = PatientStore(patientService: mockService);
|
||||||
|
store.populateFromModel(const PatientModel(
|
||||||
|
id: 123,
|
||||||
|
nombre: 'ROBERTO',
|
||||||
|
apellido: 'GARCIA',
|
||||||
|
cedula: '402-1234567-8',
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: PatientModalView(
|
||||||
|
store: store,
|
||||||
|
isSecretary: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Click Eliminar button
|
||||||
|
await tester.tap(find.text('Eliminar'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify confirmation dialog
|
||||||
|
expect(find.text('¿Eliminar Ficha del Paciente?'), findsOneWidget);
|
||||||
|
expect(find.text('ROBERTO GARCIA'), findsOneWidget);
|
||||||
|
expect(find.textContaining('#123'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Esta acción es permanente e irreversible'), findsOneWidget);
|
||||||
|
|
||||||
|
// Click Cancelar
|
||||||
|
await tester.tap(find.text('Cancelar'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Dialog dismissed, patient NOT deleted
|
||||||
|
expect(find.text('¿Eliminar Ficha del Paciente?'), findsNothing);
|
||||||
|
expect(mockService.deletedPatientId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Confirming deletion invokes service, calls onDeleted, and closes modal', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1400, 1000);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
final mockService = MockPatientServiceForDelete();
|
||||||
|
final store = PatientStore(patientService: mockService);
|
||||||
|
store.populateFromModel(const PatientModel(
|
||||||
|
id: 999,
|
||||||
|
nombre: 'ALBERTO',
|
||||||
|
apellido: 'MOREL',
|
||||||
|
));
|
||||||
|
|
||||||
|
int? deletedIdReceived;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (ctx) => ElevatedButton(
|
||||||
|
onPressed: () => showDialog(
|
||||||
|
context: ctx,
|
||||||
|
builder: (_) => PatientModalView(
|
||||||
|
store: store,
|
||||||
|
isSecretary: false,
|
||||||
|
onDeleted: (id) => deletedIdReceived = id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Open Modal'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
await tester.tap(find.text('Open Modal'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Click Eliminar
|
||||||
|
await tester.tap(find.text('Eliminar'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Click Eliminar Paciente inside confirmation dialog
|
||||||
|
await tester.tap(find.text('Eliminar Paciente'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(mockService.deletedPatientId, 999);
|
||||||
|
expect(deletedIdReceived, 999);
|
||||||
|
expect(find.textContaining('eliminada exitosamente'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -127,9 +127,10 @@ void main() {
|
|||||||
expect(find.text('Ficha de Recepción'), findsOneWidget);
|
expect(find.text('Ficha de Recepción'), findsOneWidget);
|
||||||
expect(find.text('Recepción, datos personales y programación de citas'), findsOneWidget);
|
expect(find.text('Recepción, datos personales y programación de citas'), findsOneWidget);
|
||||||
|
|
||||||
// Confidential clinical documents and clinical tabs MUST NOT exist
|
// Confidential clinical documents, clinical tabs and delete button MUST NOT exist
|
||||||
expect(find.byType(ClinicalDocumentsDropdown), findsNothing);
|
expect(find.byType(ClinicalDocumentsDropdown), findsNothing);
|
||||||
expect(find.byType(PatientClinicalTabs), findsNothing);
|
expect(find.byType(PatientClinicalTabs), findsNothing);
|
||||||
|
expect(find.text('Eliminar'), findsNothing);
|
||||||
|
|
||||||
// Secretary appointment section MUST exist
|
// Secretary appointment section MUST exist
|
||||||
expect(find.byType(SecretaryAppointmentSection), findsOneWidget);
|
expect(find.byType(SecretaryAppointmentSection), findsOneWidget);
|
||||||
@@ -160,9 +161,10 @@ void main() {
|
|||||||
// Verify title reflects doctor editing
|
// Verify title reflects doctor editing
|
||||||
expect(find.text('Editar Paciente'), findsOneWidget);
|
expect(find.text('Editar Paciente'), findsOneWidget);
|
||||||
|
|
||||||
// Doctor gets clinical documents dropdown and clinical tabs
|
// Doctor gets clinical documents dropdown, clinical tabs and delete button
|
||||||
expect(find.byType(ClinicalDocumentsDropdown), findsOneWidget);
|
expect(find.byType(ClinicalDocumentsDropdown), findsOneWidget);
|
||||||
expect(find.byType(PatientClinicalTabs), findsOneWidget);
|
expect(find.byType(PatientClinicalTabs), findsOneWidget);
|
||||||
|
expect(find.text('Eliminar'), findsOneWidget);
|
||||||
expect(find.byType(SecretaryAppointmentSection), findsNothing);
|
expect(find.byType(SecretaryAppointmentSection), findsNothing);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user