feat: improve patient flow and pricing workflow
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -8,8 +8,8 @@ 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/clinic_discount.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../../../models/patient.dart';
|
||||
import '../../../models/prosthetic_product.dart';
|
||||
@@ -18,6 +18,11 @@ 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',
|
||||
@@ -46,13 +51,17 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
// 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 = [];
|
||||
@@ -64,13 +73,16 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
final List<PlatformFile> _pendingFiles = [];
|
||||
|
||||
// Patient search
|
||||
bool _showPatientSearch = false;
|
||||
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;
|
||||
|
||||
@@ -82,6 +94,9 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_patientSearchDebounce?.cancel();
|
||||
_patientNameController.dispose();
|
||||
_patientLastNameController.dispose();
|
||||
_patientCodeController.dispose();
|
||||
_colorController.dispose();
|
||||
_descriptionController.dispose();
|
||||
@@ -111,9 +126,15 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchPrice() async {
|
||||
Future<void> _refreshProductsAndPrice() async {
|
||||
if (_selectedLab == null || _selectedProstheticType == null) {
|
||||
setState(() { _labProduct = null; _effectivePrice = null; });
|
||||
setState(() {
|
||||
_availableProducts = [];
|
||||
_selectedProduct = null;
|
||||
_labProduct = null;
|
||||
_pricingBreakdown = null;
|
||||
_effectivePrice = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final labId = _selectedLab!['id'] as String;
|
||||
@@ -122,43 +143,73 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
|
||||
setState(() => _priceLoading = true);
|
||||
try {
|
||||
final products = await LabProductsRepository.instance.listProducts(labId, isActive: true);
|
||||
final products = await LabProductsRepository.instance.listProducts(
|
||||
labId,
|
||||
isActive: true,
|
||||
);
|
||||
final matchingProducts = products
|
||||
.where((p) => p.prostheticType == ptValue)
|
||||
.toList();
|
||||
|
||||
ProstheticProduct? product;
|
||||
try {
|
||||
product = products.firstWhere((p) => p.prostheticType == ptValue);
|
||||
} catch (_) {
|
||||
product = null;
|
||||
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(() { _labProduct = null; _effectivePrice = null; _priceLoading = false; });
|
||||
setState(() {
|
||||
_availableProducts = matchingProducts;
|
||||
_selectedProduct = product;
|
||||
_labProduct = null;
|
||||
_pricingBreakdown = null;
|
||||
_effectivePrice = null;
|
||||
_priceLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final discounts = await DiscountRepository.instance.listDiscounts(labId);
|
||||
final applicable = discounts.where((d) =>
|
||||
d.isActive &&
|
||||
(d.appliesToAll || d.clinicTenantId == clinicTenantId) &&
|
||||
(d.appliesToAllTypes || d.prostheticType == ptValue)
|
||||
).toList();
|
||||
|
||||
double price = product.unitPrice!;
|
||||
for (final d in applicable) {
|
||||
price = d.discountType == DiscountType.percentage
|
||||
? price * (1 - d.discountValue / 100)
|
||||
: price - d.discountValue;
|
||||
}
|
||||
final breakdown = PricingService.instance.calculate(
|
||||
product: product,
|
||||
prostheticType: _selectedProstheticType!,
|
||||
memberCount: _selectedTeeth.length,
|
||||
clinicTenantId: clinicTenantId,
|
||||
discounts: discounts,
|
||||
);
|
||||
setState(() {
|
||||
_availableProducts = matchingProducts;
|
||||
_selectedProduct = product;
|
||||
_labProduct = product;
|
||||
_effectivePrice = price.clamp(0, double.infinity);
|
||||
_pricingBreakdown = breakdown;
|
||||
_effectivePrice = breakdown.finalAmount;
|
||||
_priceLoading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
setState(() { _labProduct = null; _effectivePrice = null; _priceLoading = false; });
|
||||
setState(() {
|
||||
_availableProducts = [];
|
||||
_selectedProduct = null;
|
||||
_labProduct = null;
|
||||
_pricingBreakdown = null;
|
||||
_effectivePrice = null;
|
||||
_priceLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _searchPatients(String query) async {
|
||||
if (query.trim().isEmpty) {
|
||||
setState(() => _patientResults = []);
|
||||
final normalizedQuery = query.trim();
|
||||
if (normalizedQuery.length < 2) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_patientResults = [];
|
||||
_patientSearchLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() => _patientSearchLoading = true);
|
||||
@@ -166,16 +217,52 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
final tenantId =
|
||||
ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final results = await ClinicPatientsRepository.instance
|
||||
.listPatients(tenantId, search: query, limit: 10);
|
||||
.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,
|
||||
@@ -231,16 +318,38 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
try {
|
||||
final auth = ref.read(authProvider);
|
||||
final tenantId = auth.activeTenant!.tenant.id;
|
||||
final clinicName = auth.activeTenant!.tenant.companyName;
|
||||
final rawCode = _patientCodeController.text.trim();
|
||||
final protocolNo = rawCode.isNotEmpty ? rawCode : _generateProtocolNo();
|
||||
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: '',
|
||||
prostheticId: _selectedProduct?.id,
|
||||
prostheticType: _selectedProstheticType!,
|
||||
teeth: _selectedTeeth.map((t) => t.toString()).toList()..sort(),
|
||||
patientId: _selectedPatient?.id,
|
||||
patientId: patient?.id,
|
||||
color: _colorController.text.trim().isNotEmpty
|
||||
? _colorController.text.trim()
|
||||
: null,
|
||||
@@ -248,6 +357,9 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
? _descriptionController.text.trim()
|
||||
: null,
|
||||
dueDate: _dueDate?.toIso8601String(),
|
||||
price: _effectivePrice,
|
||||
currency: _labProduct?.currency,
|
||||
workflowType: _selectedWorkflowType,
|
||||
provaRequired: _provaRequired,
|
||||
);
|
||||
|
||||
@@ -343,57 +455,62 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (val) {
|
||||
setState(() => _selectedLab = val);
|
||||
_fetchPrice();
|
||||
setState(() {
|
||||
_selectedLab = val;
|
||||
_selectedProduct = null;
|
||||
});
|
||||
_refreshProductsAndPrice();
|
||||
},
|
||||
validator: (val) =>
|
||||
val == null ? 'Laboratuvar seçimi zorunludur' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Protocol number
|
||||
_SectionLabel(label: 'Protokol No (İsteğe Bağlı)'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _patientCodeController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Boş bırakılırsa otomatik üretilir',
|
||||
suffixIcon: _selectedPatient != null
|
||||
? const Icon(Icons.person,
|
||||
color: AppColors.success)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
_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'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
setState(() => _showPatientSearch = !_showPatientSearch);
|
||||
},
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Ara'),
|
||||
ButtonSegment(
|
||||
value: _PatientEntryMode.createNew,
|
||||
icon: Icon(Icons.person_add_alt_1_rounded),
|
||||
label: Text('Yeni Hasta'),
|
||||
),
|
||||
],
|
||||
selected: {_patientEntryMode},
|
||||
onSelectionChanged: (selection) {
|
||||
_setPatientEntryMode(selection.first);
|
||||
},
|
||||
),
|
||||
|
||||
// Patient search panel
|
||||
if (_showPatientSearch) ...[
|
||||
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: _searchPatients,
|
||||
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,
|
||||
@@ -403,14 +520,93 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedPatient = p;
|
||||
_patientNameController.text = p.firstName ?? '';
|
||||
_patientLastNameController.text = p.lastName ?? '';
|
||||
_patientCodeController.text = p.patientCode;
|
||||
_showPatientSearch = false;
|
||||
_patientSearchController.clear();
|
||||
_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),
|
||||
|
||||
@@ -430,12 +626,69 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (val) {
|
||||
setState(() => _selectedProstheticType = val);
|
||||
_fetchPrice();
|
||||
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(
|
||||
@@ -450,6 +703,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 8),
|
||||
_PricePreviewChip(
|
||||
product: _labProduct!,
|
||||
prostheticType: _selectedProstheticType,
|
||||
breakdown: _pricingBreakdown,
|
||||
effectivePrice: _effectivePrice!,
|
||||
),
|
||||
],
|
||||
@@ -471,31 +726,43 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
// 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,
|
||||
]);
|
||||
}),
|
||||
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);
|
||||
}
|
||||
}),
|
||||
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);
|
||||
}
|
||||
}),
|
||||
onClear: () => setState(() => _selectedTeeth.clear()),
|
||||
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(
|
||||
@@ -508,6 +775,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
_selectedTeeth.add(t);
|
||||
}
|
||||
});
|
||||
_refreshProductsAndPrice();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -908,16 +1176,28 @@ class _FilePicker extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _PricePreviewChip extends StatelessWidget {
|
||||
const _PricePreviewChip({required this.product, required this.effectivePrice});
|
||||
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 = (effectivePrice - unitPrice).abs() > 0.01;
|
||||
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),
|
||||
@@ -938,9 +1218,13 @@ class _PricePreviewChip extends StatelessWidget {
|
||||
'${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: ${unitPrice.toStringAsFixed(2)} $currency · İndirim uygulandı',
|
||||
'Liste: ${baseAmount.toStringAsFixed(2)} $currency · İndirim uygulandı',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)),
|
||||
)
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user