feat: merge branche IA (beta) - Intégration assistant IA logisticien Gemini
This commit is contained in:
@@ -47,3 +47,4 @@ lib/config/env.dev.dart
|
|||||||
functions/.env
|
functions/.env
|
||||||
.env
|
.env
|
||||||
env.dart
|
env.dart
|
||||||
|
functions/.env.local
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ SMTP_PASS="aL8@Rx8xqFrNij$a"
|
|||||||
# URL de l'application
|
# URL de l'application
|
||||||
APP_URL="https://app.em2events.fr"
|
APP_URL="https://app.em2events.fr"
|
||||||
|
|
||||||
|
GEMINI_API_KEY="AIzaSyBdBdjFLma2pLenmFBlqZHArS4GVF-mclo"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,11 @@
|
|||||||
* Architecture backend sécurisée avec authentification et permissions
|
* Architecture backend sécurisée avec authentification et permissions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Charger les variables d'environnement depuis .env
|
// Charger les variables d'environnement depuis .env.local (développement)
|
||||||
require('dotenv').config();
|
// ou .env (production Firebase)
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '.env.local') });
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||||||
|
|
||||||
const { onRequest, onCall } = require("firebase-functions/v2/https");
|
const { onRequest, onCall } = require("firebase-functions/v2/https");
|
||||||
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
||||||
@@ -17,6 +20,7 @@ const { Storage } = require('@google-cloud/storage');
|
|||||||
const auth = require('./utils/auth');
|
const auth = require('./utils/auth');
|
||||||
const helpers = require('./utils/helpers');
|
const helpers = require('./utils/helpers');
|
||||||
const { generateTTS } = require('./generateTTS');
|
const { generateTTS } = require('./generateTTS');
|
||||||
|
const { handleAiEquipmentProposal } = require('./aiEquipmentProposal');
|
||||||
|
|
||||||
// Initialisation sécurisée
|
// Initialisation sécurisée
|
||||||
if (!admin.apps.length) {
|
if (!admin.apps.length) {
|
||||||
@@ -33,6 +37,13 @@ const httpOptions = {
|
|||||||
// Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS
|
// Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Options dédiées pour les traitements IA potentiellement longs.
|
||||||
|
const aiHttpOptions = {
|
||||||
|
...httpOptions,
|
||||||
|
timeoutSeconds: 300,
|
||||||
|
memory: '1GiB',
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CORS Middleware
|
// CORS Middleware
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -4544,3 +4555,20 @@ exports.generateTTSV2 = onRequest(ttsHttpOptions, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI - Assistant Logisticien (Gemini avec function calling côté serveur)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
exports.aiEquipmentProposal = onRequest(aiHttpOptions, withCors(async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Authentification Firebase obligatoire (pas de clé API côté client)
|
||||||
|
await auth.authenticateUser(req);
|
||||||
|
await handleAiEquipmentProposal(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[aiEquipmentProposal] Error:', error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
|||||||
Generated
+19
-5
@@ -8,11 +8,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/storage": "^7.18.0",
|
"@google-cloud/storage": "^7.18.0",
|
||||||
"@google-cloud/text-to-speech": "^5.4.0",
|
"@google-cloud/text-to-speech": "^5.4.0",
|
||||||
|
"@google/generative-ai": "^0.21.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"envdot": "^0.0.3",
|
"envdot": "^0.0.3",
|
||||||
"firebase-admin": "^12.6.0",
|
"firebase-admin": "^12.6.0",
|
||||||
"firebase-functions": "^7.0.3",
|
"firebase-functions": "^7.2.5",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"nodemailer": "^6.10.1"
|
"nodemailer": "^6.10.1"
|
||||||
},
|
},
|
||||||
@@ -785,6 +786,15 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@google/generative-ai": {
|
||||||
|
"version": "0.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz",
|
||||||
|
"integrity": "sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@grpc/grpc-js": {
|
"node_modules/@grpc/grpc-js": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
||||||
@@ -3354,9 +3364,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/firebase-functions": {
|
"node_modules/firebase-functions": {
|
||||||
"version": "7.0.3",
|
"version": "7.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz",
|
||||||
"integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==",
|
"integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3375,7 +3385,8 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@apollo/server": "^5.2.0",
|
"@apollo/server": "^5.2.0",
|
||||||
"@as-integrations/express4": "^1.1.2",
|
"@as-integrations/express4": "^1.1.2",
|
||||||
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0"
|
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0",
|
||||||
|
"graphql": "^16.12.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@apollo/server": {
|
"@apollo/server": {
|
||||||
@@ -3383,6 +3394,9 @@
|
|||||||
},
|
},
|
||||||
"@as-integrations/express4": {
|
"@as-integrations/express4": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"graphql": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,11 +16,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/storage": "^7.18.0",
|
"@google-cloud/storage": "^7.18.0",
|
||||||
"@google-cloud/text-to-speech": "^5.4.0",
|
"@google-cloud/text-to-speech": "^5.4.0",
|
||||||
|
"@google/generative-ai": "^0.21.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"envdot": "^0.0.3",
|
"envdot": "^0.0.3",
|
||||||
"firebase-admin": "^12.6.0",
|
"firebase-admin": "^12.6.0",
|
||||||
"firebase-functions": "^7.0.3",
|
"firebase-functions": "^7.2.5",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"nodemailer": "^6.10.1"
|
"nodemailer": "^6.10.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ ReturnStatus returnStatusFromString(String? status) {
|
|||||||
class EventEquipment {
|
class EventEquipment {
|
||||||
final String equipmentId; // ID de l'équipement
|
final String equipmentId; // ID de l'équipement
|
||||||
final int quantity; // Quantité initiale assignée
|
final int quantity; // Quantité initiale assignée
|
||||||
|
final String? rationale; // Explication/Justification (ex: IA alternative)
|
||||||
final bool isPrepared; // Validé en préparation
|
final bool isPrepared; // Validé en préparation
|
||||||
final bool isLoaded; // Validé au chargement
|
final bool isLoaded; // Validé au chargement
|
||||||
final bool isUnloaded; // Validé au déchargement
|
final bool isUnloaded; // Validé au déchargement
|
||||||
@@ -194,6 +195,7 @@ class EventEquipment {
|
|||||||
EventEquipment({
|
EventEquipment({
|
||||||
required this.equipmentId,
|
required this.equipmentId,
|
||||||
this.quantity = 1,
|
this.quantity = 1,
|
||||||
|
this.rationale,
|
||||||
this.isPrepared = false,
|
this.isPrepared = false,
|
||||||
this.isLoaded = false,
|
this.isLoaded = false,
|
||||||
this.isUnloaded = false,
|
this.isUnloaded = false,
|
||||||
@@ -212,6 +214,7 @@ class EventEquipment {
|
|||||||
return EventEquipment(
|
return EventEquipment(
|
||||||
equipmentId: map['equipmentId'] ?? '',
|
equipmentId: map['equipmentId'] ?? '',
|
||||||
quantity: map['quantity'] ?? 1,
|
quantity: map['quantity'] ?? 1,
|
||||||
|
rationale: map['rationale'],
|
||||||
isPrepared: map['isPrepared'] ?? false,
|
isPrepared: map['isPrepared'] ?? false,
|
||||||
isLoaded: map['isLoaded'] ?? false,
|
isLoaded: map['isLoaded'] ?? false,
|
||||||
isUnloaded: map['isUnloaded'] ?? false,
|
isUnloaded: map['isUnloaded'] ?? false,
|
||||||
@@ -231,6 +234,7 @@ class EventEquipment {
|
|||||||
return {
|
return {
|
||||||
'equipmentId': equipmentId,
|
'equipmentId': equipmentId,
|
||||||
'quantity': quantity,
|
'quantity': quantity,
|
||||||
|
'rationale': rationale,
|
||||||
'isPrepared': isPrepared,
|
'isPrepared': isPrepared,
|
||||||
'isLoaded': isLoaded,
|
'isLoaded': isLoaded,
|
||||||
'isUnloaded': isUnloaded,
|
'isUnloaded': isUnloaded,
|
||||||
@@ -249,6 +253,7 @@ class EventEquipment {
|
|||||||
EventEquipment copyWith({
|
EventEquipment copyWith({
|
||||||
String? equipmentId,
|
String? equipmentId,
|
||||||
int? quantity,
|
int? quantity,
|
||||||
|
String? rationale,
|
||||||
bool? isPrepared,
|
bool? isPrepared,
|
||||||
bool? isLoaded,
|
bool? isLoaded,
|
||||||
bool? isUnloaded,
|
bool? isUnloaded,
|
||||||
@@ -265,6 +270,7 @@ class EventEquipment {
|
|||||||
return EventEquipment(
|
return EventEquipment(
|
||||||
equipmentId: equipmentId ?? this.equipmentId,
|
equipmentId: equipmentId ?? this.equipmentId,
|
||||||
quantity: quantity ?? this.quantity,
|
quantity: quantity ?? this.quantity,
|
||||||
|
rationale: rationale ?? this.rationale,
|
||||||
isPrepared: isPrepared ?? this.isPrepared,
|
isPrepared: isPrepared ?? this.isPrepared,
|
||||||
isLoaded: isLoaded ?? this.isLoaded,
|
isLoaded: isLoaded ?? this.isLoaded,
|
||||||
isUnloaded: isUnloaded ?? this.isUnloaded,
|
isUnloaded: isUnloaded ?? this.isUnloaded,
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Représente un tour de conversation dans le chat.
|
||||||
|
class AiAssistantChatTurn {
|
||||||
|
final bool isUser;
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const AiAssistantChatTurn({required this.isUser, required this.text});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Document à attacher pour demander à l'IA d'analyser un devis, etc.
|
||||||
|
class AiEquipmentDocument {
|
||||||
|
final String base64Data;
|
||||||
|
final String mimeType;
|
||||||
|
final String? fileName;
|
||||||
|
|
||||||
|
const AiEquipmentDocument({
|
||||||
|
required this.base64Data,
|
||||||
|
required this.mimeType,
|
||||||
|
this.fileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un item proposé par l'IA dans la liste de matériel.
|
||||||
|
class AiEquipmentProposalItem {
|
||||||
|
final String equipmentId;
|
||||||
|
final int quantity;
|
||||||
|
final String rationale;
|
||||||
|
|
||||||
|
const AiEquipmentProposalItem({
|
||||||
|
required this.equipmentId,
|
||||||
|
required this.quantity,
|
||||||
|
required this.rationale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Métadonnées pour un container proposé par l'IA.
|
||||||
|
class AiEquipmentProposalContainer {
|
||||||
|
final String containerId;
|
||||||
|
final String rationale;
|
||||||
|
final List<String> equipmentIds;
|
||||||
|
final List<String> matchingEquipmentIds;
|
||||||
|
final List<String> missingEquipmentIds;
|
||||||
|
final bool partial;
|
||||||
|
final bool? available;
|
||||||
|
final dynamic availabilityDetail;
|
||||||
|
|
||||||
|
const AiEquipmentProposalContainer({
|
||||||
|
required this.containerId,
|
||||||
|
required this.rationale,
|
||||||
|
this.equipmentIds = const [],
|
||||||
|
this.matchingEquipmentIds = const [],
|
||||||
|
this.missingEquipmentIds = const [],
|
||||||
|
this.partial = false,
|
||||||
|
this.available,
|
||||||
|
this.availabilityDetail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proposition complète retournée par l'IA.
|
||||||
|
class AiEquipmentProposal {
|
||||||
|
final String summary;
|
||||||
|
final List<AiEquipmentProposalItem> items;
|
||||||
|
|
||||||
|
/// Équipements individuels prêts à être injectés dans l'état local de l'événement.
|
||||||
|
final List<EventEquipment> asEventEquipment;
|
||||||
|
|
||||||
|
/// Containers (métadonnées) proposés par l'IA.
|
||||||
|
final List<AiEquipmentProposalContainer> containers;
|
||||||
|
|
||||||
|
List<String> get containerIds => containers.map((c) => c.containerId).toList();
|
||||||
|
|
||||||
|
const AiEquipmentProposal({
|
||||||
|
required this.summary,
|
||||||
|
required this.items,
|
||||||
|
required this.asEventEquipment,
|
||||||
|
required this.containers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse complète de l'assistant IA (message + proposition optionnelle).
|
||||||
|
class AiEquipmentAssistantResponse {
|
||||||
|
final String assistantMessage;
|
||||||
|
final AiEquipmentProposal? proposal;
|
||||||
|
final List<String> debugLogs;
|
||||||
|
|
||||||
|
const AiEquipmentAssistantResponse({
|
||||||
|
required this.assistantMessage,
|
||||||
|
this.proposal,
|
||||||
|
this.debugLogs = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service assistant IA logisticien.
|
||||||
|
/// Délègue tous les appels Gemini à la Cloud Function [aiEquipmentProposal].
|
||||||
|
/// L'authentification Firebase (token Bearer) suffit — aucune clé API côté client.
|
||||||
|
class AiEquipmentAssistantService {
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
AiEquipmentAssistantService({ApiService? apiService})
|
||||||
|
: _apiService = apiService ?? FirebaseFunctionsApiService();
|
||||||
|
|
||||||
|
/// Envoie un message et retourne la réponse de l'assistant IA.
|
||||||
|
Future<AiEquipmentAssistantResponse> generateProposal({
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
required List<AiAssistantChatTurn> history,
|
||||||
|
required String userMessage,
|
||||||
|
String? eventTypeId,
|
||||||
|
String? excludeEventId,
|
||||||
|
List<EventEquipment> currentAssignedEquipment = const [],
|
||||||
|
List<EventEquipment> workingProposalEquipment = const [],
|
||||||
|
AiEquipmentDocument? document,
|
||||||
|
}) async {
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'startDate': startDate.toIso8601String(),
|
||||||
|
'endDate': endDate.toIso8601String(),
|
||||||
|
'userMessage': userMessage.trim(),
|
||||||
|
'history': history
|
||||||
|
.where((turn) => turn.text.trim().isNotEmpty)
|
||||||
|
.map((turn) => {'isUser': turn.isUser, 'text': turn.text.trim()})
|
||||||
|
.toList(),
|
||||||
|
'currentEquipment': currentAssignedEquipment
|
||||||
|
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
|
||||||
|
.toList(),
|
||||||
|
'workingProposal': workingProposalEquipment
|
||||||
|
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (eventTypeId != null) payload['eventTypeId'] = eventTypeId;
|
||||||
|
if (excludeEventId != null) payload['excludeEventId'] = excludeEventId;
|
||||||
|
|
||||||
|
if (document != null) {
|
||||||
|
payload['document'] = {
|
||||||
|
'mimeType': document.mimeType,
|
||||||
|
'data': document.base64Data,
|
||||||
|
if (document.fileName != null) 'fileName': document.fileName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DebugLog.info('[AiEquipmentAssistantService] Calling aiEquipmentProposal Cloud Function');
|
||||||
|
|
||||||
|
final result = await _apiService.call('aiEquipmentProposal', payload);
|
||||||
|
final assistantMessage = result['assistantMessage']?.toString().trim() ?? '';
|
||||||
|
final proposal = _parseProposal(result['proposal']);
|
||||||
|
|
||||||
|
final rawLogs = result['debugLogs'];
|
||||||
|
final debugLogs = (rawLogs is List) ? rawLogs.map((e) => e.toString()).toList() : <String>[];
|
||||||
|
|
||||||
|
DebugLog.info(
|
||||||
|
'[AiEquipmentAssistantService] Response received, items: ${proposal?.items.length ?? 0}',
|
||||||
|
);
|
||||||
|
|
||||||
|
return AiEquipmentAssistantResponse(
|
||||||
|
assistantMessage: assistantMessage.isNotEmpty
|
||||||
|
? assistantMessage
|
||||||
|
: 'Je n\'ai pas pu générer de réponse.',
|
||||||
|
proposal: proposal,
|
||||||
|
debugLogs: debugLogs,
|
||||||
|
);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
DebugLog.error('[AiEquipmentAssistantService] API error', e);
|
||||||
|
if (e.isUnauthorized) {
|
||||||
|
throw Exception('Vous n\'êtes pas authentifié. Reconnectez-vous et réessayez.');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur du service IA (${e.statusCode}): ${e.message}');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AiEquipmentAssistantService] Error', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AiEquipmentProposal? _parseProposal(dynamic rawProposal) {
|
||||||
|
if (rawProposal == null || rawProposal is! Map<String, dynamic>) return null;
|
||||||
|
|
||||||
|
final proposalItems = <AiEquipmentProposalItem>[];
|
||||||
|
final eventEquipmentList = <EventEquipment>[];
|
||||||
|
// legacy containerIds variable removed (we now use containersMeta)
|
||||||
|
|
||||||
|
final rawItems = rawProposal['items'];
|
||||||
|
if (rawItems is List) {
|
||||||
|
for (final rawItem in rawItems) {
|
||||||
|
if (rawItem is! Map) continue;
|
||||||
|
final item = Map<String, dynamic>.from(rawItem);
|
||||||
|
|
||||||
|
final equipmentId = item['equipmentId']?.toString().trim() ?? '';
|
||||||
|
final quantity = int.tryParse(item['quantity']?.toString() ?? '1') ?? 1;
|
||||||
|
|
||||||
|
if (equipmentId.isEmpty || quantity <= 0) continue;
|
||||||
|
|
||||||
|
final rationale = item['rationale']?.toString().trim() ?? 'Proposition IA';
|
||||||
|
|
||||||
|
proposalItems.add(AiEquipmentProposalItem(
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
quantity: quantity,
|
||||||
|
rationale: rationale,
|
||||||
|
));
|
||||||
|
eventEquipmentList.add(EventEquipment(
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
quantity: quantity,
|
||||||
|
rationale: rationale,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final containersMeta = <AiEquipmentProposalContainer>[];
|
||||||
|
final rawContainers = rawProposal['containers'];
|
||||||
|
if (rawContainers is List) {
|
||||||
|
for (final rawContainer in rawContainers) {
|
||||||
|
if (rawContainer is String) {
|
||||||
|
final cid = rawContainer.toString().trim();
|
||||||
|
if (cid.isNotEmpty) {
|
||||||
|
containersMeta.add(AiEquipmentProposalContainer(containerId: cid, rationale: 'Proposition IA'));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawContainer is! Map) continue;
|
||||||
|
final container = Map<String, dynamic>.from(rawContainer);
|
||||||
|
final containerId = container['containerId']?.toString().trim() ?? '';
|
||||||
|
if (containerId.isEmpty) continue;
|
||||||
|
|
||||||
|
final rationale = container['rationale']?.toString().trim() ?? 'Proposition IA';
|
||||||
|
final equipmentIds = <String>[];
|
||||||
|
final matching = <String>[];
|
||||||
|
final missing = <String>[];
|
||||||
|
|
||||||
|
if (container['equipmentIds'] is List) {
|
||||||
|
for (final v in container['equipmentIds']) {
|
||||||
|
final s = v == null ? null : v.toString().trim();
|
||||||
|
if (s != null && s.isNotEmpty) equipmentIds.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (container['matchingEquipmentIds'] is List) {
|
||||||
|
for (final v in container['matchingEquipmentIds']) {
|
||||||
|
final s = v == null ? null : v.toString().trim();
|
||||||
|
if (s != null && s.isNotEmpty) matching.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (container['missingEquipmentIds'] is List) {
|
||||||
|
for (final v in container['missingEquipmentIds']) {
|
||||||
|
final s = v == null ? null : v.toString().trim();
|
||||||
|
if (s != null && s.isNotEmpty) missing.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final partial = container['partial'] is bool ? container['partial'] as bool : (missing.isNotEmpty);
|
||||||
|
final available = container.containsKey('available') ? (container['available'] is bool ? container['available'] as bool : null) : null;
|
||||||
|
final availabilityDetail = container.containsKey('availabilityDetail') ? container['availabilityDetail'] : null;
|
||||||
|
|
||||||
|
containersMeta.add(AiEquipmentProposalContainer(
|
||||||
|
containerId: containerId,
|
||||||
|
rationale: rationale,
|
||||||
|
equipmentIds: equipmentIds,
|
||||||
|
matchingEquipmentIds: matching,
|
||||||
|
missingEquipmentIds: missing,
|
||||||
|
partial: partial,
|
||||||
|
available: available,
|
||||||
|
availabilityDetail: availabilityDetail,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proposalItems.isEmpty && containersMeta.isEmpty) return null;
|
||||||
|
|
||||||
|
return AiEquipmentProposal(
|
||||||
|
summary: rawProposal['summary']?.toString().trim().isNotEmpty == true
|
||||||
|
? rawProposal['summary'].toString().trim()
|
||||||
|
: 'Proposition matériel générée automatiquement.',
|
||||||
|
items: proposalItems,
|
||||||
|
asEventEquipment: eventEquipmentList,
|
||||||
|
containers: containersMeta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -553,6 +553,156 @@ class DataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recherche des équipements pour l'assistant IA avec fallback paginé.
|
||||||
|
Future<List<Map<String, dynamic>>> searchEquipmentsForAssistant({
|
||||||
|
required String query,
|
||||||
|
int limit = 12,
|
||||||
|
}) async {
|
||||||
|
final normalizedQuery = query.trim();
|
||||||
|
if (normalizedQuery.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final quickResults = await quickSearch(
|
||||||
|
normalizedQuery,
|
||||||
|
limit: limit,
|
||||||
|
includeEquipments: true,
|
||||||
|
includeContainers: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final equipmentResults = quickResults
|
||||||
|
.where((item) =>
|
||||||
|
(item['type']?.toString().toLowerCase() ?? '') == 'equipment')
|
||||||
|
.map(_normalizeAssistantEquipment)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (equipmentResults.isNotEmpty) {
|
||||||
|
return equipmentResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
final paginated = await getEquipmentsPaginated(
|
||||||
|
limit: limit,
|
||||||
|
searchQuery: normalizedQuery,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final equipments =
|
||||||
|
paginated['equipments'] as List<Map<String, dynamic>>? ?? [];
|
||||||
|
return equipments.map(_normalizeAssistantEquipment).toList();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[DataService] Error in searchEquipmentsForAssistant', e);
|
||||||
|
throw Exception('Erreur lors de la recherche de matériel: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie la disponibilité d'un équipement dans un format normalisé pour l'IA.
|
||||||
|
Future<Map<String, dynamic>> checkEquipmentAvailabilityForAssistant({
|
||||||
|
required String equipmentId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await checkEquipmentAvailability(
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
excludeEventId: excludeEventId,
|
||||||
|
);
|
||||||
|
|
||||||
|
final available = result['available'] as bool? ?? true;
|
||||||
|
final conflicts = (result['conflicts'] as List<dynamic>? ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((conflict) {
|
||||||
|
final eventData =
|
||||||
|
conflict['eventData'] as Map<String, dynamic>? ?? const {};
|
||||||
|
final eventName =
|
||||||
|
(eventData['Name'] ?? conflict['eventName'] ?? '').toString();
|
||||||
|
return {
|
||||||
|
'eventId': conflict['eventId']?.toString() ?? '',
|
||||||
|
'eventName': eventName,
|
||||||
|
'overlapDays': conflict['overlapDays'] as int? ?? 0,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'available': available,
|
||||||
|
'conflictCount': conflicts.length,
|
||||||
|
'conflicts': conflicts,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error(
|
||||||
|
'[DataService] Error in checkEquipmentAvailabilityForAssistant', e);
|
||||||
|
throw Exception('Erreur lors de la vérification de disponibilité: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne des événements passés, idéalement filtrés par type d'événement.
|
||||||
|
Future<List<Map<String, dynamic>>> getPastEventsForAssistant({
|
||||||
|
String? eventTypeId,
|
||||||
|
int limit = 10,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final events = eventTypeId != null && eventTypeId.isNotEmpty
|
||||||
|
? await getEventsByEventType(eventTypeId)
|
||||||
|
: (await getEvents())['events'] as List<Map<String, dynamic>>? ?? [];
|
||||||
|
|
||||||
|
final pastEvents = events.where((event) {
|
||||||
|
final endDate = _parseEventDate(event['EndDateTime']);
|
||||||
|
return endDate != null && endDate.isBefore(now);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
pastEvents.sort((a, b) {
|
||||||
|
final aDate = _parseEventDate(a['StartDateTime']) ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
final bDate = _parseEventDate(b['StartDateTime']) ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
return bDate.compareTo(aDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
return pastEvents.take(limit).map((event) {
|
||||||
|
final assignedEquipment =
|
||||||
|
event['assignedEquipment'] as List<dynamic>? ?? const [];
|
||||||
|
return {
|
||||||
|
'id': event['id']?.toString() ?? '',
|
||||||
|
'name': (event['Name'] ?? '').toString(),
|
||||||
|
'startDate': event['StartDateTime']?.toString() ?? '',
|
||||||
|
'endDate': event['EndDateTime']?.toString() ?? '',
|
||||||
|
'assignedEquipment': assignedEquipment,
|
||||||
|
'assignedEquipmentCount': assignedEquipment.length,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[DataService] Error in getPastEventsForAssistant', e);
|
||||||
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération des événements passés: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _normalizeAssistantEquipment(Map<String, dynamic> item) {
|
||||||
|
return {
|
||||||
|
'id': (item['id'] ?? '').toString(),
|
||||||
|
'name': (item['name'] ?? item['id'] ?? '').toString(),
|
||||||
|
'category': (item['category'] ?? '').toString(),
|
||||||
|
'status': (item['status'] ?? '').toString(),
|
||||||
|
'brand': item['brand']?.toString(),
|
||||||
|
'model': item['model']?.toString(),
|
||||||
|
'availableQuantity': item['availableQuantity'],
|
||||||
|
'totalQuantity': item['totalQuantity'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _parseEventDate(dynamic rawValue) {
|
||||||
|
if (rawValue is String) {
|
||||||
|
return DateTime.tryParse(rawValue);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// USER - Current User
|
// USER - Current User
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import 'package:em2rp/providers/container_provider.dart';
|
|||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/utils/id_generator.dart';
|
import 'package:em2rp/utils/id_generator.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class ContainerFormPage extends StatefulWidget {
|
class ContainerFormPage extends StatefulWidget {
|
||||||
final ContainerModel? container;
|
final ContainerModel? container;
|
||||||
@@ -650,25 +652,86 @@ class _EquipmentSelectorDialog extends StatefulWidget {
|
|||||||
|
|
||||||
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
EquipmentCategory? _filterCategory;
|
EquipmentCategory? _filterCategory;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
late Set<String> _tempSelectedIds;
|
late Set<String> _tempSelectedIds;
|
||||||
late final Future<void> _loadingFuture;
|
|
||||||
|
final List<EquipmentModel> _paginatedEquipments = [];
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
bool _hasMoreEquipments = true;
|
||||||
|
String? _lastEquipmentId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Créer une copie temporaire des IDs sélectionnés
|
// Créer une copie temporaire des IDs sélectionnés
|
||||||
_tempSelectedIds = Set<String>.from(widget.selectedIds);
|
_tempSelectedIds = Set<String>.from(widget.selectedIds);
|
||||||
_loadingFuture = widget.equipmentProvider.loadEquipments();
|
_scrollController.addListener(_onScroll);
|
||||||
|
_loadNextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_isLoadingMore) return;
|
||||||
|
if (_scrollController.hasClients &&
|
||||||
|
_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) {
|
||||||
|
if (_hasMoreEquipments) {
|
||||||
|
_loadNextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadNextPage() async {
|
||||||
|
if (_isLoadingMore || !_hasMoreEquipments) return;
|
||||||
|
setState(() => _isLoadingMore = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _dataService.getEquipmentsPaginated(
|
||||||
|
limit: 50,
|
||||||
|
startAfter: _lastEquipmentId,
|
||||||
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||||
|
category: _filterCategory != null ? equipmentCategoryToString(_filterCategory!) : null,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final newEquipments = (result['equipments'] as List<dynamic>)
|
||||||
|
.map((data) => EquipmentModel.fromMap(data as Map<String, dynamic>, data['id'] as String))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_paginatedEquipments.addAll(newEquipments);
|
||||||
|
_hasMoreEquipments = result['hasMore'] as bool? ?? false;
|
||||||
|
_lastEquipmentId = result['lastVisible'] as String?;
|
||||||
|
_isLoadingMore = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoadingMore = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reloadData() async {
|
||||||
|
setState(() {
|
||||||
|
_paginatedEquipments.clear();
|
||||||
|
_lastEquipmentId = null;
|
||||||
|
_hasMoreEquipments = true;
|
||||||
|
});
|
||||||
|
await _loadNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Dialog(
|
return Dialog(
|
||||||
@@ -718,6 +781,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchQuery = '';
|
_searchQuery = '';
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -726,6 +790,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchQuery = value;
|
_searchQuery = value;
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -743,6 +808,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_filterCategory = null;
|
_filterCategory = null;
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
selectedColor: AppColors.rouge,
|
selectedColor: AppColors.rouge,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
@@ -761,6 +827,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_filterCategory = selected ? category : null;
|
_filterCategory = selected ? category : null;
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
selectedColor: AppColors.rouge,
|
selectedColor: AppColors.rouge,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
@@ -780,7 +847,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.rouge.withOpacity(0.1),
|
color: AppColors.rouge.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -798,90 +865,62 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
|
|
||||||
// Liste des équipements
|
// Liste des équipements
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<void>(
|
child: _paginatedEquipments.isEmpty && !_isLoadingMore
|
||||||
future: _loadingFuture,
|
? const Center(child: Text('Aucun équipement trouvé'))
|
||||||
builder: (context, snapshot) {
|
: ListView.builder(
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
controller: _scrollController,
|
||||||
return const Center(child: CircularProgressIndicator());
|
itemCount: _paginatedEquipments.length + (_isLoadingMore ? 1 : 0),
|
||||||
}
|
itemBuilder: (context, index) {
|
||||||
|
if (index == _paginatedEquipments.length) {
|
||||||
if (snapshot.hasError) {
|
return const Center(
|
||||||
return Center(child: Text('Erreur: ${snapshot.error}'));
|
child: Padding(
|
||||||
}
|
padding: EdgeInsets.all(8.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
var equipment = List<EquipmentModel>.from(
|
|
||||||
widget.equipmentProvider.allEquipment,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filtrer par catégorie
|
|
||||||
if (_filterCategory != null) {
|
|
||||||
equipment = equipment
|
|
||||||
.where((e) => e.category == _filterCategory)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtrer par recherche
|
|
||||||
if (_searchQuery.isNotEmpty) {
|
|
||||||
final query = _searchQuery.toLowerCase();
|
|
||||||
equipment = equipment.where((e) {
|
|
||||||
return e.id.toLowerCase().contains(query) ||
|
|
||||||
(e.brand?.toLowerCase().contains(query) ?? false) ||
|
|
||||||
(e.model?.toLowerCase().contains(query) ?? false);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (equipment.isEmpty) {
|
|
||||||
return const Center(
|
|
||||||
child: Text('Aucun équipement trouvé'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: equipment.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = equipment[index];
|
|
||||||
final isSelected = _tempSelectedIds.contains(item.id);
|
|
||||||
|
|
||||||
return CheckboxListTile(
|
|
||||||
value: isSelected,
|
|
||||||
onChanged: (selected) {
|
|
||||||
setState(() {
|
|
||||||
if (selected == true) {
|
|
||||||
_tempSelectedIds.add(item.id);
|
|
||||||
} else {
|
|
||||||
_tempSelectedIds.remove(item.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
title: Text(
|
|
||||||
item.id,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (item.brand != null || item.model != null)
|
|
||||||
Text('${item.brand ?? ''} ${item.model ?? ''}'),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_getCategoryLabel(item.category),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
}
|
||||||
secondary: Icon(
|
|
||||||
_getCategoryIcon(item.category),
|
final item = _paginatedEquipments[index];
|
||||||
color: AppColors.rouge,
|
final isSelected = _tempSelectedIds.contains(item.id);
|
||||||
),
|
|
||||||
activeColor: AppColors.rouge,
|
return CheckboxListTile(
|
||||||
);
|
value: isSelected,
|
||||||
},
|
onChanged: (selected) {
|
||||||
);
|
setState(() {
|
||||||
},
|
if (selected == true) {
|
||||||
),
|
_tempSelectedIds.add(item.id);
|
||||||
|
} else {
|
||||||
|
_tempSelectedIds.remove(item.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
title: Text(
|
||||||
|
item.id,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (item.brand != null || item.model != null)
|
||||||
|
Text('${item.brand ?? ''} ${item.model ?? ''}'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_getCategoryLabel(item.category),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
secondary: Icon(
|
||||||
|
_getCategoryIcon(item.category),
|
||||||
|
color: AppColors.rouge,
|
||||||
|
),
|
||||||
|
activeColor: AppColors.rouge,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Boutons d'action
|
// Boutons d'action
|
||||||
|
|||||||
@@ -163,11 +163,11 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _identifierController,
|
controller: _identifierController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Identifiant *',
|
labelText: 'Identifiant (Laissez vide pour auto-génération) *',
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
prefixIcon: const Icon(Icons.tag),
|
prefixIcon: const Icon(Icons.tag),
|
||||||
hintText: isEditing ? null : 'Laissez vide pour générer automatiquement',
|
hintText: isEditing ? null : 'Auto-attribué par défaut',
|
||||||
helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}',
|
helperText: isEditing ? 'Non modifiable' : 'Génération auto recommandée basée sur Marque/Modèle',
|
||||||
),
|
),
|
||||||
enabled: !isEditing,
|
enabled: !isEditing,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final success = await _controller.submitForm(context, existingEvent: widget.event);
|
final success =
|
||||||
|
await _controller.submitForm(context, existingEvent: widget.event);
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
@@ -158,21 +159,25 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(isEditMode ? 'Modifier un événement' : 'Créer un événement'),
|
title: Text(
|
||||||
|
isEditMode ? 'Modifier un événement' : 'Créer un événement'),
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: (isMobile
|
child: (isMobile
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 12),
|
||||||
child: _buildFormContent(isMobile),
|
child: _buildFormContent(isMobile),
|
||||||
)
|
)
|
||||||
: Card(
|
: Card(
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
margin: const EdgeInsets.all(24),
|
margin: const EdgeInsets.all(24),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(18)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 32, vertical: 32),
|
||||||
child: _buildFormContent(isMobile),
|
child: _buildFormContent(isMobile),
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
@@ -186,15 +191,6 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
Widget _buildFormContent(bool isMobile) {
|
Widget _buildFormContent(bool isMobile) {
|
||||||
return Consumer<EventFormController>(
|
return Consumer<EventFormController>(
|
||||||
builder: (context, controller, child) {
|
builder: (context, controller, child) {
|
||||||
// Trouver le nom du type d'événement pour le passer au sélecteur d'options
|
|
||||||
final selectedEventTypeIndex = controller.selectedEventTypeId != null
|
|
||||||
? controller.eventTypes.indexWhere((et) => et.id == controller.selectedEventTypeId)
|
|
||||||
: -1;
|
|
||||||
final selectedEventType = selectedEventTypeIndex != -1
|
|
||||||
? controller.eventTypes[selectedEventTypeIndex]
|
|
||||||
: null;
|
|
||||||
final selectedEventTypeName = selectedEventType?.name;
|
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -209,18 +205,22 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
selectedEventTypeId: controller.selectedEventTypeId,
|
selectedEventTypeId: controller.selectedEventTypeId,
|
||||||
startDateTime: controller.startDateTime,
|
startDateTime: controller.startDateTime,
|
||||||
endDateTime: controller.endDateTime,
|
endDateTime: controller.endDateTime,
|
||||||
onEventTypeChanged: (typeId) => controller.onEventTypeChanged(typeId, context),
|
onEventTypeChanged: (typeId) =>
|
||||||
|
controller.onEventTypeChanged(typeId, context),
|
||||||
onStartDateTimeChanged: controller.setStartDateTime,
|
onStartDateTimeChanged: controller.setStartDateTime,
|
||||||
onEndDateTimeChanged: controller.setEndDateTime,
|
onEndDateTimeChanged: controller.setEndDateTime,
|
||||||
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
|
onAnyFieldChanged:
|
||||||
|
() {}, // Géré automatiquement par le contrôleur
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
OptionSelectorWidget(
|
OptionSelectorWidget(
|
||||||
eventType: controller.selectedEventTypeId, // Utilise l'ID au lieu du nom
|
eventType: controller
|
||||||
|
.selectedEventTypeId, // Utilise l'ID au lieu du nom
|
||||||
selectedOptions: controller.selectedOptions,
|
selectedOptions: controller.selectedOptions,
|
||||||
onChanged: controller.setSelectedOptions,
|
onChanged: controller.setSelectedOptions,
|
||||||
onRemove: (optionId) {
|
onRemove: (optionId) {
|
||||||
final newOptions = List<Map<String, dynamic>>.from(controller.selectedOptions);
|
final newOptions = List<Map<String, dynamic>>.from(
|
||||||
|
controller.selectedOptions);
|
||||||
newOptions.removeWhere((o) => o['id'] == optionId);
|
newOptions.removeWhere((o) => o['id'] == optionId);
|
||||||
controller.setSelectedOptions(newOptions);
|
controller.setSelectedOptions(newOptions);
|
||||||
},
|
},
|
||||||
@@ -236,6 +236,7 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
endDate: controller.endDateTime,
|
endDate: controller.endDateTime,
|
||||||
onChanged: controller.setAssignedEquipment,
|
onChanged: controller.setAssignedEquipment,
|
||||||
eventId: widget.event?.id,
|
eventId: widget.event?.id,
|
||||||
|
eventTypeId: controller.selectedEventTypeId,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
EventDetailsSection(
|
EventDetailsSection(
|
||||||
@@ -247,7 +248,8 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
contactEmailController: controller.contactEmailController,
|
contactEmailController: controller.contactEmailController,
|
||||||
contactPhoneController: controller.contactPhoneController,
|
contactPhoneController: controller.contactPhoneController,
|
||||||
isMobile: isMobile,
|
isMobile: isMobile,
|
||||||
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
|
onAnyFieldChanged:
|
||||||
|
() {}, // Géré automatiquement par le contrôleur
|
||||||
),
|
),
|
||||||
EventStaffAndDocumentsSection(
|
EventStaffAndDocumentsSection(
|
||||||
allUsers: controller.allUsers,
|
allUsers: controller.allUsers,
|
||||||
@@ -290,9 +292,10 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSubmit: _submit,
|
onSubmit: _submit,
|
||||||
onSetConfirmed: !isEditMode ? () {
|
onSetConfirmed: !isEditMode ? () {} : null,
|
||||||
} : null,
|
onDelete: isEditMode
|
||||||
onDelete: isEditMode ? _deleteEvent : null, // Ajout du callback de suppression
|
? _deleteEvent
|
||||||
|
: null, // Ajout du callback de suppression
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialise la sélection avec le matériel déjà assigné
|
/// Initialise la slection avec le matriel dj assign
|
||||||
Future<void> _initializeAlreadyAssigned() async {
|
Future<void> _initializeAlreadyAssigned() async {
|
||||||
final Map<String, SelectedItem> initialSelection = {};
|
final Map<String, SelectedItem> initialSelection = {};
|
||||||
|
|
||||||
@@ -304,7 +304,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await _dataService.getEquipmentsPaginated(
|
final result = await _dataService.getEquipmentsPaginated(
|
||||||
limit: 25,
|
limit: 50,
|
||||||
startAfter: _lastEquipmentId,
|
startAfter: _lastEquipmentId,
|
||||||
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||||
category: _selectedCategory != null
|
category: _selectedCategory != null
|
||||||
@@ -331,7 +331,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
DebugLog.info(
|
DebugLog.info(
|
||||||
'[EquipmentSelectionDialog] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipments.length}, hasMore: $_hasMoreEquipments');
|
'[EquipmentSelectionDialog] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipments.length}, hasMore: $_hasMoreEquipments');
|
||||||
|
|
||||||
// Charger les quantités pour les consommables/câbles de cette page
|
// Charger les quantites pour les consommables/cbles de cette page
|
||||||
await _loadAvailableQuantities(newEquipments);
|
await _loadAvailableQuantities(newEquipments);
|
||||||
|
|
||||||
// Si la liste ne peut pas scroller, précharger la page suivante.
|
// Si la liste ne peut pas scroller, précharger la page suivante.
|
||||||
@@ -354,7 +354,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await _dataService.getContainersPaginated(
|
final result = await _dataService.getContainersPaginated(
|
||||||
limit: 25,
|
limit: 50,
|
||||||
startAfter: _lastContainerId,
|
startAfter: _lastContainerId,
|
||||||
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||||
category: _selectedCategory?.name, // Filtre par catégorie d'équipements
|
category: _selectedCategory?.name, // Filtre par catégorie d'équipements
|
||||||
@@ -421,7 +421,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
DebugLog.info(
|
DebugLog.info(
|
||||||
'[EquipmentSelectionDialog] Cached ${allEquipmentsToCache.length} equipment(s) from containers, total cache: ${_cachedEquipment.length}');
|
'[EquipmentSelectionDialog] Cached ${allEquipmentsToCache.length} equipment(s) from containers, total cache: ${_cachedEquipment.length}');
|
||||||
|
|
||||||
// Mettre à jour les statuts de conflit pour les nouveaux containers
|
// Mettre jour les statuts de conflit pour les nouveaux containers
|
||||||
await _updateContainerConflictStatus();
|
await _updateContainerConflictStatus();
|
||||||
|
|
||||||
// Si la liste ne peut pas scroller, précharger la page suivante.
|
// Si la liste ne peut pas scroller, précharger la page suivante.
|
||||||
@@ -454,6 +454,40 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _checkIfMoreItemsNeeded() {
|
||||||
|
if (!mounted || _isLoadingMore) return;
|
||||||
|
|
||||||
|
int visibleItems = 0;
|
||||||
|
if (_displayType == SelectionType.equipment) {
|
||||||
|
visibleItems = _paginatedEquipments.where((eq) {
|
||||||
|
return _showConflictingItems || !_conflictingEquipmentIds.contains(eq.id);
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
if (visibleItems < 15 && _hasMoreEquipments) {
|
||||||
|
_loadNextEquipmentPage();
|
||||||
|
} else if (_scrollController.hasClients && _scrollController.position.maxScrollExtent <= 0 && _hasMoreEquipments) {
|
||||||
|
_loadNextEquipmentPage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
visibleItems = _paginatedContainers.where((container) {
|
||||||
|
if (!_showConflictingItems) {
|
||||||
|
if (_conflictingContainerIds.contains(container.id)) return false;
|
||||||
|
final hasConflictingChildren = container.equipmentIds.any(
|
||||||
|
(eqId) => _conflictingEquipmentIds.contains(eqId),
|
||||||
|
);
|
||||||
|
if (hasConflictingChildren) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
if (visibleItems < 15 && _hasMoreContainers) {
|
||||||
|
_loadNextContainerPage();
|
||||||
|
} else if (_scrollController.hasClients && _scrollController.position.maxScrollExtent <= 0 && _hasMoreContainers) {
|
||||||
|
_loadNextContainerPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
@@ -1539,7 +1573,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,755 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/services/ai_equipment_assistant_service.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// Résultat retourné par le dialog après confirmation de la proposition IA.
|
||||||
|
class AiProposalResult {
|
||||||
|
final List<EventEquipment> equipment;
|
||||||
|
final List<String> containerIds;
|
||||||
|
|
||||||
|
const AiProposalResult({
|
||||||
|
required this.equipment,
|
||||||
|
required this.containerIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AiEquipmentAssistantDialog extends StatefulWidget {
|
||||||
|
final DateTime startDate;
|
||||||
|
final DateTime endDate;
|
||||||
|
final String? eventTypeId;
|
||||||
|
final String? excludeEventId;
|
||||||
|
final List<EventEquipment> currentAssignedEquipment;
|
||||||
|
|
||||||
|
const AiEquipmentAssistantDialog({
|
||||||
|
super.key,
|
||||||
|
required this.startDate,
|
||||||
|
required this.endDate,
|
||||||
|
required this.currentAssignedEquipment,
|
||||||
|
this.eventTypeId,
|
||||||
|
this.excludeEventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AiEquipmentAssistantDialog> createState() =>
|
||||||
|
_AiEquipmentAssistantDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AiEquipmentAssistantDialogState
|
||||||
|
extends State<AiEquipmentAssistantDialog> {
|
||||||
|
final TextEditingController _messageController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final ScrollController _proposalScrollController = ScrollController();
|
||||||
|
final List<_AssistantChatMessage> _messages = [];
|
||||||
|
|
||||||
|
late final AiEquipmentAssistantService _assistantService;
|
||||||
|
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
AiEquipmentProposal? _latestProposal;
|
||||||
|
late List<EventEquipment> _workingEquipment;
|
||||||
|
AiEquipmentDocument? _selectedDocument;
|
||||||
|
List<String> _sessionLogs = [];
|
||||||
|
Set<String> _selectedContainerIds = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_assistantService = AiEquipmentAssistantService();
|
||||||
|
_workingEquipment = List<EventEquipment>.from(widget.currentAssignedEquipment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_messageController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
_proposalScrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isChatEmpty => _messages.isEmpty;
|
||||||
|
|
||||||
|
String get _actionButtonLabel {
|
||||||
|
return _isChatEmpty ? 'Generer la liste automatiquement' : 'Envoyer';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendMessage() async {
|
||||||
|
if (_isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawInput = _messageController.text.trim();
|
||||||
|
final isAutoMode = _isChatEmpty;
|
||||||
|
final userMessage = isAutoMode
|
||||||
|
? (rawInput.isNotEmpty
|
||||||
|
? rawInput
|
||||||
|
: 'Genere automatiquement une proposition de materiel pour cet evenement.')
|
||||||
|
: rawInput;
|
||||||
|
|
||||||
|
if (userMessage.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_messageController.clear();
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = null;
|
||||||
|
_messages.add(_AssistantChatMessage.user(userMessage));
|
||||||
|
if (_selectedDocument != null) {
|
||||||
|
_messages.add(_AssistantChatMessage.user('[Document joint : ${_selectedDocument!.fileName ?? "Document"}]'));
|
||||||
|
}
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
_scrollToBottom();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final documentToSend = _selectedDocument;
|
||||||
|
_selectedDocument = null; // Clear after sending
|
||||||
|
final response = await _assistantService
|
||||||
|
.generateProposal(
|
||||||
|
startDate: widget.startDate,
|
||||||
|
endDate: widget.endDate,
|
||||||
|
eventTypeId: widget.eventTypeId,
|
||||||
|
excludeEventId: widget.excludeEventId,
|
||||||
|
currentAssignedEquipment: widget.currentAssignedEquipment,
|
||||||
|
workingProposalEquipment: _workingEquipment,
|
||||||
|
userMessage: userMessage,
|
||||||
|
document: documentToSend,
|
||||||
|
history: _messages
|
||||||
|
.map((message) => AiAssistantChatTurn(
|
||||||
|
isUser: message.isUser, text: message.text))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_messages
|
||||||
|
.add(_AssistantChatMessage.assistant(response.assistantMessage));
|
||||||
|
_latestProposal = response.proposal;
|
||||||
|
if (response.proposal != null) {
|
||||||
|
_workingEquipment = List<EventEquipment>.from(
|
||||||
|
response.proposal!.asEventEquipment,
|
||||||
|
);
|
||||||
|
// Préselectionner les containers non partiels
|
||||||
|
_selectedContainerIds = {
|
||||||
|
for (final c in response.proposal!.containers)
|
||||||
|
if (!c.partial) c.containerId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_sessionLogs.addAll(response.debugLogs);
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
_scrollToBottom();
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = 'Reponse IA invalide: ${error.message}';
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = 'Erreur IA: $error';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!_scrollController.hasClients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDocument() async {
|
||||||
|
try {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['pdf', 'txt', 'jpg', 'jpeg', 'png'],
|
||||||
|
withData: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
final file = result.files.first;
|
||||||
|
if (file.bytes != null) {
|
||||||
|
final base64String = base64Encode(file.bytes!);
|
||||||
|
String mimeType = 'application/octet-stream';
|
||||||
|
if (file.extension == 'pdf') mimeType = 'application/pdf';
|
||||||
|
else if (file.extension == 'txt') mimeType = 'text/plain';
|
||||||
|
else if (file.extension == 'jpg' || file.extension == 'jpeg') mimeType = 'image/jpeg';
|
||||||
|
else if (file.extension == 'png') mimeType = 'image/png';
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_selectedDocument = AiEquipmentDocument(
|
||||||
|
base64Data: base64String,
|
||||||
|
mimeType: mimeType,
|
||||||
|
fileName: file.name,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Erreur lors de la selection du document : $e';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLogsDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Logs de l\'IA'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _sessionLogs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final log = _sessionLogs[index];
|
||||||
|
final isError = log.startsWith('[ERROR]');
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Text(
|
||||||
|
log,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
color: isError ? Colors.red : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
final fullLogs = _sessionLogs.join('\n');
|
||||||
|
Clipboard.setData(ClipboardData(text: fullLogs));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Logs copiés dans le presse-papiers')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Copier tout'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 760,
|
||||||
|
height: 640,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
AppBar(
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
title: const Text('(BETA) Assistant IA Logisticien'),
|
||||||
|
actions: [
|
||||||
|
if (_sessionLogs.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.bug_report),
|
||||||
|
tooltip: 'Voir les logs',
|
||||||
|
onPressed: _showLogsDialog,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed:
|
||||||
|
_isLoading ? null : () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _messages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final message = _messages[index];
|
||||||
|
return _buildMessageBubble(message);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isLoading)
|
||||||
|
const Padding(
|
||||||
|
padding:
|
||||||
|
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: const Text(
|
||||||
|
'Generation en cours... verification du materiel et disponibilites. (Cela peut prendre jusqu\'a une minute en cas de forte affluence)',
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: Colors.red.shade200),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: TextStyle(color: Colors.red.shade800),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_latestProposal != null)
|
||||||
|
_buildProposalSummary(_latestProposal!),
|
||||||
|
if (_selectedDocument != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, right: 16, top: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.attach_file, color: Colors.blue, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_selectedDocument!.fileName ?? 'Document joint',
|
||||||
|
style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.w500),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedDocument = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'Retirer le document',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.attach_file),
|
||||||
|
onPressed: _isLoading ? null : _pickDocument,
|
||||||
|
tooltip: 'Joindre un devis ou document',
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _messageController,
|
||||||
|
enabled: !_isLoading,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText:
|
||||||
|
'Precisez votre besoin (style, jauge, contraintes...)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _sendMessage,
|
||||||
|
child: Text(_actionButtonLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageBubble(_AssistantChatMessage message) {
|
||||||
|
final bubbleColor = message.isUser ? Colors.blue.shade600 : Colors.white;
|
||||||
|
final textColor = message.isUser ? Colors.white : Colors.black87;
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bubbleColor,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: const Radius.circular(16),
|
||||||
|
topRight: const Radius.circular(16),
|
||||||
|
bottomLeft: Radius.circular(message.isUser ? 16 : 4),
|
||||||
|
bottomRight: Radius.circular(message.isUser ? 4 : 16),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
border:
|
||||||
|
message.isUser ? null : Border.all(color: Colors.grey.shade200),
|
||||||
|
),
|
||||||
|
child: message.isUser
|
||||||
|
? Text(message.text, style: TextStyle(color: textColor))
|
||||||
|
: _buildAssistantMessageContent(message.text),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAssistantMessageContent(String text) {
|
||||||
|
// Si le message semble structuré par l'IA avec nos nouvelles règles
|
||||||
|
if (text.contains('Matériel ajouté :') || text.contains('Matériel non trouvé')) {
|
||||||
|
final sections = text.split('\n\n');
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: sections.map((section) {
|
||||||
|
final isAdded = section.contains('Matériel ajouté :');
|
||||||
|
final isMissing = section.contains('Matériel non trouvé');
|
||||||
|
|
||||||
|
if (isAdded) {
|
||||||
|
return _buildStatusSection(
|
||||||
|
title: section.split('\n').first,
|
||||||
|
content: section.split('\n').skip(1).join('\n'),
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
bgColor: Colors.green.shade50,
|
||||||
|
);
|
||||||
|
} else if (isMissing) {
|
||||||
|
return _buildStatusSection(
|
||||||
|
title: section.split('\n').first,
|
||||||
|
content: section.split('\n').skip(1).join('\n'),
|
||||||
|
icon: Icons.warning_amber_rounded,
|
||||||
|
color: Colors.orange.shade800,
|
||||||
|
bgColor: Colors.orange.shade50,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: Text(section),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Text(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusSection({
|
||||||
|
required String title,
|
||||||
|
required String content,
|
||||||
|
required IconData icon,
|
||||||
|
required Color color,
|
||||||
|
required Color bgColor,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title.replaceAll(':', '').trim(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (content.trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
content.trim(),
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade800),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmProposal({bool excludeAlternatives = false}) {
|
||||||
|
if (_latestProposal == null) return;
|
||||||
|
|
||||||
|
List<EventEquipment> equipment = List.from(_latestProposal!.asEventEquipment);
|
||||||
|
// Ne renvoyer que les containerIds sélectionnés (par défaut les containers complets)
|
||||||
|
final List<String> containerIds = _selectedContainerIds.isNotEmpty
|
||||||
|
? _selectedContainerIds.toList()
|
||||||
|
: List.from(_latestProposal!.containerIds);
|
||||||
|
|
||||||
|
if (excludeAlternatives) {
|
||||||
|
// On utilise la liste des items d'origine pour savoir lesquels exclure
|
||||||
|
// car ils contiennent le champ rationale (avant conversion en EventEquipment)
|
||||||
|
final idsToExclude = _latestProposal!.items
|
||||||
|
.where((item) {
|
||||||
|
final rationale = item.rationale.toLowerCase();
|
||||||
|
return rationale.contains('alternative') ||
|
||||||
|
rationale.contains('remplacement') ||
|
||||||
|
rationale.contains('indisponible');
|
||||||
|
})
|
||||||
|
.map((item) => item.equipmentId)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
equipment = equipment.where((eq) => !idsToExclude.contains(eq.equipmentId)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
AiProposalResult(
|
||||||
|
equipment: equipment,
|
||||||
|
containerIds: containerIds,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProposalSummary(AiEquipmentProposal proposal) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
constraints: const BoxConstraints(maxHeight: 280),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.assignment_turned_in, color: Colors.indigo),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Récapitulatif de la proposition IA',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.indigo,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Flexible(
|
||||||
|
child: Scrollbar(
|
||||||
|
controller: _proposalScrollController,
|
||||||
|
thumbVisibility: true,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: _proposalScrollController,
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
proposal.summary,
|
||||||
|
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||||
|
),
|
||||||
|
if (proposal.items.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Matériel individuel :',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
...proposal.items.map((item) {
|
||||||
|
final isAlt = item.rationale.toLowerCase().contains('alternative') || item.rationale.toLowerCase().contains('remplacement');
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6, left: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isAlt ? Icons.swap_horiz : Icons.add_circle_outline,
|
||||||
|
size: 14,
|
||||||
|
color: isAlt ? Colors.orange : Colors.indigo,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${item.equipmentId} x${item.quantity}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
if (proposal.containers.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Fly-cases & Boîtes :',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
...proposal.containers.map((c) {
|
||||||
|
final isPartial = c.partial;
|
||||||
|
final isSelected = _selectedContainerIds.contains(c.containerId);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6, left: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: c.available == false ? Colors.red : Colors.indigo,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text('${c.containerId} ${c.rationale.isNotEmpty ? "- ${c.rationale}" : ""}', style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||||
|
if (c.available == false)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
|
child: Icon(Icons.block, color: Colors.red.shade700, size: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (isPartial) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Contenu partiel : ${c.matchingEquipmentIds.length}/${c.equipmentIds.length} items utilisés.', style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (isPartial)
|
||||||
|
Checkbox(
|
||||||
|
value: isSelected,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() {
|
||||||
|
if (v == true) _selectedContainerIds.add(c.containerId);
|
||||||
|
else _selectedContainerIds.remove(c.containerId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : () => _confirmProposal(),
|
||||||
|
icon: const Icon(Icons.check),
|
||||||
|
label: const Text('Tout ajouter'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.indigo,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : () => _confirmProposal(excludeAlternatives: true),
|
||||||
|
icon: const Icon(Icons.filter_list_off),
|
||||||
|
label: const Text('Ajouter sans alternatives'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.indigo,
|
||||||
|
side: const BorderSide(color: Colors.indigo),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssistantChatMessage {
|
||||||
|
final bool isUser;
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const _AssistantChatMessage._({required this.isUser, required this.text});
|
||||||
|
|
||||||
|
factory _AssistantChatMessage.user(String text) {
|
||||||
|
return _AssistantChatMessage._(isUser: true, text: text);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory _AssistantChatMessage.assistant(String text) {
|
||||||
|
return _AssistantChatMessage._(isUser: false, text: text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import 'package:em2rp/providers/equipment_provider.dart';
|
|||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
|
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
|
||||||
|
import 'package:em2rp/views/widgets/event_form/ai_equipment_assistant_dialog.dart';
|
||||||
|
|
||||||
/// Section pour afficher et gérer le matériel assigné à un événement
|
/// Section pour afficher et gérer le matériel assigné à un événement
|
||||||
class EventAssignedEquipmentSection extends StatefulWidget {
|
class EventAssignedEquipmentSection extends StatefulWidget {
|
||||||
@@ -17,6 +18,7 @@ class EventAssignedEquipmentSection extends StatefulWidget {
|
|||||||
final DateTime? endDate;
|
final DateTime? endDate;
|
||||||
final Function(List<EventEquipment>, List<String>) onChanged;
|
final Function(List<EventEquipment>, List<String>) onChanged;
|
||||||
final String? eventId; // Pour exclure l'événement actuel de la vérification
|
final String? eventId; // Pour exclure l'événement actuel de la vérification
|
||||||
|
final String? eventTypeId;
|
||||||
|
|
||||||
const EventAssignedEquipmentSection({
|
const EventAssignedEquipmentSection({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -26,14 +28,18 @@ class EventAssignedEquipmentSection extends StatefulWidget {
|
|||||||
required this.endDate,
|
required this.endDate,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
this.eventId,
|
this.eventId,
|
||||||
|
this.eventTypeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<EventAssignedEquipmentSection> createState() => _EventAssignedEquipmentSectionState();
|
State<EventAssignedEquipmentSection> createState() =>
|
||||||
|
_EventAssignedEquipmentSectionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
|
class _EventAssignedEquipmentSectionState
|
||||||
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
|
extends State<EventAssignedEquipmentSection> {
|
||||||
|
bool get _canAddMaterial =>
|
||||||
|
widget.startDate != null && widget.endDate != null;
|
||||||
final Map<String, EquipmentModel> _equipmentCache = {};
|
final Map<String, EquipmentModel> _equipmentCache = {};
|
||||||
final Map<String, ContainerModel> _containerCache = {};
|
final Map<String, ContainerModel> _containerCache = {};
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
@@ -61,19 +67,24 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
final equipmentProvider = context.read<EquipmentProvider>();
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Loading caches from assigned lists');
|
DebugLog.info(
|
||||||
|
'[EventAssignedEquipmentSection] Loading caches from assigned lists');
|
||||||
|
|
||||||
// Toujours partir des données locales du formulaire pour éviter les décalages visuels.
|
// Toujours partir des données locales du formulaire pour éviter les décalages visuels.
|
||||||
final equipmentIds = widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
|
final equipmentIds =
|
||||||
final containers = await containerProvider.getContainersByIds(widget.assignedContainers);
|
widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
|
||||||
|
final containers =
|
||||||
|
await containerProvider.getContainersByIds(widget.assignedContainers);
|
||||||
|
|
||||||
final childEquipmentIds = <String>[];
|
final childEquipmentIds = <String>[];
|
||||||
for (final container in containers) {
|
for (final container in containers) {
|
||||||
childEquipmentIds.addAll(container.equipmentIds);
|
childEquipmentIds.addAll(container.equipmentIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
final allEquipmentIds = <String>{...equipmentIds, ...childEquipmentIds}.toList();
|
final allEquipmentIds =
|
||||||
final equipment = await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
|
<String>{...equipmentIds, ...childEquipmentIds}.toList();
|
||||||
|
final equipment =
|
||||||
|
await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
|
||||||
|
|
||||||
_equipmentCache.clear();
|
_equipmentCache.clear();
|
||||||
_containerCache.clear();
|
_containerCache.clear();
|
||||||
@@ -110,7 +121,9 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
_containerCache[containerId] = container;
|
_containerCache[containerId] = container;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[EventAssignedEquipmentSection] Error loading equipment and containers', e);
|
DebugLog.error(
|
||||||
|
'[EventAssignedEquipmentSection] Error loading equipment and containers',
|
||||||
|
e);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -138,7 +151,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _processSelection(Map<String, SelectedItem> selection) async {
|
Future<void> _processSelection(Map<String, SelectedItem> selection) async {
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
|
DebugLog.info(
|
||||||
|
'[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
|
||||||
|
|
||||||
// Séparer équipements et conteneurs
|
// Séparer équipements et conteneurs
|
||||||
final newEquipment = <EventEquipment>[];
|
final newEquipment = <EventEquipment>[];
|
||||||
@@ -155,23 +169,27 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
|
DebugLog.info(
|
||||||
|
'[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
|
||||||
|
|
||||||
// 🔧 FIX: Pour chaque container sélectionné, ajouter aussi ses équipements enfants
|
// 🔧 FIX: Pour chaque container sélectionné, ajouter aussi ses équipements enfants
|
||||||
if (newContainers.isNotEmpty) {
|
if (newContainers.isNotEmpty) {
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
final containers = await containerProvider.getContainersByIds(newContainers);
|
final containers =
|
||||||
|
await containerProvider.getContainersByIds(newContainers);
|
||||||
|
|
||||||
for (var container in containers) {
|
for (var container in containers) {
|
||||||
for (var childEquipmentId in container.equipmentIds) {
|
for (var childEquipmentId in container.equipmentIds) {
|
||||||
// Vérifier si l'équipement enfant n'est pas déjà dans la liste
|
// Vérifier si l'équipement enfant n'est pas déjà dans la liste
|
||||||
final existsInNew = newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
|
final existsInNew =
|
||||||
|
newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
|
||||||
if (!existsInNew) {
|
if (!existsInNew) {
|
||||||
newEquipment.add(EventEquipment(
|
newEquipment.add(EventEquipment(
|
||||||
equipmentId: childEquipmentId,
|
equipmentId: childEquipmentId,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
));
|
));
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
|
DebugLog.info(
|
||||||
|
'[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +204,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
// Pour chaque nouvel équipement
|
// Pour chaque nouvel équipement
|
||||||
for (var eq in newEquipment) {
|
for (var eq in newEquipment) {
|
||||||
final existingIndex = updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId);
|
final existingIndex =
|
||||||
|
updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId);
|
||||||
|
|
||||||
if (existingIndex != -1) {
|
if (existingIndex != -1) {
|
||||||
// L'équipement existe déjà : mettre à jour la quantité
|
// L'équipement existe déjà : mettre à jour la quantité
|
||||||
@@ -215,6 +234,74 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
widget.onChanged(updatedEquipment, updatedContainers);
|
widget.onChanged(updatedEquipment, updatedContainers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openAiAssistantDialog() async {
|
||||||
|
if (widget.startDate == null || widget.endDate == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await showDialog<AiProposalResult>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AiEquipmentAssistantDialog(
|
||||||
|
startDate: widget.startDate!,
|
||||||
|
endDate: widget.endDate!,
|
||||||
|
eventTypeId: widget.eventTypeId,
|
||||||
|
excludeEventId: widget.eventId,
|
||||||
|
currentAssignedEquipment: widget.assignedEquipment,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyAiProposal(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyAiProposal(AiProposalResult result) async {
|
||||||
|
final existingById = {
|
||||||
|
for (final equipment in widget.assignedEquipment)
|
||||||
|
equipment.equipmentId: equipment,
|
||||||
|
};
|
||||||
|
|
||||||
|
final updatedEquipment = result.equipment.map((proposed) {
|
||||||
|
final existing = existingById[proposed.equipmentId];
|
||||||
|
if (existing == null) {
|
||||||
|
return proposed;
|
||||||
|
}
|
||||||
|
return existing.copyWith(quantity: proposed.quantity, rationale: proposed.rationale);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// 🔧 FIX: Pour chaque container ajouté par l'IA, ajouter aussi ses équipements enfants
|
||||||
|
if (result.containerIds.isNotEmpty) {
|
||||||
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
final containers = await containerProvider.getContainersByIds(result.containerIds);
|
||||||
|
|
||||||
|
for (var container in containers) {
|
||||||
|
for (var childEquipmentId in container.equipmentIds) {
|
||||||
|
// Vérifier si l'équipement enfant n'est pas déjà dans la liste (ou déjà ajouté par la proposition)
|
||||||
|
final exists = updatedEquipment.any((eq) => eq.equipmentId == childEquipmentId);
|
||||||
|
if (!exists) {
|
||||||
|
updatedEquipment.add(EventEquipment(
|
||||||
|
equipmentId: childEquipmentId,
|
||||||
|
quantity: 1,
|
||||||
|
rationale: 'Inclus dans ${container.id}',
|
||||||
|
));
|
||||||
|
DebugLog.info('[EventAssignedEquipmentSection] AI adding child equipment $childEquipmentId from container ${container.id}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedContainers = [...widget.assignedContainers];
|
||||||
|
for (final containerId in result.containerIds) {
|
||||||
|
if (!updatedContainers.contains(containerId)) {
|
||||||
|
updatedContainers.add(containerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onChanged(updatedEquipment, updatedContainers);
|
||||||
|
}
|
||||||
|
|
||||||
void _removeEquipment(String equipmentId) {
|
void _removeEquipment(String equipmentId) {
|
||||||
final updated = widget.assignedEquipment
|
final updated = widget.assignedEquipment
|
||||||
.where((eq) => eq.equipmentId != equipmentId)
|
.where((eq) => eq.equipmentId != equipmentId)
|
||||||
@@ -231,9 +318,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
final container = _containerCache[containerId];
|
final container = _containerCache[containerId];
|
||||||
|
|
||||||
// Retirer le conteneur de la liste
|
// Retirer le conteneur de la liste
|
||||||
final updatedContainers = widget.assignedContainers
|
final updatedContainers =
|
||||||
.where((id) => id != containerId)
|
widget.assignedContainers.where((id) => id != containerId).toList();
|
||||||
.toList();
|
|
||||||
|
|
||||||
// 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container
|
// 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container
|
||||||
final updatedEquipment = <EventEquipment>[];
|
final updatedEquipment = <EventEquipment>[];
|
||||||
@@ -252,8 +338,10 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
// 1. Ne sont PAS dans le container supprimé OU
|
// 1. Ne sont PAS dans le container supprimé OU
|
||||||
// 2. Sont dans le container supprimé MAIS aussi dans un autre container
|
// 2. Sont dans le container supprimé MAIS aussi dans un autre container
|
||||||
for (var eq in widget.assignedEquipment) {
|
for (var eq in widget.assignedEquipment) {
|
||||||
final isInRemovedContainer = container.equipmentIds.contains(eq.equipmentId);
|
final isInRemovedContainer =
|
||||||
final isInOtherContainer = equipmentIdsInOtherContainers.contains(eq.equipmentId);
|
container.equipmentIds.contains(eq.equipmentId);
|
||||||
|
final isInOtherContainer =
|
||||||
|
equipmentIdsInOtherContainers.contains(eq.equipmentId);
|
||||||
|
|
||||||
if (!isInRemovedContainer || isInOtherContainer) {
|
if (!isInRemovedContainer || isInOtherContainer) {
|
||||||
updatedEquipment.add(eq);
|
updatedEquipment.add(eq);
|
||||||
@@ -271,7 +359,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
_containerCache.remove(containerId);
|
_containerCache.remove(containerId);
|
||||||
// Nettoyer le cache uniquement pour les équipements effectivement supprimés
|
// Nettoyer le cache uniquement pour les équipements effectivement supprimés
|
||||||
if (container != null) {
|
if (container != null) {
|
||||||
final remainingEquipmentIds = updatedEquipment.map((eq) => eq.equipmentId).toSet();
|
final remainingEquipmentIds =
|
||||||
|
updatedEquipment.map((eq) => eq.equipmentId).toSet();
|
||||||
for (var equipmentId in container.equipmentIds) {
|
for (var equipmentId in container.equipmentIds) {
|
||||||
if (!remainingEquipmentIds.contains(equipmentId)) {
|
if (!remainingEquipmentIds.contains(equipmentId)) {
|
||||||
_equipmentCache.remove(equipmentId);
|
_equipmentCache.remove(equipmentId);
|
||||||
@@ -301,7 +390,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final totalItems = widget.assignedEquipment.length + widget.assignedContainers.length;
|
final totalItems =
|
||||||
|
widget.assignedEquipment.length + widget.assignedContainers.length;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
@@ -350,15 +440,25 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ActionChip(
|
||||||
|
onPressed: _canAddMaterial ? _openAiAssistantDialog : null,
|
||||||
|
avatar: const Icon(Icons.auto_fix_high, size: 18),
|
||||||
|
label: const Text('Assistant IA'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: _canAddMaterial ? _openSelectionDialog : null,
|
onPressed: _canAddMaterial ? _openSelectionDialog : null,
|
||||||
icon: Icon(Icons.add, color: _canAddMaterial ? Colors.white : Colors.grey),
|
icon: Icon(Icons.add,
|
||||||
|
color: _canAddMaterial ? Colors.white : Colors.grey),
|
||||||
label: Text(
|
label: Text(
|
||||||
'Ajouter',
|
'Ajouter',
|
||||||
style: TextStyle(color: _canAddMaterial ? Colors.white : Colors.grey),
|
style: TextStyle(
|
||||||
|
color: _canAddMaterial ? Colors.white : Colors.grey),
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: _canAddMaterial ? AppColors.rouge : Colors.grey.shade300,
|
backgroundColor: _canAddMaterial
|
||||||
|
? AppColors.rouge
|
||||||
|
: Colors.grey.shade300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -512,7 +612,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -537,7 +638,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
color: Colors.grey.shade600,
|
color: Colors.grey.shade600,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
eq.category.getIcon(size: 16, color: eq.category.color),
|
eq.category
|
||||||
|
.getIcon(size: 16, color: eq.category.color),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -562,7 +664,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEquipmentItem(EquipmentModel? equipment, EventEquipment eventEq) {
|
Widget _buildEquipmentItem(
|
||||||
|
EquipmentModel? equipment, EventEquipment eventEq) {
|
||||||
if (equipment == null) {
|
if (equipment == null) {
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
@@ -585,17 +688,15 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isConsumable = equipment.category == EquipmentCategory.consumable ||
|
final isConsumable = equipment.category == EquipmentCategory.consumable ||
|
||||||
equipment.category == EquipmentCategory.cable;
|
equipment.category == EquipmentCategory.cable;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
||||||
child: equipment.category.getIconForAvatar(
|
child: equipment.category
|
||||||
size: 24,
|
.getIconForAvatar(size: 24, color: equipment.category.color),
|
||||||
color: equipment.category.color
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
equipment.id,
|
equipment.id,
|
||||||
@@ -634,4 +735,3 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user