1352 lines
45 KiB
Dart
1352 lines
45 KiB
Dart
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<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;
|
||
|
||
@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;
|
||
});
|
||
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 || product.unitPrice == null) {
|
||
setState(() {
|
||
_availableProducts = matchingProducts;
|
||
_selectedProduct = product;
|
||
_labProduct = null;
|
||
_pricingBreakdown = null;
|
||
_effectivePrice = null;
|
||
_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;
|
||
_priceLoading = false;
|
||
});
|
||
} catch (_) {
|
||
setState(() {
|
||
_availableProducts = [];
|
||
_selectedProduct = null;
|
||
_labProduct = null;
|
||
_pricingBreakdown = null;
|
||
_effectivePrice = null;
|
||
_priceLoading = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
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 (_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<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),
|
||
|
||
_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<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),
|
||
|
||
_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.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;
|
||
},
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
_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,
|
||
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: _submit,
|
||
icon: const Icon(Icons.check),
|
||
label: const Text('İş Oluştur'),
|
||
style: FilledButton.styleFrom(
|
||
minimumSize: const Size(double.infinity, 52),
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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,
|
||
this.prostheticType,
|
||
});
|
||
|
||
final bool value;
|
||
final ValueChanged<bool> onChanged;
|
||
final ProstheticType? prostheticType;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final steps = prostheticType != null
|
||
? jobStepTemplate(prostheticType!, value)
|
||
: <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(
|
||
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(),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|