import 'dart:async'; import 'dart:math'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart' as http; import '../../../core/api/pocketbase_client.dart'; import '../../../core/providers/auth_provider.dart'; import '../../../core/services/pricing_service.dart'; import '../../../core/theme/app_theme.dart'; import '../../../models/job.dart'; import '../../../models/patient.dart'; import '../../../models/prosthetic_product.dart'; import '../../lab/discounts/discount_repository.dart'; import '../../lab/products/lab_products_repository.dart'; import 'clinic_jobs_repository.dart'; import '../patients/clinic_patients_repository.dart'; enum _PatientEntryMode { selectExisting, createNew, } String _mimeFromExt(String ext) => switch (ext) { 'jpg' || 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'pdf' => 'application/pdf', 'stl' => 'model/stl', 'obj' => 'model/obj', 'ply' => 'model/ply', 'zip' => 'application/zip', 'mp3' => 'audio/mpeg', 'mp4' => 'video/mp4', 'opus' => 'audio/opus', _ => 'application/octet-stream', }; class NewJobScreen extends ConsumerStatefulWidget { const NewJobScreen({super.key}); @override ConsumerState createState() => _NewJobScreenState(); } class _NewJobScreenState extends ConsumerState { final _formKey = GlobalKey(); // Form fields Map? _selectedLab; Patient? _selectedPatient; final _patientNameController = TextEditingController(); final _patientLastNameController = TextEditingController(); final _patientCodeController = TextEditingController(); ProstheticType? _selectedProstheticType; JobWorkflowType? _selectedWorkflowType = JobWorkflowType.geleneksel; final Set _selectedTeeth = {}; final _colorController = TextEditingController(); final _descriptionController = TextEditingController(); DateTime? _dueDate; bool _provaRequired = true; _PatientEntryMode _patientEntryMode = _PatientEntryMode.selectExisting; // State List> _labs = []; bool _labsLoading = true; bool _isSubmitting = false; String? _labsError; // File upload final List _pendingFiles = []; // Patient search final _patientSearchController = TextEditingController(); List _patientResults = []; bool _patientSearchLoading = false; Timer? _patientSearchDebounce; // Price preview List _availableProducts = []; ProstheticProduct? _selectedProduct; ProstheticProduct? _labProduct; PricingBreakdown? _pricingBreakdown; double? _effectivePrice; bool _priceLoading = false; String? _productAvailabilityMessage; @override void initState() { super.initState(); _loadLabs(); } @override void dispose() { _patientSearchDebounce?.cancel(); _patientNameController.dispose(); _patientLastNameController.dispose(); _patientCodeController.dispose(); _colorController.dispose(); _descriptionController.dispose(); _patientSearchController.dispose(); super.dispose(); } Future _loadLabs() async { setState(() { _labsLoading = true; _labsError = null; }); try { final tenantId = ref.read(authProvider).activeTenant!.tenant.id; final labs = await ClinicJobsRepository.instance.listApprovedLabs(tenantId); setState(() { _labs = labs; _labsLoading = false; }); } catch (e) { setState(() { _labsError = e.toString(); _labsLoading = false; }); } } Future _refreshProductsAndPrice() async { if (_selectedLab == null || _selectedProstheticType == null) { setState(() { _availableProducts = []; _selectedProduct = null; _labProduct = null; _pricingBreakdown = null; _effectivePrice = null; _productAvailabilityMessage = null; }); return; } final labId = _selectedLab!['id'] as String; final ptValue = _selectedProstheticType!.value; final clinicTenantId = ref.read(authProvider).activeTenant!.tenant.id; setState(() => _priceLoading = true); try { final products = await LabProductsRepository.instance.listProducts( labId, isActive: true, ); final matchingProducts = products .where((p) => p.prostheticType == ptValue) .toList(); ProstheticProduct? product; if (_selectedProduct != null) { try { product = matchingProducts.firstWhere( (p) => p.id == _selectedProduct!.id, ); } catch (_) { product = null; } } product ??= matchingProducts.isNotEmpty ? matchingProducts.first : null; if (product == null) { setState(() { _availableProducts = matchingProducts; _selectedProduct = null; _labProduct = null; _pricingBreakdown = null; _effectivePrice = null; _productAvailabilityMessage = 'Bu laboratuvarda seçtiğiniz protez türü için ürün tanımlı değil.'; _priceLoading = false; }); return; } if (product.unitPrice == null) { setState(() { _availableProducts = matchingProducts; _selectedProduct = product; _labProduct = product; _pricingBreakdown = null; _effectivePrice = null; _productAvailabilityMessage = 'Seçilen ürün laboratuvarda mevcut ancak fiyatı tanımlı değil. Laboratuvarın fiyat tanımlaması gerekiyor.'; _priceLoading = false; }); return; } final discounts = await DiscountRepository.instance.listDiscounts(labId); final breakdown = PricingService.instance.calculate( product: product, prostheticType: _selectedProstheticType!, memberCount: _selectedTeeth.length, clinicTenantId: clinicTenantId, discounts: discounts, ); setState(() { _availableProducts = matchingProducts; _selectedProduct = product; _labProduct = product; _pricingBreakdown = breakdown; _effectivePrice = breakdown.finalAmount; _productAvailabilityMessage = null; _priceLoading = false; }); } catch (_) { setState(() { _availableProducts = []; _selectedProduct = null; _labProduct = null; _pricingBreakdown = null; _effectivePrice = null; _productAvailabilityMessage = 'Ürün ve fiyat bilgisi alınırken bir hata oluştu.'; _priceLoading = false; }); } } bool get _hasMissingProductForType => _selectedLab != null && _selectedProstheticType != null && !_priceLoading && _availableProducts.isEmpty; bool get _hasSelectedProductWithoutPrice => _selectedProduct != null && _selectedProduct!.unitPrice == null; bool get _canSubmitJob => !_isSubmitting && !_priceLoading && !_hasMissingProductForType && !_hasSelectedProductWithoutPrice; Future _searchPatients(String query) async { final normalizedQuery = query.trim(); if (normalizedQuery.length < 2) { if (!mounted) return; setState(() { _patientResults = []; _patientSearchLoading = false; }); return; } setState(() => _patientSearchLoading = true); try { final tenantId = ref.read(authProvider).activeTenant!.tenant.id; final results = await ClinicPatientsRepository.instance .listPatients(tenantId, search: normalizedQuery, limit: 10); if (!mounted || _patientSearchController.text.trim() != normalizedQuery) { return; } setState(() { _patientResults = results; _patientSearchLoading = false; }); } catch (_) { if (!mounted) return; setState(() => _patientSearchLoading = false); } } void _onPatientSearchChanged(String value) { _selectedPatient = null; _patientSearchDebounce?.cancel(); final query = value.trim(); if (query.length < 2) { setState(() { _patientResults = []; _patientSearchLoading = false; }); return; } setState(() => _patientSearchLoading = true); _patientSearchDebounce = Timer( const Duration(milliseconds: 300), () => _searchPatients(query), ); } void _setPatientEntryMode(_PatientEntryMode mode) { _patientSearchDebounce?.cancel(); setState(() { _patientEntryMode = mode; _selectedPatient = null; _patientResults = []; _patientSearchLoading = false; _patientSearchController.clear(); _patientNameController.clear(); _patientLastNameController.clear(); _patientCodeController.clear(); }); } Future _pickDueDate() async { final pickedDate = await showDatePicker( context: context, initialDate: DateTime.now().add(const Duration(days: 7)), firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 365)), ); if (pickedDate == null || !mounted) return; final pickedTime = await showTimePicker( context: context, initialTime: const TimeOfDay(hour: 17, minute: 0), ); if (!mounted) return; setState(() { _dueDate = DateTime( pickedDate.year, pickedDate.month, pickedDate.day, pickedTime?.hour ?? 17, pickedTime?.minute ?? 0, ); }); } String _generateProtocolNo() { final now = DateTime.now(); final date = '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}'; const chars = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ'; final rand = List.generate(4, (_) => chars[Random().nextInt(chars.length)]).join(); return 'PR-$date-$rand'; } Future _submit() async { if (!_formKey.currentState!.validate()) return; if (_selectedLab == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Lütfen bir laboratuvar seçin.')), ); return; } if (_selectedProstheticType == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Lütfen protez türünü seçin.')), ); return; } if (_hasMissingProductForType) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Bu laboratuvarda seçtiğiniz protez türü için ürün tanımlı değil.', ), ), ); return; } if (_hasSelectedProductWithoutPrice) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Seçilen ürünün fiyatı laboratuvarda tanımlı değil. İş açmadan önce fiyat tanımlanmalı.', ), ), ); return; } if (_selectedTeeth.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('En az bir diş seçmelisiniz.')), ); return; } setState(() => _isSubmitting = true); try { final auth = ref.read(authProvider); final tenantId = auth.activeTenant!.tenant.id; final clinicName = auth.activeTenant!.tenant.companyName; final rawCode = _patientCodeController.text.trim(); final rawFirstName = _patientNameController.text.trim(); final rawLastName = _patientLastNameController.text.trim(); Patient? patient = _selectedPatient; if (_patientEntryMode == _PatientEntryMode.selectExisting && patient == null && _patientSearchController.text.trim().isNotEmpty) { throw 'Lütfen listeden bir hasta seçin veya "Yeni Hasta Oluştur" moduna geçin.'; } final protocolNo = patient?.patientCode ?? (rawCode.isNotEmpty ? rawCode : _generateProtocolNo()); if (_patientEntryMode == _PatientEntryMode.createNew && patient == null && (rawFirstName.isNotEmpty || rawLastName.isNotEmpty)) { patient = await ClinicPatientsRepository.instance.createPatient( tenantId: tenantId, patientCode: protocolNo, firstName: rawFirstName.isNotEmpty ? rawFirstName : null, lastName: rawLastName.isNotEmpty ? rawLastName : null, ); } final job = await ClinicJobsRepository.instance.createJob( clinicTenantId: tenantId, labTenantId: _selectedLab!['id'] as String, clinicName: clinicName, labName: _selectedLab!['company_name'] as String? ?? 'Laboratuvar', patientCode: protocolNo, prostheticId: _selectedProduct?.id, prostheticType: _selectedProstheticType!, teeth: _selectedTeeth.map((t) => t.toString()).toList()..sort(), patientId: patient?.id, color: _colorController.text.trim().isNotEmpty ? _colorController.text.trim() : null, description: _descriptionController.text.trim().isNotEmpty ? _descriptionController.text.trim() : null, dueDate: _dueDate?.toIso8601String(), price: _effectivePrice, currency: _labProduct?.currency, workflowType: _selectedWorkflowType, provaRequired: _provaRequired, ); // Upload pending files if (_pendingFiles.isNotEmpty) { final pb = PocketBaseClient.instance.pb; final token = pb.authStore.token; final uploaderId = (pb.authStore.record?.id) ?? (auth.profile?.id ?? ''); for (final file in _pendingFiles) { final bytes = file.bytes; if (bytes == null) continue; final ext = (file.extension ?? '').toLowerCase(); final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply') ? 'scan' : (ext == 'pdf') ? 'document' : 'image'; final mimeType = _mimeFromExt(ext); final req = http.MultipartRequest( 'POST', Uri.parse('https://pocket.kovaksoft.com/api/collections/job_files/records'), ) ..headers['Authorization'] = 'Bearer $token' ..fields['job_id'] = job.id ..fields['clinic_tenant_id'] = job.clinicTenantId ..fields['lab_tenant_id'] = job.labTenantId ..fields['uploaded_by'] = uploaderId ..fields['kind'] = kind ..fields['name'] = file.name ..fields['size'] = bytes.length.toString() ..fields['mime_type'] = mimeType ..files.add(http.MultipartFile.fromBytes( 'file', bytes, filename: file.name, )); final response = await req.send(); if (response.statusCode < 200 || response.statusCode >= 300) { final body = await response.stream.bytesToString(); debugPrint('File upload failed: ${response.statusCode} $body'); } } } if (mounted) { context.go('/clinic/jobs/${job.id}'); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Hata: $e')), ); } } finally { if (mounted) setState(() => _isSubmitting = false); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Yeni İş')), body: Form( key: _formKey, child: ListView( padding: const EdgeInsets.all(16), children: [ // Lab selection _SectionLabel(label: 'Laboratuvar *'), if (_labsLoading) const Center(child: CircularProgressIndicator()) else if (_labsError != null) Row( children: [ Text('Hata: $_labsError', style: const TextStyle(color: AppColors.cancelled)), TextButton( onPressed: _loadLabs, child: const Text('Tekrar Dene'), ), ], ) else DropdownButtonFormField>( initialValue: _selectedLab, decoration: const InputDecoration( hintText: 'Laboratuvar seçin', ), items: _labs .map( (lab) => DropdownMenuItem( value: lab, child: Text(lab['company_name'] as String? ?? ''), ), ) .toList(), onChanged: (val) { setState(() { _selectedLab = val; _selectedProduct = null; }); _refreshProductsAndPrice(); }, validator: (val) => val == null ? 'Laboratuvar seçimi zorunludur' : null, ), const SizedBox(height: 16), _SectionLabel(label: 'Hasta / Protokol'), const SizedBox(height: 8), SegmentedButton<_PatientEntryMode>( segments: const [ ButtonSegment( value: _PatientEntryMode.selectExisting, icon: Icon(Icons.search_rounded), label: Text('Mevcut Hasta'), ), ButtonSegment( value: _PatientEntryMode.createNew, icon: Icon(Icons.person_add_alt_1_rounded), label: Text('Yeni Hasta'), ), ], selected: {_patientEntryMode}, onSelectionChanged: (selection) { _setPatientEntryMode(selection.first); }, ), if (_patientEntryMode == _PatientEntryMode.selectExisting) ...[ const SizedBox(height: 8), TextField( controller: _patientSearchController, decoration: const InputDecoration( hintText: 'Ad, soyad veya kod ile arayın...', prefixIcon: Icon(Icons.search), helperText: 'Yazmaya başlayınca otomatik arar', ), onChanged: _onPatientSearchChanged, ), if (_patientSearchLoading) const Padding( padding: EdgeInsets.all(8), child: Center(child: CircularProgressIndicator()), ), if (!_patientSearchLoading && _patientSearchController.text.trim().length >= 2 && _patientResults.isEmpty) const ListTile( dense: true, leading: Icon(Icons.info_outline), title: Text('Hasta bulunamadı'), subtitle: Text('İsterseniz "Yeni Hasta" modundan manuel ekleyebilirsiniz.'), ), ..._patientResults.map( (p) => ListTile( dense: true, leading: const Icon(Icons.person_outline), title: Text(p.displayName), subtitle: Text(p.patientCode), onTap: () { setState(() { _selectedPatient = p; _patientNameController.text = p.firstName ?? ''; _patientLastNameController.text = p.lastName ?? ''; _patientCodeController.text = p.patientCode; _patientResults.clear(); }); }, ), ), if (_selectedPatient != null) Container( margin: const EdgeInsets.only(top: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.successBg, borderRadius: BorderRadius.circular(12), border: Border.all( color: AppColors.success.withValues(alpha: 0.25), ), ), child: Row( children: [ const Icon(Icons.person, color: AppColors.success), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _selectedPatient!.displayName, style: const TextStyle( fontWeight: FontWeight.w700, color: AppColors.success, ), ), Text( _selectedPatient!.patientCode, style: TextStyle( fontSize: 12, color: AppColors.success.withValues(alpha: 0.8), ), ), ], ), ), TextButton( onPressed: () { setState(() { _selectedPatient = null; _patientNameController.clear(); _patientLastNameController.clear(); _patientCodeController.clear(); }); }, child: const Text('Temizle'), ), ], ), ), ] else ...[ Row( children: [ Expanded( child: TextFormField( controller: _patientNameController, decoration: const InputDecoration( hintText: 'Hasta adı', ), ), ), const SizedBox(width: 8), Expanded( child: TextFormField( controller: _patientLastNameController, decoration: const InputDecoration( hintText: 'Hasta soyadı', ), ), ), ], ), const SizedBox(height: 8), TextFormField( controller: _patientCodeController, decoration: const InputDecoration( hintText: 'Protokol no (boş bırakılırsa otomatik üretilir)', ), ), ], const SizedBox(height: 16), // Prosthetic type _SectionLabel(label: 'Protez Türü *'), DropdownButtonFormField( initialValue: _selectedProstheticType, decoration: const InputDecoration( hintText: 'Protez türü seçin', ), items: ProstheticType.values .map( (pt) => DropdownMenuItem( value: pt, child: Text(pt.label), ), ) .toList(), onChanged: (val) { setState(() { _selectedProstheticType = val; _selectedProduct = null; }); _refreshProductsAndPrice(); }, validator: (val) => val == null ? 'Protez türü zorunludur' : null, ), const SizedBox(height: 16), _SectionLabel(label: 'Ürün'), DropdownButtonFormField( initialValue: _selectedProduct, decoration: InputDecoration( hintText: _selectedProstheticType == null ? 'Önce protez türü seçin' : _availableProducts.isEmpty ? 'Bu tür için ürün bulunamadı' : 'Ürün seçin', ), items: _availableProducts .map( (product) => DropdownMenuItem( value: product, child: Text( product.unitPrice == null ? '${product.name} · Fiyat tanımsız' : product.name, ), ), ) .toList(), onChanged: (_selectedProstheticType == null || _availableProducts.isEmpty) ? null : (val) { setState(() => _selectedProduct = val); _refreshProductsAndPrice(); }, validator: (val) { if (_availableProducts.isNotEmpty && val == null) { return 'Lütfen ürün seçin'; } return null; }, ), if (_productAvailabilityMessage != null) ...[ const SizedBox(height: 8), _InlineInfoBanner( message: _productAvailabilityMessage!, tone: _hasMissingProductForType || _hasSelectedProductWithoutPrice ? _InfoBannerTone.warning : _InfoBannerTone.info, ), ], const SizedBox(height: 16), _SectionLabel(label: 'İş Tipi'), DropdownButtonFormField( initialValue: _selectedWorkflowType, decoration: const InputDecoration( hintText: 'İş tipi seçin', ), items: JobWorkflowType.values .map( (type) => DropdownMenuItem( value: type, child: Text(type.label), ), ) .toList(), onChanged: (val) => setState(() => _selectedWorkflowType = val), validator: (val) => val == null ? 'Lütfen iş tipi seçin' : null, ), // Price preview if (_priceLoading) const Padding( padding: EdgeInsets.only(top: 8), child: Row(children: [ SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 1.5)), SizedBox(width: 8), Text('Fiyat yükleniyor...', style: TextStyle(fontSize: 12, color: AppColors.textMuted)), ]), ) else if (_labProduct != null && _effectivePrice != null) ...[ const SizedBox(height: 8), _PricePreviewChip( product: _labProduct!, prostheticType: _selectedProstheticType, breakdown: _pricingBreakdown, effectivePrice: _effectivePrice!, ), ], const SizedBox(height: 12), // Prova flag _ProvaToggle( value: _provaRequired, prostheticType: _selectedProstheticType, onChanged: (v) => setState(() => _provaRequired = v), ), const SizedBox(height: 16), // Teeth selection _SectionLabel( label: 'Dişler * (${_selectedTeeth.length} seçili)', ), const SizedBox(height: 6), // Bulk select row _TeethBulkBar( selectedTeeth: _selectedTeeth, onSelectAll: () { setState(() { _selectedTeeth.addAll([ for (int i = 11; i <= 18; i++) i, for (int i = 21; i <= 28; i++) i, for (int i = 31; i <= 38; i++) i, for (int i = 41; i <= 48; i++) i, ]); }); _refreshProductsAndPrice(); }, onSelectUpper: () { setState(() { final upper = {...[for (int i = 11; i <= 18; i++) i], ...[for (int i = 21; i <= 28; i++) i]}; if (upper.every(_selectedTeeth.contains)) { _selectedTeeth.removeAll(upper); } else { _selectedTeeth.addAll(upper); } }); _refreshProductsAndPrice(); }, onSelectLower: () { setState(() { final lower = {...[for (int i = 31; i <= 38; i++) i], ...[for (int i = 41; i <= 48; i++) i]}; if (lower.every(_selectedTeeth.contains)) { _selectedTeeth.removeAll(lower); } else { _selectedTeeth.addAll(lower); } }); _refreshProductsAndPrice(); }, onClear: () { setState(() => _selectedTeeth.clear()); _refreshProductsAndPrice(); }, ), const SizedBox(height: 8), _TeethGrid( selectedTeeth: _selectedTeeth, onToggle: (t) { setState(() { if (_selectedTeeth.contains(t)) { _selectedTeeth.remove(t); } else { _selectedTeeth.add(t); } }); _refreshProductsAndPrice(); }, ), const SizedBox(height: 16), // Color (optional) _SectionLabel(label: 'Renk (İsteğe Bağlı)'), TextFormField( controller: _colorController, decoration: const InputDecoration( hintText: 'Ör: A2, B3', ), ), const SizedBox(height: 16), // Description (optional) _SectionLabel(label: 'Açıklama (İsteğe Bağlı)'), TextFormField( controller: _descriptionController, decoration: const InputDecoration( hintText: 'Ek notlar...', ), minLines: 2, maxLines: 4, ), const SizedBox(height: 16), // Due date (optional) _SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'), InkWell( onTap: _pickDueDate, child: InputDecorator( decoration: const InputDecoration( suffixIcon: Icon(Icons.calendar_today), ), child: Text( _dueDate != null ? '${_dueDate!.day.toString().padLeft(2, '0')}.${_dueDate!.month.toString().padLeft(2, '0')}.${_dueDate!.year} ${_dueDate!.hour.toString().padLeft(2, '0')}:${_dueDate!.minute.toString().padLeft(2, '0')}' : 'Tarih ve saat seçin', style: _dueDate != null ? null : TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ), const SizedBox(height: 16), // File attachments (optional) _SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'), _FilePicker( files: _pendingFiles, onAdd: () async { final result = await FilePicker.platform.pickFiles( allowMultiple: true, withData: true, ); if (result != null) { setState(() => _pendingFiles.addAll(result.files)); } }, onRemove: (i) => setState(() => _pendingFiles.removeAt(i)), ), const SizedBox(height: 24), // Submit button if (_isSubmitting) const Center(child: CircularProgressIndicator()) else FilledButton.icon( onPressed: _canSubmitJob ? _submit : null, icon: const Icon(Icons.check), label: const Text('İş Oluştur'), style: FilledButton.styleFrom( minimumSize: const Size(double.infinity, 52), ), ), const SizedBox(height: 24), ], ), ), ); } } enum _InfoBannerTone { info, warning } class _InlineInfoBanner extends StatelessWidget { const _InlineInfoBanner({ required this.message, required this.tone, }); final String message; final _InfoBannerTone tone; @override Widget build(BuildContext context) { final isWarning = tone == _InfoBannerTone.warning; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: isWarning ? AppColors.pendingBg : AppColors.surfaceVariant, borderRadius: BorderRadius.circular(10), border: Border.all( color: isWarning ? AppColors.pending : AppColors.border, ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( isWarning ? Icons.warning_amber_rounded : Icons.info_outline_rounded, size: 18, color: isWarning ? AppColors.pending : AppColors.textSecondary, ), const SizedBox(width: 8), Expanded( child: Text( message, style: TextStyle( fontSize: 12, color: isWarning ? AppColors.pending : AppColors.textSecondary, fontWeight: isWarning ? FontWeight.w600 : FontWeight.w500, ), ), ), ], ), ); } } class _TeethBulkBar extends StatelessWidget { const _TeethBulkBar({ required this.selectedTeeth, required this.onSelectAll, required this.onSelectUpper, required this.onSelectLower, required this.onClear, }); final Set selectedTeeth; final VoidCallback onSelectAll; final VoidCallback onSelectUpper; final VoidCallback onSelectLower; final VoidCallback onClear; bool _allUpperSelected() { final upper = [for (int i = 11; i <= 18; i++) i, for (int i = 21; i <= 28; i++) i]; return upper.every(selectedTeeth.contains); } bool _allLowerSelected() { final lower = [for (int i = 31; i <= 38; i++) i, for (int i = 41; i <= 48; i++) i]; return lower.every(selectedTeeth.contains); } @override Widget build(BuildContext context) { final allSelected = _allUpperSelected() && _allLowerSelected(); return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ _BulkChip( label: 'Tüm Dişler', active: allSelected, onTap: allSelected ? onClear : onSelectAll, icon: Icons.select_all_rounded, ), const SizedBox(width: 6), _BulkChip( label: 'Üst Çene', active: _allUpperSelected(), onTap: onSelectUpper, icon: Icons.arrow_upward_rounded, ), const SizedBox(width: 6), _BulkChip( label: 'Alt Çene', active: _allLowerSelected(), onTap: onSelectLower, icon: Icons.arrow_downward_rounded, ), const SizedBox(width: 6), if (selectedTeeth.isNotEmpty) _BulkChip( label: 'Temizle', active: false, onTap: onClear, icon: Icons.clear_rounded, destructive: true, ), ], ), ); } } class _BulkChip extends StatelessWidget { const _BulkChip({ required this.label, required this.active, required this.onTap, required this.icon, this.destructive = false, }); final String label; final bool active; final VoidCallback onTap; final IconData icon; final bool destructive; @override Widget build(BuildContext context) { final color = destructive ? AppColors.cancelled : active ? AppColors.accent : AppColors.textSecondary; final bg = destructive ? AppColors.cancelledBg : active ? AppColors.inProgressBg : AppColors.surfaceVariant; return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 120), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(20), border: Border.all( color: active && !destructive ? AppColors.accent : AppColors.border, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: color), const SizedBox(width: 4), Text( label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: color), ), ], ), ), ); } } class _TeethGrid extends StatelessWidget { const _TeethGrid({ required this.selectedTeeth, required this.onToggle, }); final Set selectedTeeth; final ValueChanged onToggle; @override Widget build(BuildContext context) { // Upper jaw: 18-11, 21-28 // Lower jaw: 48-41, 31-38 final upperRight = List.generate(8, (i) => 18 - i); // 18..11 final upperLeft = List.generate(8, (i) => 21 + i); // 21..28 final lowerRight = List.generate(8, (i) => 48 - i); // 48..41 final lowerLeft = List.generate(8, (i) => 31 + i); // 31..38 return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Upper jaw label Text('Üst Çene', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, )), const SizedBox(height: 4), Row( children: [ ...upperRight.map((t) => _ToothButton( tooth: t, selected: selectedTeeth.contains(t), onTap: () => onToggle(t), )), const VerticalDivider(width: 8), ...upperLeft.map((t) => _ToothButton( tooth: t, selected: selectedTeeth.contains(t), onTap: () => onToggle(t), )), ], ), const SizedBox(height: 8), // Lower jaw Text('Alt Çene', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, )), const SizedBox(height: 4), Row( children: [ ...lowerRight.map((t) => _ToothButton( tooth: t, selected: selectedTeeth.contains(t), onTap: () => onToggle(t), )), const VerticalDivider(width: 8), ...lowerLeft.map((t) => _ToothButton( tooth: t, selected: selectedTeeth.contains(t), onTap: () => onToggle(t), )), ], ), ], ); } } class _ToothButton extends StatelessWidget { const _ToothButton({ required this.tooth, required this.selected, required this.onTap, }); final int tooth; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { return Expanded( child: GestureDetector( onTap: onTap, child: Container( margin: const EdgeInsets.all(1.5), height: 36, decoration: BoxDecoration( color: selected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Center( child: Text( '$tooth', style: TextStyle( fontSize: 9, fontWeight: FontWeight.bold, color: selected ? Theme.of(context).colorScheme.onPrimary : Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ), ), ); } } class _FilePicker extends StatelessWidget { const _FilePicker({ required this.files, required this.onAdd, required this.onRemove, }); final List files; final VoidCallback onAdd; final void Function(int index) onRemove; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (files.isNotEmpty) ...[ ...files.asMap().entries.map((e) { final i = e.key; final f = e.value; return Container( margin: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: AppColors.surfaceVariant, borderRadius: BorderRadius.circular(10), border: Border.all(color: AppColors.border), ), child: Row( children: [ const Icon(Icons.attach_file, size: 16, color: AppColors.textSecondary), const SizedBox(width: 8), Expanded( child: Text( f.name, style: const TextStyle( fontSize: 13, color: AppColors.textPrimary), overflow: TextOverflow.ellipsis, ), ), Text( _formatSize(f.size), style: const TextStyle( fontSize: 11, color: AppColors.textMuted), ), const SizedBox(width: 4), GestureDetector( onTap: () => onRemove(i), child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), ), ], ), ); }), const SizedBox(height: 4), ], OutlinedButton.icon( onPressed: onAdd, icon: const Icon(Icons.upload_file_outlined, size: 18), label: const Text('Dosya Seç'), style: OutlinedButton.styleFrom( foregroundColor: AppColors.accent, side: const BorderSide(color: AppColors.accent), ), ), ], ); } String _formatSize(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } } class _PricePreviewChip extends StatelessWidget { const _PricePreviewChip({ required this.product, required this.effectivePrice, this.prostheticType, this.breakdown, }); final ProstheticProduct product; final double effectivePrice; final ProstheticType? prostheticType; final PricingBreakdown? breakdown; @override Widget build(BuildContext context) { final currency = product.currency ?? 'TRY'; final unitPrice = product.unitPrice!; final hasDiscount = (breakdown?.discountAmount ?? 0) > 0.01; final units = breakdown?.billableUnits ?? 1; final unitLabel = prostheticType != null ? PricingService.instance.unitLabelForType(prostheticType!) : 'adet'; final baseAmount = breakdown?.baseAmount ?? unitPrice; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: AppColors.successBg, borderRadius: BorderRadius.circular(10), border: Border.all(color: AppColors.success.withValues(alpha: 0.25)), ), child: Row( children: [ const Icon(Icons.sell_outlined, size: 16, color: AppColors.success), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${product.name} — ${effectivePrice.toStringAsFixed(2)} $currency', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.success), ), Text( '${unitPrice.toStringAsFixed(2)} $currency x $units $unitLabel', style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)), ), if (hasDiscount) Text( 'Liste: ${baseAmount.toStringAsFixed(2)} $currency · İndirim uygulandı', style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)), ) else Text( 'Liste fiyatı · İndirim yok', style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)), ), ], ), ), ], ), ); } } class _SectionLabel extends StatelessWidget { const _SectionLabel({required this.label}); final String label; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 6), child: Text( label, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), ); } } class _ProvaToggle extends StatelessWidget { const _ProvaToggle({ required this.value, required this.onChanged, this.prostheticType, }); final bool value; final ValueChanged onChanged; final ProstheticType? prostheticType; @override Widget build(BuildContext context) { final steps = prostheticType != null ? jobStepTemplate(prostheticType!, value) : []; return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: value ? AppColors.inProgressBg : AppColors.surfaceVariant, borderRadius: BorderRadius.circular(12), border: Border.all( color: value ? AppColors.inProgress.withValues(alpha: 0.3) : AppColors.border, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( value ? Icons.swap_horiz_rounded : Icons.straighten_rounded, size: 20, color: value ? AppColors.inProgress : AppColors.textSecondary, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( value ? 'Provalı İş' : 'Provasız İş', style: TextStyle( fontWeight: FontWeight.w700, color: value ? AppColors.inProgress : AppColors.textPrimary, fontSize: 14, ), ), Text( value ? 'Lab her adımda klinik onayı bekler' : 'Lab doğrudan üretip teslime gönderir', style: const TextStyle( fontSize: 12, color: AppColors.textSecondary), ), ], ), ), Switch( value: value, onChanged: onChanged, activeColor: AppColors.inProgress, ), ], ), if (steps.isNotEmpty) ...[ const SizedBox(height: 8), Wrap( spacing: 6, children: steps.map((s) => Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(6), border: Border.all(color: AppColors.border), ), child: Text( s.label, style: const TextStyle( fontSize: 11, color: AppColors.textSecondary), ), )).toList(), ), ], ], ), ); } }