feat(calendar): add block/unblock slots for professionals

- ServicesProvider: add blockSlot() calling POST /services/block and
  unblockSlot() calling PATCH /services/:id/status with cancelled
- ProfessionalCalendarView:
  - Fix _services always-null bug: getServicesForProfessional() returns
    void; now reads from sp.services after it resolves
  - Differentiate self_booked (blocked) vs regular occupied slots
  - Show amber 'Bloqueado' state with lock icon for self_booked services
  - Show lock button on available slots → confirm dialog → blockSlot()
  - Show unlock button on blocked slots → confirm dialog → unblockSlot()
  - Reload calendar after block/unblock so UI reflects new state
  - Header stats now show separate counts: ocupadas / bloqueadas / libres
  - _ActionButton widget for reusable tap-target icon buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-12 18:26:45 -05:00
co-authored by Claude Sonnet 4.6
parent 498481cab0
commit 52845c59fd
2 changed files with 163 additions and 24 deletions
+12
View File
@@ -51,6 +51,18 @@ class ServicesProvider extends ChangeNotifier {
notifyListeners();
}
// Blocks a slot on the professional's calendar by creating a self_booked service.
Future<void> blockSlot(String day, TimeOfDay time) async {
final h = time.hour.toString().padLeft(2, '0');
final m = time.minute.toString().padLeft(2, '0');
await _api.post('/services/block', {'day': day, 'range1_hour1': '$h:$m'});
}
// Unblocks a previously blocked slot by cancelling the self_booked service.
Future<void> unblockSlot(String serviceId) async {
await _api.patch('/services/$serviceId/status', {'status': 'cancelled'});
}
getServiceForUser(String serviceId) async {
try {
isLoading = true;
+151 -24
View File
@@ -5,15 +5,17 @@ import 'package:prosapp_web_app/models/service.dart';
import 'package:prosapp_web_app/providers/auth_provider.dart';
import 'package:prosapp_web_app/providers/professional_form_provider.dart';
import 'package:prosapp_web_app/providers/professional_provider.dart';
import 'package:prosapp_web_app/models/service_status.dart';
import 'package:prosapp_web_app/providers/services_provider.dart';
import 'package:prosapp_web_app/utils/time_of_day_utils.dart';
import 'package:provider/provider.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:flutter/material.dart';
const _kPrimary = Color(0xFF1565C0);
const _kPrimary = Color(0xFF1565C0);
const _kAvailable = Color(0xFF16A34A);
const _kOccupied = Color(0xFFDC2626);
const _kOccupied = Color(0xFFDC2626);
const _kBlocked = Color(0xFFF59E0B);
extension _Th on BuildContext {
ThemeData get _t => Theme.of(this);
@@ -54,8 +56,12 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
final pro = await Provider.of<ProfessionalProvider>(context, listen: false)
.getProfessional(auth.user!.id);
fp.setProfesional(pro);
final services = await sp.getServicesForProfessional(pro.id);
if (mounted) setState(() { _services = services; _loading = false; });
// getServicesForProfessional returns void; read from sp.services after it resolves
await sp.getServicesForProfessional(pro.id);
if (mounted) setState(() {
_services = sp.services.map((s) => s.service).toList();
_loading = false;
});
}
void _onDaySelected(DateTime day, DateTime _) =>
@@ -71,20 +77,22 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
return const Center(child: CircularProgressIndicator(color: _kPrimary));
}
final pro = fp.profesional!;
final sp = Provider.of<ServicesProvider>(context, listen: false);
final schedule = _scheduleFor(_selected.weekday, pro);
final slots = _buildSlots(schedule);
final occupied = slots.where((t) => _isOccupied(t, _services, _selected)).length;
final blocked = slots.where((t) => _isSelfBooked(t, _services, _selected)).length;
return ListView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 32),
children: [
_calendarCard(context),
_dayHeader(context, schedule, slots.length, occupied),
_dayHeader(context, schedule, slots.length, occupied, blocked),
if (slots.isEmpty)
_emptyState(context)
else
..._slotCards(context, slots),
..._slotCards(context, slots, sp, pro.id),
],
);
},
@@ -142,10 +150,10 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
);
}
Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total, int occupied) {
Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total, int occupied, int blocked) {
final dayName = DateFormat('EEEE', 'es').format(_selected);
final dateStr = DateFormat('d MMMM yyyy', 'es').format(_selected);
final available = total - occupied;
final available = total - occupied - blocked;
final hasSchedule = schedule != null && schedule.enabled;
return Center(
@@ -179,6 +187,8 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
if (hasSchedule && total > 0) ...[
_StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied),
const SizedBox(width: 8),
_StatPill(label: '$blocked', sublabel: 'bloqueadas', color: _kBlocked),
const SizedBox(width: 8),
_StatPill(label: '$available', sublabel: 'libres', color: _kAvailable),
],
]),
@@ -187,11 +197,28 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
);
}
List<Widget> _slotCards(BuildContext context, List<TimeOfDay> slots) {
List<Widget> _slotCards(BuildContext context, List<TimeOfDay> slots, ServicesProvider sp, String professionalId) {
return slots.map((time) {
final occ = _isOccupied(time, _services, _selected);
final matchService = occ ? _serviceFor(time, _services, _selected) : null;
final color = occ ? _kOccupied : _kAvailable;
final selfBooked = _isSelfBooked(time, _services, _selected);
final occ = !selfBooked && _isOccupied(time, _services, _selected);
final matchService = (occ || selfBooked) ? _serviceFor(time, _services, _selected) : null;
final Color color;
final String label;
final IconData icon;
if (selfBooked) {
color = _kBlocked;
label = 'Bloqueado';
icon = Icons.lock_outline;
} else if (occ) {
color = _kOccupied;
label = 'Ocupado';
icon = Icons.event_busy_outlined;
} else {
color = _kAvailable;
label = 'Disponible';
icon = Icons.event_available_outlined;
}
return Center(
child: ConstrainedBox(
@@ -224,25 +251,37 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(occ ? 'Ocupado' : 'Disponible',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color)),
Text(label, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color)),
if (matchService != null && matchService.description.isNotEmpty)
Text(matchService.description,
style: TextStyle(fontSize: 12, color: context.muted),
maxLines: 1, overflow: TextOverflow.ellipsis)
else if (!occ)
else if (!occ && !selfBooked)
Text('Horario libre para nuevas citas',
style: TextStyle(fontSize: 11, color: context.subtle)),
],
)),
Container(
width: 32, height: 32,
decoration: BoxDecoration(
color: color.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(
occ ? Icons.event_busy_outlined : Icons.event_available_outlined,
size: 17, color: color),
),
// Action button
if (selfBooked)
_ActionButton(
icon: Icons.lock_open_outlined,
color: _kBlocked,
tooltip: 'Desbloquear',
onTap: () => _confirmUnblock(context, sp, matchService!.id!, time),
)
else if (!occ)
_ActionButton(
icon: Icons.lock_outline,
color: context.subtle,
tooltip: 'Bloquear horario',
onTap: () => _confirmBlock(context, sp, time),
)
else
Container(
width: 32, height: 32,
decoration: BoxDecoration(color: color.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(icon, size: 17, color: color),
),
]),
),
),
@@ -255,6 +294,59 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
}).toList();
}
Future<void> _confirmBlock(BuildContext context, ServicesProvider sp, TimeOfDay time) async {
final timeStr = ScheduleEntity.getFormatTime(time) ?? '';
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Bloquear horario'),
content: Text('¿Bloquear el horario de $timeStr? Los usuarios no podrán agendarse en este slot.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar')),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: _kBlocked),
child: const Text('Bloquear', style: TextStyle(color: Colors.white)),
),
],
),
);
if (confirmed != true) return;
try {
final dayStr = _selected.toIso8601String().split('T').first;
await sp.blockSlot(dayStr, time);
await _load();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
Future<void> _confirmUnblock(BuildContext context, ServicesProvider sp, String serviceId, TimeOfDay time) async {
final timeStr = ScheduleEntity.getFormatTime(time) ?? '';
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Desbloquear horario'),
content: Text('¿Desbloquear el horario de $timeStr? Estará disponible para nuevas citas.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar')),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: _kAvailable),
child: const Text('Desbloquear', style: TextStyle(color: Colors.white)),
),
],
),
);
if (confirmed != true) return;
try {
await sp.unblockSlot(serviceId);
await _load();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
Widget _emptyState(BuildContext context) {
return Center(
child: ConstrainedBox(
@@ -307,7 +399,19 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
bool _isOccupied(TimeOfDay time, List<Service>? services, DateTime day) {
if (services == null) return false;
final dayStr = day.toIso8601String().split('T').first;
return services.any((s) => s.day == dayStr && s.range1Hour1 == time);
return services.any((s) =>
s.day == dayStr &&
s.range1Hour1 == time &&
s.status != ServiceStatus.selfBooked);
}
bool _isSelfBooked(TimeOfDay time, List<Service>? services, DateTime day) {
if (services == null) return false;
final dayStr = day.toIso8601String().split('T').first;
return services.any((s) =>
s.day == dayStr &&
s.range1Hour1 == time &&
s.status == ServiceStatus.selfBooked);
}
Service? _serviceFor(TimeOfDay time, List<Service>? services, DateTime day) {
@@ -321,6 +425,29 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
String _capitalize(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
}
class _ActionButton extends StatelessWidget {
final IconData icon;
final Color color;
final String tooltip;
final VoidCallback onTap;
const _ActionButton({required this.icon, required this.color, required this.tooltip, required this.onTap});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: GestureDetector(
onTap: onTap,
child: Container(
width: 32, height: 32,
decoration: BoxDecoration(color: color.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(icon, size: 17, color: color),
),
),
);
}
}
class _StatPill extends StatelessWidget {
final String label;
final String sublabel;