Add pricing entry flow and platform admin foundations

This commit is contained in:
egecankomur
2026-06-20 18:24:40 +03:00
parent 1d36ccdf30
commit ac42681f7e
44 changed files with 6567 additions and 1419 deletions
+147 -73
View File
@@ -13,6 +13,7 @@ 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';
@@ -111,8 +112,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
_labsError = null;
});
try {
final tenantId =
ref.read(authProvider).activeTenant!.tenant.id;
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
final labs =
await ClinicJobsRepository.instance.listApprovedLabs(tenantId);
setState(() {
@@ -149,9 +149,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
labId,
isActive: true,
);
final matchingProducts = products
.where((p) => p.prostheticType == ptValue)
.toList();
final matchingProducts =
products.where((p) => p.prostheticType == ptValue).toList();
ProstheticProduct? product;
if (_selectedProduct != null) {
@@ -230,8 +229,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
_availableProducts.isEmpty;
bool get _hasSelectedProductWithoutPrice =>
_selectedProduct != null &&
_selectedProduct!.unitPrice == null;
_selectedProduct != null && _selectedProduct!.unitPrice == null;
bool get _canSubmitJob =>
!_isSubmitting &&
@@ -251,8 +249,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
}
setState(() => _patientSearchLoading = true);
try {
final tenantId =
ref.read(authProvider).activeTenant!.tenant.id;
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) {
@@ -315,8 +312,11 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
if (!mounted) return;
setState(() {
_dueDate = DateTime(
pickedDate.year, pickedDate.month, pickedDate.day,
pickedTime?.hour ?? 17, pickedTime?.minute ?? 0,
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime?.hour ?? 17,
pickedTime?.minute ?? 0,
);
});
}
@@ -326,7 +326,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
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();
final rand =
List.generate(4, (_) => chars[Random().nextInt(chars.length)]).join();
return 'PR-$date-$rand';
}
@@ -397,6 +398,13 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
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,
@@ -418,24 +426,29 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
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 ?? '');
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';
: (ext == 'pdf')
? 'document'
: 'image';
final mimeType = _mimeFromExt(ext);
final req = http.MultipartRequest(
'POST',
Uri.parse('https://pocket.kovaksoft.com/api/collections/job_files/records'),
Uri.parse(
'https://pocket.kovaksoft.com/api/collections/job_files/records'),
)
..headers['Authorization'] = 'Bearer $token'
..fields['job_id'] = job.id
@@ -483,7 +496,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
padding: const EdgeInsets.all(16),
children: [
// Lab selection
_SectionLabel(label: 'Laboratuvar *'),
const _SectionLabel(label: 'Laboratuvar *'),
if (_labsLoading)
const Center(child: CircularProgressIndicator())
else if (_labsError != null)
@@ -523,7 +536,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
),
const SizedBox(height: 16),
_SectionLabel(label: 'Hasta / Protokol'),
const _SectionLabel(label: 'Hasta / Protokol'),
const SizedBox(height: 8),
SegmentedButton<_PatientEntryMode>(
segments: const [
@@ -566,7 +579,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
dense: true,
leading: Icon(Icons.info_outline),
title: Text('Hasta bulunamadı'),
subtitle: Text('İsterseniz "Yeni Hasta" modundan manuel ekleyebilirsiniz.'),
subtitle: Text(
'İsterseniz "Yeni Hasta" modundan manuel ekleyebilirsiniz.'),
),
..._patientResults.map(
(p) => ListTile(
@@ -668,7 +682,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
const SizedBox(height: 16),
// Prosthetic type
_SectionLabel(label: 'Protez Türü *'),
const _SectionLabel(label: 'Protez Türü *'),
DropdownButtonFormField<ProstheticType>(
initialValue: _selectedProstheticType,
decoration: const InputDecoration(
@@ -689,12 +703,11 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
});
_refreshProductsAndPrice();
},
validator: (val) =>
val == null ? 'Protez türü zorunludur' : null,
validator: (val) => val == null ? 'Protez türü zorunludur' : null,
),
const SizedBox(height: 16),
_SectionLabel(label: 'Ürün'),
const _SectionLabel(label: 'Ürün'),
DropdownButtonFormField<ProstheticProduct>(
initialValue: _selectedProduct,
decoration: InputDecoration(
@@ -716,7 +729,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
),
)
.toList(),
onChanged: (_selectedProstheticType == null || _availableProducts.isEmpty)
onChanged: (_selectedProstheticType == null ||
_availableProducts.isEmpty)
? null
: (val) {
setState(() => _selectedProduct = val);
@@ -733,14 +747,15 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
const SizedBox(height: 8),
_InlineInfoBanner(
message: _productAvailabilityMessage!,
tone: _hasMissingProductForType || _hasSelectedProductWithoutPrice
? _InfoBannerTone.warning
: _InfoBannerTone.info,
tone:
_hasMissingProductForType || _hasSelectedProductWithoutPrice
? _InfoBannerTone.warning
: _InfoBannerTone.info,
),
],
const SizedBox(height: 16),
_SectionLabel(label: 'İş Tipi'),
const _SectionLabel(label: 'İş Tipi'),
DropdownButtonFormField<JobWorkflowType>(
initialValue: _selectedWorkflowType,
decoration: const InputDecoration(
@@ -754,19 +769,22 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
),
)
.toList(),
onChanged: (val) =>
setState(() => _selectedWorkflowType = val),
validator: (val) =>
val == null ? 'Lütfen iş tipi seçin' : null,
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: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 1.5)),
SizedBox(width: 8),
Text('Fiyat yükleniyor...', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
Text('Fiyat yükleniyor...',
style:
TextStyle(fontSize: 12, color: AppColors.textMuted)),
]),
)
else if (_labProduct != null && _effectivePrice != null) ...[
@@ -784,6 +802,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
_ProvaToggle(
value: _provaRequired,
prostheticType: _selectedProstheticType,
workflowType: _selectedWorkflowType,
optionalSteps: _selectedLab != null
? Tenant.fromJson(_selectedLab!).workflowOverrideSteps
: const [],
onChanged: (v) => setState(() => _provaRequired = v),
),
const SizedBox(height: 16),
@@ -809,7 +831,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
},
onSelectUpper: () {
setState(() {
final upper = {...[for (int i = 11; i <= 18; i++) i], ...[for (int i = 21; i <= 28; i++) i]};
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 {
@@ -820,7 +845,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
},
onSelectLower: () {
setState(() {
final lower = {...[for (int i = 31; i <= 38; i++) i], ...[for (int i = 41; i <= 48; i++) i]};
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 {
@@ -851,7 +879,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
const SizedBox(height: 16),
// Color (optional)
_SectionLabel(label: 'Renk (İsteğe Bağlı)'),
const _SectionLabel(label: 'Renk (İsteğe Bağlı)'),
TextFormField(
controller: _colorController,
decoration: const InputDecoration(
@@ -861,7 +889,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
const SizedBox(height: 16),
// Description (optional)
_SectionLabel(label: 'Açıklama (İsteğe Bağlı)'),
const _SectionLabel(label: 'Açıklama (İsteğe Bağlı)'),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
@@ -873,7 +901,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
const SizedBox(height: 16),
// Due date (optional)
_SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'),
const _SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'),
InkWell(
onTap: _pickDueDate,
child: InputDecorator(
@@ -895,7 +923,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
const SizedBox(height: 16),
// File attachments (optional)
_SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'),
const _SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'),
_FilePicker(
files: _pendingFiles,
onAdd: () async {
@@ -958,7 +986,9 @@ class _InlineInfoBanner extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isWarning ? Icons.warning_amber_rounded : Icons.info_outline_rounded,
isWarning
? Icons.warning_amber_rounded
: Icons.info_outline_rounded,
size: 18,
color: isWarning ? AppColors.pending : AppColors.textSecondary,
),
@@ -995,12 +1025,18 @@ class _TeethBulkBar extends StatelessWidget {
final VoidCallback onClear;
bool _allUpperSelected() {
final upper = [for (int i = 11; i <= 18; i++) i, for (int i = 21; i <= 28; i++) i];
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];
final lower = [
for (int i = 31; i <= 38; i++) i,
for (int i = 41; i <= 48; i++) i
];
return lower.every(selectedTeeth.contains);
}
@@ -1094,9 +1130,7 @@ class _BulkChip extends StatelessWidget {
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color),
fontSize: 12, fontWeight: FontWeight.w600, color: color),
),
],
),
@@ -1247,7 +1281,8 @@ class _FilePicker extends StatelessWidget {
),
child: Row(
children: [
const Icon(Icons.attach_file, size: 16, color: AppColors.textSecondary),
const Icon(Icons.attach_file,
size: 16, color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: Text(
@@ -1265,7 +1300,8 @@ class _FilePicker extends StatelessWidget {
const SizedBox(width: 4),
GestureDetector(
onTap: () => onRemove(i),
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
child: const Icon(Icons.close,
size: 16, color: AppColors.textSecondary),
),
],
),
@@ -1334,21 +1370,30 @@ class _PricePreviewChip extends StatelessWidget {
children: [
Text(
'${product.name}${effectivePrice.toStringAsFixed(2)} $currency',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.success),
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)),
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)),
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)),
style: TextStyle(
fontSize: 11,
color: AppColors.success.withValues(alpha: 0.75)),
),
],
),
@@ -1381,18 +1426,28 @@ 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 steps = prostheticType != null
? jobStepTemplate(prostheticType!, value)
: <JobStep>[];
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),
@@ -1400,7 +1455,9 @@ class _ProvaToggle extends StatelessWidget {
color: value ? AppColors.inProgressBg : AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: value ? AppColors.inProgress.withValues(alpha: 0.3) : AppColors.border,
color: value
? AppColors.inProgress.withValues(alpha: 0.3)
: AppColors.border,
),
),
child: Column(
@@ -1422,14 +1479,17 @@ class _ProvaToggle extends StatelessWidget {
value ? 'Provalı İş' : 'Provasız İş',
style: TextStyle(
fontWeight: FontWeight.w700,
color: value ? AppColors.inProgress : AppColors.textPrimary,
color: value
? AppColors.inProgress
: AppColors.textPrimary,
fontSize: 14,
),
),
Text(
value
? 'Lab her adımda klinik onayı bekler'
: 'Lab doğrudan üretip teslime gönderir',
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),
),
@@ -1439,27 +1499,41 @@ class _ProvaToggle extends StatelessWidget {
Switch(
value: value,
onChanged: onChanged,
activeColor: AppColors.inProgress,
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(),
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(),
),
],
],