Files
lab-app/lib/features/clinic/jobs/new_job_screen.dart
T
2026-06-20 18:24:40 +03:00

1544 lines
51 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '../../../models/tenant.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<NewJobScreen> createState() => _NewJobScreenState();
}
class _NewJobScreenState extends ConsumerState<NewJobScreen> {
final _formKey = GlobalKey<FormState>();
// Form fields
Map<String, dynamic>? _selectedLab;
Patient? _selectedPatient;
final _patientNameController = TextEditingController();
final _patientLastNameController = TextEditingController();
final _patientCodeController = TextEditingController();
ProstheticType? _selectedProstheticType;
JobWorkflowType? _selectedWorkflowType = JobWorkflowType.geleneksel;
final Set<int> _selectedTeeth = {};
final _colorController = TextEditingController();
final _descriptionController = TextEditingController();
DateTime? _dueDate;
bool _provaRequired = true;
_PatientEntryMode _patientEntryMode = _PatientEntryMode.selectExisting;
// State
List<Map<String, dynamic>> _labs = [];
bool _labsLoading = true;
bool _isSubmitting = false;
String? _labsError;
// File upload
final List<PlatformFile> _pendingFiles = [];
// Patient search
final _patientSearchController = TextEditingController();
List<Patient> _patientResults = [];
bool _patientSearchLoading = false;
Timer? _patientSearchDebounce;
// Price preview
List<ProstheticProduct> _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<void> _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<void> _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<void> _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<void> _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<void> _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 selectedLabTenant = Tenant.fromJson(_selectedLab!);
final workflowSteps = buildJobWorkflowPreset(
prostheticType: _selectedProstheticType!,
workflowType: _selectedWorkflowType,
provaRequired: _provaRequired,
optionalSteps: selectedLabTenant.workflowOverrideSteps,
).steps;
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,
workflowSteps: workflowSteps.map((step) => step.value).toList(),
);
// 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
const _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<Map<String, dynamic>>(
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),
const _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
const _SectionLabel(label: 'Protez Türü *'),
DropdownButtonFormField<ProstheticType>(
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),
const _SectionLabel(label: 'Ürün'),
DropdownButtonFormField<ProstheticProduct>(
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),
const _SectionLabel(label: 'İş Tipi'),
DropdownButtonFormField<JobWorkflowType>(
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,
workflowType: _selectedWorkflowType,
optionalSteps: _selectedLab != null
? Tenant.fromJson(_selectedLab!).workflowOverrideSteps
: const [],
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)
const _SectionLabel(label: 'Renk (İsteğe Bağlı)'),
TextFormField(
controller: _colorController,
decoration: const InputDecoration(
hintText: 'Ör: A2, B3',
),
),
const SizedBox(height: 16),
// Description (optional)
const _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)
const _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)
const _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<int> 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<int> selectedTeeth;
final ValueChanged<int> 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<PlatformFile> 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,
required this.optionalSteps,
this.prostheticType,
this.workflowType,
});
final bool value;
final ValueChanged<bool> onChanged;
final ProstheticType? prostheticType;
final JobWorkflowType? workflowType;
final List<JobStep> optionalSteps;
@override
Widget build(BuildContext context) {
final preset = prostheticType != null
? buildJobWorkflowPreset(
prostheticType: prostheticType!,
workflowType: workflowType,
provaRequired: value,
optionalSteps: optionalSteps,
)
: null;
final steps = preset?.steps ?? <JobStep>[];
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(
preset?.title ??
(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,
activeThumbColor: AppColors.inProgress,
),
],
),
if (steps.isNotEmpty) ...[
const SizedBox(height: 8),
if (preset != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
preset.summary,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
),
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(),
),
],
],
),
);
}
}