From 216fd85888d71873818ed7a5edfe6574aebf02a9 Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Tue, 12 May 2026 12:36:50 +0200 Subject: [PATCH] a --- lib/core/routes/app_router.dart | 9 + .../tickets/ui/ticket_form_screen.dart | 62 +++- .../tickets/ui/ticket_timeline_section.dart | 278 ++++++++++++++++++ .../tracking/blocs/tracking_cubit.dart | 57 ++++ .../tracking/data/tracking_repository.dart | 57 ++++ .../tracking/models/tracking_model.dart | 132 +++++++++ 6 files changed, 592 insertions(+), 3 deletions(-) create mode 100644 lib/features/tickets/ui/ticket_timeline_section.dart create mode 100644 lib/features/tracking/blocs/tracking_cubit.dart create mode 100644 lib/features/tracking/data/tracking_repository.dart create mode 100644 lib/features/tracking/models/tracking_model.dart diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 655cf78..b356000 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -40,6 +40,7 @@ import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart'; import 'package:flux/features/tickets/models/ticket_model.dart'; import 'package:flux/features/tickets/ui/ticket_form_screen.dart'; import 'package:flux/features/tickets/ui/ticket_list_screen.dart'; +import 'package:flux/features/tracking/blocs/tracking_cubit.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; @@ -234,6 +235,14 @@ class AppRouter { ), ), BlocProvider(create: (context) => TicketFormCubit()), + BlocProvider( + create: (context) => TrackingCubit( + repo: repo, + parentId: parentId, + parentType: parentType, + companyId: companyId, + ), + ), ], child: TicketFormScreen( diff --git a/lib/features/tickets/ui/ticket_form_screen.dart b/lib/features/tickets/ui/ticket_form_screen.dart index 22a7449..3f116f5 100644 --- a/lib/features/tickets/ui/ticket_form_screen.dart +++ b/lib/features/tickets/ui/ticket_form_screen.dart @@ -13,7 +13,11 @@ import 'package:flux/features/tickets/blocs/ticket_form_state.dart'; import 'package:flux/features/tickets/models/ticket_model.dart'; import 'package:flux/core/widgets/shared_forms/staff_section.dart'; import 'package:flux/features/tickets/models/ticket_status_extension.dart'; +import 'package:flux/features/tickets/ui/ticket_timeline_section.dart'; import 'package:flux/features/tickets/utils/ticket_pdf_service.dart'; +import 'package:flux/features/tracking/blocs/tracking_cubit.dart'; +import 'package:flux/features/tracking/data/tracking_repository.dart'; +import 'package:flux/features/tracking/models/tracking_model.dart'; import 'package:get_it/get_it.dart'; import 'package:printing/printing.dart'; @@ -409,7 +413,11 @@ class _TicketFormScreenState extends State { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: Column(children: [_cardAnagrafica(ticket)])), + Expanded( + child: Column( + children: [_cardTimeline(ticket), _cardAnagrafica(ticket)], + ), + ), const SizedBox(width: 24), Expanded( child: Column( @@ -441,7 +449,11 @@ class _TicketFormScreenState extends State { const SizedBox(width: 24), Expanded( child: Column( - children: [_cardDettagli(ticket), _cardCosti(ticket)], + children: [ + _cardTimeline(ticket), + _cardDettagli(ticket), + _cardCosti(ticket), + ], ), ), ], @@ -451,6 +463,7 @@ class _TicketFormScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _cardTimeline(ticket), _cardAnagrafica(ticket), _cardDispositivi(ticket), _cardDettagli(ticket), @@ -461,7 +474,7 @@ class _TicketFormScreenState extends State { } } - // --- LE 5 CARD (MODULARIZZATE E COLORATE) --- + // --- LE 6 CARD (MODULARIZZATE E COLORATE) --- Widget _cardAnagrafica(TicketModel ticket) { return _buildCard( @@ -781,6 +794,49 @@ class _TicketFormScreenState extends State { ); } + Widget _cardTimeline(TicketModel ticket) { + // Se il ticket è nuovo (non ha ancora un ID salvato a DB), nascondiamo la timeline + if (ticket.id == null || ticket.id!.isEmpty) { + return const SizedBox.shrink(); + } + + return _buildCard( + title: 'Timeline & Note', + icon: Icons.history, + themeColor: Colors.blueGrey, + children: [ + BlocProvider( + create: (context) => TrackingCubit( + repo: GetIt.I.get(), + parentId: ticket.id!, + parentType: TrackingParentType.ticket, + companyId: ticket.companyId, + ), + child: BlocBuilder( + builder: (context, state) { + if (state.isLoading && state.logs.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return TicketTimelineSection( + logs: state.logs, + onAddNote: (message, isInternal) { + // Recupera l'ID dello staff loggato dal tuo auth state + // final currentStaffId = ... + context.read().addManualNote( + message, + isInternal, + // staffId: currentStaffId, + ); + }, + ); + }, + ), + ), + ], + ); + } + // --- WIDGET BASE PER LA CARD --- Widget _buildCard({ required String title, diff --git a/lib/features/tickets/ui/ticket_timeline_section.dart b/lib/features/tickets/ui/ticket_timeline_section.dart new file mode 100644 index 0000000..5aa6fa9 --- /dev/null +++ b/lib/features/tickets/ui/ticket_timeline_section.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:flux/features/tracking/models/tracking_model.dart'; + +class TicketTimelineSection extends StatefulWidget { + final List logs; + final void Function(String message, bool isInternal) onAddNote; + + const TicketTimelineSection({ + super.key, + required this.logs, + required this.onAddNote, + }); + + @override + State createState() => _TicketTimelineSectionState(); +} + +class _TicketTimelineSectionState extends State { + final TextEditingController _textController = TextEditingController(); + bool _isInternal = true; // Di default blindiamo tutto a uso interno! + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + void _submitNote() { + final text = _textController.text.trim(); + if (text.isNotEmpty) { + widget.onAddNote(text, _isInternal); + _textController.clear(); + // Chiudiamo la tastiera se siamo su mobile + FocusScope.of(context).unfocus(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // --- ZONA INPUT --- + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.dividerColor), + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + decoration: const InputDecoration( + hintText: 'Scrivi un aggiornamento...', + border: InputBorder.none, + isDense: true, + ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _submitNote(), + ), + ), + IconButton.filled( + onPressed: _submitNote, + icon: const Icon(Icons.send, size: 20), + tooltip: 'Invia', + ), + ], + ), + const Divider(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + _isInternal ? Icons.lock : Icons.public, + size: 16, + color: _isInternal + ? Colors.amber.shade700 + : Colors.green, + ), + const SizedBox(width: 8), + Text( + _isInternal + ? 'Nota Interna (Privata)' + : 'Visibile al Cliente', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: _isInternal + ? Colors.amber.shade700 + : Colors.green, + ), + ), + ], + ), + Switch( + value: _isInternal, + activeThumbColor: Colors.amber.shade700, + activeTrackColor: Colors.amber, + inactiveThumbColor: Colors.green, + inactiveTrackColor: Colors.green.withValues(alpha: 0.2), + onChanged: (val) { + setState(() { + _isInternal = val; + }); + }, + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 24), + + // --- TIMELINE SCROLLABILE --- + if (widget.logs.isEmpty) + const Padding( + padding: EdgeInsets.all(32.0), + child: Center( + child: Text( + 'Nessun evento registrato.', + style: TextStyle(color: Colors.grey), + ), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 400, + ), // Limite di altezza + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.logs.length, + itemBuilder: (context, index) { + final log = widget.logs[index]; + final isLast = index == widget.logs.length - 1; + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // --- LINEA E PALLINO --- + SizedBox( + width: 30, + child: Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 4), + width: 14, + height: 14, + decoration: BoxDecoration( + color: _getEventColor(log.eventType), + shape: BoxShape.circle, + border: Border.all( + color: theme.scaffoldBackgroundColor, + width: 2, + ), + ), + ), + if (!isLast) + Expanded( + child: Container( + width: 2, + color: theme.dividerColor, + ), + ), + ], + ), + ), + + // --- CONTENUTO EVENTO --- + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + log.staffName ?? 'Sistema', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + const SizedBox(width: 8), + Text( + _formatDate(log.createdAt), + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + if (!log.isInternal) ...[ + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.green.withValues( + alpha: 0.1, + ), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.green.withValues( + alpha: 0.3, + ), + ), + ), + child: const Text( + "PUBBLICO", + style: TextStyle( + color: Colors.green, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 6), + Text( + log.message, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ); + } + + Color _getEventColor(TrackingType type) { + switch (type) { + case TrackingType.statusChange: + return Colors.blue; + case TrackingType.assignment: + return Colors.purple; + case TrackingType.systemAlert: + return Colors.redAccent; + case TrackingType.customerContact: + return Colors.teal; + case TrackingType.manualNote: + // ignore: unreachable_switch_default + default: + return Colors.amber.shade600; + } + } + + String _formatDate(DateTime date) { + final day = date.day.toString().padLeft(2, '0'); + final month = date.month.toString().padLeft(2, '0'); + final hour = date.hour.toString().padLeft(2, '0'); + final minute = date.minute.toString().padLeft(2, '0'); + return "$day/$month - $hour:$minute"; + } +} diff --git a/lib/features/tracking/blocs/tracking_cubit.dart b/lib/features/tracking/blocs/tracking_cubit.dart new file mode 100644 index 0000000..9087265 --- /dev/null +++ b/lib/features/tracking/blocs/tracking_cubit.dart @@ -0,0 +1,57 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/tracking/data/tracking_repository.dart'; +import 'package:flux/features/tracking/models/tracking_model.dart'; + +// Stati base: initial, loading, loaded, error +class TrackingState { + final bool isLoading; + final List logs; + + TrackingState({this.isLoading = false, this.logs = const []}); +} + +class TrackingCubit extends Cubit { + final TrackingRepository _repo; + final String parentId; + final TrackingParentType parentType; + final String companyId; + + TrackingCubit({ + required TrackingRepository repo, + required this.parentId, + required this.parentType, + required this.companyId, + }) : _repo = repo, + super(TrackingState()) { + loadTrackings(); + } + + Future loadTrackings() async { + emit(TrackingState(isLoading: true, logs: state.logs)); + final trackings = await _repo.getTrackingsByParent( + parentId: parentId, + parentType: parentType, + ); + emit(TrackingState(isLoading: false, logs: trackings)); + } + + Future addManualNote( + String message, + bool isInternal, { + String? staffId, + }) async { + // Aggiungiamo un feedback visivo immediato (Optimistic UI) se vogliamo, + // oppure semplicemente mostriamo il loading + await _repo.logQuickEvent( + companyId: companyId, + message: message, + type: TrackingType.manualNote, + parentId: parentId, + parentType: parentType, + staffId: staffId, + isInternal: isInternal, + ); + // Ricarichiamo la lista fresca dal server + await loadTrackings(); + } +} diff --git a/lib/features/tracking/data/tracking_repository.dart b/lib/features/tracking/data/tracking_repository.dart new file mode 100644 index 0000000..33160f1 --- /dev/null +++ b/lib/features/tracking/data/tracking_repository.dart @@ -0,0 +1,57 @@ +import 'package:flux/features/tracking/models/tracking_model.dart'; +import 'package:get_it/get_it.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class TrackingRepository { + final SupabaseClient _supabase = GetIt.I.get(); + + TrackingRepository(); + + /// Recupera la cronologia di un'entità (Ticket o Operazione) + Future> getTrackingsByParent({ + required String parentId, // <-- Reso obbligatorio + required TrackingParentType parentType, // <-- Reso obbligatorio + }) async { + // Facciamo la query con la JOIN per recuperare il nome dello staff al volo + final response = await _supabase + .from('tracking') + .select('*, staff_member(name)') + .eq('parent_id', parentId) + .eq('parent_type', parentType.name) + .order( + 'created_at', + ascending: true, + ); // ascending: true per avere la timeline dall'alto (vecchi) al basso (nuovi) + + return response.map((map) => TrackingModel.fromMap(map)).toList(); + } + + /// Inserisce un nuovo evento di tracking + Future logEvent(TrackingModel tracking) async { + await _supabase.from('tracking').insert(tracking.toMap()); + } + + /// Metodo helper rapido per loggare un cambio di stato o una nota + Future logQuickEvent({ + required String companyId, + required String message, + required TrackingType type, + required String parentId, // <-- Reso obbligatorio + required TrackingParentType parentType, // <-- Reso obbligatorio + String? staffId, + bool isInternal = true, + }) async { + final log = TrackingModel( + createdAt: + DateTime.now(), // Questo verrà ignorato dal toMap in fase di insert, ma serve al modello + companyId: companyId, + staffId: staffId, + parentId: parentId, + parentType: parentType, + eventType: type, + isInternal: isInternal, + message: message, + ); + await logEvent(log); + } +} diff --git a/lib/features/tracking/models/tracking_model.dart b/lib/features/tracking/models/tracking_model.dart new file mode 100644 index 0000000..b11ebf6 --- /dev/null +++ b/lib/features/tracking/models/tracking_model.dart @@ -0,0 +1,132 @@ +import 'package:equatable/equatable.dart'; + +enum TrackingType { + statusChange, + manualNote, + systemAlert, + customerContact, + assignment; + + static TrackingType fromString(String value) { + return TrackingType.values.firstWhere( + (e) => e.name == value, + orElse: () => TrackingType.manualNote, + ); + } +} + +enum TrackingParentType { + ticket, + operation; + + String get value => name; + + static TrackingParentType fromString(String val) { + return TrackingParentType.values.firstWhere( + (e) => e.name == val, + orElse: () => TrackingParentType.ticket, // Default di sicurezza + ); + } +} + +class TrackingModel extends Equatable { + final String? id; + final DateTime createdAt; + final String companyId; + final String? staffId; + final String? staffName; // Per non fare mille join, lo prendiamo dal repo + final String parentId; + final TrackingParentType parentType; + final TrackingType eventType; + final bool isInternal; + final String message; + + const TrackingModel({ + this.id, + required this.createdAt, + required this.companyId, + this.staffId, + this.staffName, + required this.parentId, + required this.parentType, + required this.eventType, + required this.isInternal, + required this.message, + }); + + TrackingModel copyWith({ + String? id, + DateTime? createdAt, + String? companyId, + String? staffId, + String? staffName, + TrackingParentType? parentType, + String? parentId, + TrackingType? eventType, + bool? isInternal, + String? message, + }) { + return TrackingModel( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + companyId: companyId ?? this.companyId, + staffId: staffId ?? this.staffId, + staffName: staffName ?? this.staffName, + parentId: parentId ?? this.parentId, + parentType: parentType ?? this.parentType, + eventType: eventType ?? this.eventType, + isInternal: isInternal ?? this.isInternal, + message: message ?? this.message, + ); + } + + factory TrackingModel.fromMap(Map map) { + return TrackingModel( + id: map['id'], + createdAt: DateTime.parse(map['created_at']), + companyId: map['company_id'], + staffId: map['staff_id'], + staffName: map['staff_member']?['name'], // Se fai la join su staff_member + parentId: map['parent_id'] as String, + parentType: TrackingParentType.fromString(map['parent_type']), + eventType: TrackingType.fromString(map['event_type']), + isInternal: map['is_internal'] ?? true, + message: map['message'], + ); + } + + Map toMap() { + final map = { + 'company_id': companyId, + 'staff_id': staffId, + 'parent_id': parentId, + 'parent_type': parentType.name, + 'event_type': eventType.name, + 'is_internal': isInternal, + 'message': message, + }; + + // Aggiungiamo id e data SOLO se stiamo aggiornando un record esistente. + // In fase di creazione (insert), li omettiamo così Supabase usa i valori di default (gen_random_uuid e now()). + if (id != null) { + map['id'] = id; + map['created_at'] = createdAt.toIso8601String(); + } + + return map; + } + + @override + List get props => [ + id, + createdAt, + companyId, + staffId, + staffName, + parentId, + parentType, + eventType, + isInternal, + message, + ]; +}