feat: improve patient flow and pricing workflow

This commit is contained in:
egecankomur
2026-06-12 00:04:53 +03:00
parent e12587398b
commit b42f68214e
26 changed files with 1283 additions and 243 deletions
+45 -1
View File
@@ -4,6 +4,7 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/l10n/app_strings.dart';
import '../../core/api/pocketbase_client.dart';
import '../../core/providers/auth_provider.dart';
import '../../core/providers/locale_provider.dart';
import '../../core/router/app_router.dart';
@@ -22,6 +23,13 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _obscure = true;
bool _rememberMe = true;
@override
void initState() {
super.initState();
_rememberMe = PocketBaseClient.instance.rememberSession;
}
@override
void dispose() {
@@ -34,7 +42,11 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
if (!_formKey.currentState!.validate()) return;
await ref
.read(authProvider.notifier)
.signIn(_emailCtrl.text.trim(), _passCtrl.text);
.signIn(
_emailCtrl.text.trim(),
_passCtrl.text,
rememberSession: _rememberMe,
);
}
@override
@@ -366,6 +378,38 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
(v == null || v.isEmpty) ? s.passwordRequired : null,
),
const SizedBox(height: 12),
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: auth.isLoading
? null
: () => setState(() => _rememberMe = !_rememberMe),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Checkbox(
value: _rememberMe,
onChanged: auth.isLoading
? null
: (value) => setState(() => _rememberMe = value ?? true),
activeColor: const Color(0xFF0D4C85),
),
const SizedBox(width: 6),
Text(
s.rememberMe,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
if (auth.error != null) ...[
const SizedBox(height: 14),
Container(
@@ -1,5 +1,6 @@
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../core/services/finance_service.dart';
import '../../../models/finance_entry.dart';
class ClinicFinanceRepository {
@@ -40,10 +41,42 @@ class ClinicFinanceRepository {
return {'pending': pending, 'paid': paid};
}
Future<List<CounterpartyFinanceSummary>> byCounterparty(String tenantId) async {
final entries = await listEntries(tenantId, limit: 300);
final map = <String, CounterpartyFinanceSummary>{};
for (final entry in entries) {
final key = entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown';
final current = map[key];
final pending = (current?.pendingAmount ?? 0) +
(entry.status == FinanceStatus.pending ? entry.amount : 0);
final paid = (current?.paidAmount ?? 0) +
(entry.status == FinanceStatus.paid ? entry.amount : 0);
map[key] = CounterpartyFinanceSummary(
counterpartyTenantId: entry.counterpartyTenantId,
counterpartyName: entry.counterpartyName ?? 'Karşı Taraf',
currency: entry.currency,
pendingAmount: pending,
paidAmount: paid,
entryCount: (current?.entryCount ?? 0) + 1,
);
}
final list = map.values.toList();
list.sort((a, b) => b.pendingAmount.compareTo(a.pendingAmount));
return list;
}
Future<void> markPaid(String entryId) async {
await _pb.collection('finance_entries').update(entryId, body: {
'status': 'paid',
'paid_at': DateTime.now().toIso8601String(),
});
final record = await _pb.collection('finance_entries').getOne(entryId);
final jobId = record.data['job_id']?.toString();
if (jobId == null || jobId.isEmpty) {
await _pb.collection('finance_entries').update(entryId, body: {
'status': 'paid',
'paid_at': DateTime.now().toIso8601String(),
});
return;
}
await FinanceService.instance.markJobPaid(jobId);
}
}
@@ -23,7 +23,7 @@ class ClinicFinanceScreen extends ConsumerStatefulWidget {
class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late Future<Map<String, double>> _summaryFuture;
late Future<_ClinicFinanceHeaderData> _headerFuture;
_FinanceSort _sort = _FinanceSort.newestFirst;
@override
@@ -45,8 +45,15 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
void _loadSummary() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_summaryFuture =
ClinicFinanceRepository.instance.summary(tenantId);
_headerFuture = Future.wait([
ClinicFinanceRepository.instance.summary(tenantId),
ClinicFinanceRepository.instance.byCounterparty(tenantId),
]).then(
(results) => _ClinicFinanceHeaderData(
summary: results[0] as Map<String, double>,
counterparties: results[1] as List<CounterpartyFinanceSummary>,
),
);
});
}
@@ -90,41 +97,58 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
),
body: Column(
children: [
FutureBuilder<Map<String, double>>(
future: _summaryFuture,
FutureBuilder<_ClinicFinanceHeaderData>(
future: _headerFuture,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const LinearProgressIndicator(
color: AppColors.accent);
}
final summary = snap.data ?? {'pending': 0.0, 'paid': 0.0};
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: _SummaryCard(
label: s.pendingReceivable,
amount: summary['pending'] ?? 0.0,
final data = snap.data ??
const _ClinicFinanceHeaderData(
summary: {'pending': 0.0, 'paid': 0.0},
counterparties: [],
);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: _SummaryCard(
label: s.pendingReceivable,
amount: data.summary['pending'] ?? 0.0,
currencyCode: currencyCode,
color: AppColors.pending,
bgColor: AppColors.pendingBg,
icon: Icons.hourglass_empty_rounded,
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
label: s.collected,
amount: data.summary['paid'] ?? 0.0,
currencyCode: currencyCode,
color: AppColors.success,
bgColor: AppColors.successBg,
icon: Icons.check_circle_outline,
),
),
],
),
),
if (data.counterparties.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: _CounterpartySummaryList(
title: 'Laboratuvar Bazlı Borç',
items: data.counterparties,
currencyCode: currencyCode,
color: AppColors.pending,
bgColor: AppColors.pendingBg,
icon: Icons.hourglass_empty_rounded,
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
label: s.collected,
amount: summary['paid'] ?? 0.0,
currencyCode: currencyCode,
color: AppColors.success,
bgColor: AppColors.successBg,
icon: Icons.check_circle_outline,
),
),
],
),
],
);
},
),
@@ -158,6 +182,16 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
}
}
class _ClinicFinanceHeaderData {
const _ClinicFinanceHeaderData({
required this.summary,
required this.counterparties,
});
final Map<String, double> summary;
final List<CounterpartyFinanceSummary> counterparties;
}
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.label,
@@ -532,3 +566,66 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
}
}
}
class _CounterpartySummaryList extends StatelessWidget {
const _CounterpartySummaryList({
required this.title,
required this.items,
required this.currencyCode,
});
final String title;
final List<CounterpartyFinanceSummary> items;
final String currencyCode;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 10),
for (final item in items.take(5)) ...[
Row(
children: [
Expanded(
child: Text(
item.counterpartyName,
style: const TextStyle(
fontSize: 13,
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
Text(
CurrencyFormatter.format(item.pendingAmount, currencyCode),
style: const TextStyle(
fontSize: 13,
color: AppColors.pending,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 8),
],
],
),
);
}
}
@@ -352,7 +352,9 @@ class _JobDetailBody extends StatelessWidget {
children: [
Expanded(
child: Text(
job.patientCode,
job.patientName?.isNotEmpty == true
? job.patientName!
: job.patientCode,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
@@ -368,6 +370,8 @@ class _JobDetailBody extends StatelessWidget {
// Patient + Lab
_SectionLabel(title: 'Hasta & Laboratuvar'),
if (job.patientName != null && job.patientName!.isNotEmpty)
_InfoRow(label: 'Hasta', value: job.patientName!),
_InfoRow(label: 'Protokol No', value: job.patientCode),
if (job.patientId != null)
_InfoRow(label: 'Hasta ID', value: job.patientId!),
@@ -378,6 +382,14 @@ class _JobDetailBody extends StatelessWidget {
// Prosthetic
_SectionLabel(title: 'Protez Bilgisi'),
_InfoRow(label: 'Tür', value: job.prostheticType.label),
if (job.prostheticName != null && job.prostheticName!.isNotEmpty)
_InfoRow(label: 'Ürün', value: job.prostheticName!),
if (job.workflowType != null)
_InfoRow(label: 'İş Tipi', value: job.workflowType!.label),
_InfoRow(
label: 'Prova',
value: job.provaRequired ? 'Provalı' : 'Provasız',
),
_InfoRow(label: 'Üye Sayısı', value: '${job.memberCount}'),
if (job.teeth.isNotEmpty)
_InfoRow(label: 'Dişler', value: job.teeth.join(', ')),
@@ -746,4 +758,3 @@ class _StatusBadge extends StatelessWidget {
}
}
}
@@ -1,10 +1,11 @@
import 'dart:async';
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../core/services/finance_service.dart';
import '../../../core/services/job_history_service.dart';
import '../../../models/job.dart';
const _listExpand = 'clinic_tenant_id,lab_tenant_id';
const _listExpand = 'clinic_tenant_id,lab_tenant_id,patient_id';
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id,prosthetic_id';
class ClinicJobsRepository {
@@ -51,14 +52,19 @@ class ClinicJobsRepository {
Future<Job> createJob({
required String clinicTenantId,
required String labTenantId,
required String clinicName,
required String labName,
required String patientCode,
required String prostheticId,
String? prostheticId,
required ProstheticType prostheticType,
required List<String> teeth,
String? patientId,
String? color,
String? description,
String? dueDate,
double? price,
String? currency,
JobWorkflowType? workflowType,
bool provaRequired = true,
}) async {
final record = await _pb.collection('jobs').create(body: {
@@ -66,18 +72,38 @@ class ClinicJobsRepository {
'lab_tenant_id': labTenantId,
'patient_code': patientCode,
if (patientId != null) 'patient_id': patientId,
'prosthetic_id': prostheticId,
if (prostheticId != null && prostheticId.isNotEmpty) 'prosthetic_id': prostheticId,
'prosthetic_type': prostheticType.value,
'member_count': teeth.length,
'teeth': teeth,
if (color != null) 'color': color,
if (description != null) 'description': description,
if (dueDate != null) 'due_date': dueDate,
if (price != null) 'price': price,
if (currency != null && currency.isNotEmpty) 'currency': currency,
if (workflowType != null) 'workflow_type': workflowType.value,
'status': 'pending',
'location': 'at_clinic',
'prova_required': provaRequired,
});
return Job.fromJson(record.toJson());
final job = Job.fromJson(record.toJson());
if (price != null && price > 0) {
try {
await FinanceService.instance.ensureEntriesForJob(
jobId: job.id,
clinicTenantId: clinicTenantId,
labTenantId: labTenantId,
clinicName: clinicName,
labName: labName,
amount: price,
currency: currency ?? 'TRY',
);
} catch (_) {
await _pb.collection('jobs').delete(job.id);
rethrow;
}
}
return job;
}
Future<Job> approveAtClinic(String jobId, Job job, {String? note}) async {
@@ -134,6 +160,7 @@ class ClinicJobsRepository {
final record = await _pb.collection('jobs').update(jobId, body: {
'status': 'cancelled',
});
await FinanceService.instance.deletePendingEntriesForJob(jobId);
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,
@@ -252,8 +252,10 @@ class _JobsTabState extends ConsumerState<_JobsTab> {
if (q.isNotEmpty) {
list = list.where((j) {
return j.patientCode.toLowerCase().contains(q) ||
(j.patientName?.toLowerCase().contains(q) ?? false) ||
(j.labName?.toLowerCase().contains(q) ?? false) ||
j.prostheticType.label.toLowerCase().contains(q);
j.prostheticType.label.toLowerCase().contains(q) ||
(j.prostheticName?.toLowerCase().contains(q) ?? false);
}).toList();
}
@@ -380,13 +382,16 @@ class _JobListCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final title = job.patientName?.trim().isNotEmpty == true
? job.patientName!
: job.patientCode;
final statusColor = _statusColor(job);
final statusBg = _statusBg(job);
final isOverdue =
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
return Semantics(
label: job.patientCode,
label: title,
button: true,
excludeSemantics: true,
child: Material(
@@ -426,7 +431,7 @@ class _JobListCard extends StatelessWidget {
children: [
Expanded(
child: Text(
job.patientCode,
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
@@ -437,9 +442,22 @@ class _JobListCard extends StatelessWidget {
],
),
const SizedBox(height: 3),
Text(job.prostheticType.label,
Text(
job.prostheticName?.isNotEmpty == true
? '${job.prostheticType.label} · ${job.prostheticName}'
: job.prostheticType.label,
style: const TextStyle(
fontSize: 12, color: AppColors.textSecondary)),
if (job.patientName?.isNotEmpty == true) ...[
const SizedBox(height: 2),
Text(
job.patientCode,
style: const TextStyle(
fontSize: 12, color: AppColors.textMuted),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (job.labName != null) ...[
const SizedBox(height: 2),
Text(
+375 -91
View File
@@ -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
@@ -742,14 +742,14 @@ class _DiscountSheetState extends State<_DiscountSheet> {
),
const SizedBox(height: 16),
const Text('Minimum Sipariş Adedi (İsteğe Bağlı)',
const Text('Minimum Faturalanabilir Adet (İsteğe Bağlı)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 4),
const Text(
'Aylık bu adede ulaşılınca indirim devreye girer. 0 = koşulsuz.',
'İş bazında diş/vaka adedi bu eşiğe ulaşınca indirim devreye girer. 0 = koşulsuz.',
style:
TextStyle(fontSize: 11, color: AppColors.textMuted)),
const SizedBox(height: 8),
@@ -39,4 +39,30 @@ class LabFinanceRepository {
}
return {'pending': pending, 'paid': paid};
}
Future<List<CounterpartyFinanceSummary>> byCounterparty(String tenantId) async {
final entries = await listEntries(tenantId, limit: 300);
final map = <String, CounterpartyFinanceSummary>{};
for (final entry in entries) {
final key = entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown';
final current = map[key];
final pending = (current?.pendingAmount ?? 0) +
(entry.status == FinanceStatus.pending ? entry.amount : 0);
final paid = (current?.paidAmount ?? 0) +
(entry.status == FinanceStatus.paid ? entry.amount : 0);
map[key] = CounterpartyFinanceSummary(
counterpartyTenantId: entry.counterpartyTenantId,
counterpartyName: entry.counterpartyName ?? 'Karşı Taraf',
currency: entry.currency,
pendingAmount: pending,
paidAmount: paid,
entryCount: (current?.entryCount ?? 0) + 1,
);
}
final list = map.values.toList();
list.sort((a, b) => b.pendingAmount.compareTo(a.pendingAmount));
return list;
}
}
@@ -48,10 +48,12 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
LabFinanceRepository.instance.listEntries(tenantId, status: 'pending'),
LabFinanceRepository.instance.listEntries(tenantId, status: 'paid'),
LabFinanceRepository.instance.summary(tenantId),
LabFinanceRepository.instance.byCounterparty(tenantId),
]).then((results) => _FinanceData(
pending: results[0] as List<FinanceEntry>,
paid: results[1] as List<FinanceEntry>,
summary: results[2] as Map<String, double>,
counterparties: results[3] as List<CounterpartyFinanceSummary>,
));
});
}
@@ -199,6 +201,15 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
],
),
),
if (data.counterparties.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: _CounterpartySummaryList(
title: 'Klinik Bazlı Alacak',
items: data.counterparties,
formatAmount: formatAmount,
),
),
PillTabs(
tabs: [s.pending, s.collected],
selected: _tabController.index,
@@ -240,11 +251,13 @@ class _FinanceData {
required this.pending,
required this.paid,
required this.summary,
required this.counterparties,
});
final List<FinanceEntry> pending;
final List<FinanceEntry> paid;
final Map<String, double> summary;
final List<CounterpartyFinanceSummary> counterparties;
}
class _SummaryCard extends StatelessWidget {
@@ -465,3 +478,66 @@ class _EntriesList extends StatelessWidget {
);
}
}
class _CounterpartySummaryList extends StatelessWidget {
const _CounterpartySummaryList({
required this.title,
required this.items,
required this.formatAmount,
});
final String title;
final List<CounterpartyFinanceSummary> items;
final String Function(double) formatAmount;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 10),
for (final item in items.take(5)) ...[
Row(
children: [
Expanded(
child: Text(
item.counterpartyName,
style: const TextStyle(
fontSize: 13,
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
Text(
formatAmount(item.pendingAmount),
style: const TextStyle(
fontSize: 13,
color: AppColors.pending,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 8),
],
],
),
);
}
}
+23 -4
View File
@@ -273,8 +273,10 @@ class _PendingJobsTabState extends ConsumerState<_PendingJobsTab> {
if (q.isEmpty) return jobs;
return jobs.where((j) =>
j.patientCode.toLowerCase().contains(q) ||
(j.patientName?.toLowerCase().contains(q) ?? false) ||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
j.prostheticType.label.toLowerCase().contains(q)
j.prostheticType.label.toLowerCase().contains(q) ||
(j.prostheticName?.toLowerCase().contains(q) ?? false)
).toList();
}
@@ -591,8 +593,10 @@ class _LabJobsTabState extends ConsumerState<_LabJobsTab> {
if (q.isNotEmpty) {
list = list.where((j) {
return j.patientCode.toLowerCase().contains(q) ||
(j.patientName?.toLowerCase().contains(q) ?? false) ||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
j.prostheticType.label.toLowerCase().contains(q) ||
(j.prostheticName?.toLowerCase().contains(q) ?? false) ||
(j.currentStep?.label.toLowerCase().contains(q) ?? false);
}).toList();
}
@@ -722,12 +726,15 @@ class _LabJobCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final title = job.patientName?.trim().isNotEmpty == true
? job.patientName!
: job.patientCode;
final isOverdue =
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
final accentColor = _statusColor(job.status);
return Semantics(
label: job.patientCode,
label: title,
button: true,
excludeSemantics: true,
child: Material(
@@ -771,7 +778,7 @@ class _LabJobCard extends StatelessWidget {
children: [
Expanded(
child: Text(
job.patientCode,
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
@@ -798,6 +805,16 @@ class _LabJobCard extends StatelessWidget {
),
],
),
if (job.patientName?.isNotEmpty == true) ...[
const SizedBox(height: 4),
Text(
job.patientCode,
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
const SizedBox(height: 5),
Row(
children: [
@@ -827,7 +844,9 @@ class _LabJobCard extends StatelessWidget {
borderRadius: BorderRadius.circular(6),
),
child: Text(
job.prostheticType.label,
job.prostheticName?.isNotEmpty == true
? '${job.prostheticType.label} · ${job.prostheticName}'
: job.prostheticType.label,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
@@ -258,7 +258,9 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
children: [
Expanded(
child: Text(
job.patientCode,
job.patientName?.isNotEmpty == true
? job.patientName!
: job.patientCode,
style: Theme.of(context)
.textTheme
.headlineSmall
@@ -289,10 +291,40 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
icon: Icons.business,
label: 'Klinik',
value: job.clinicName ?? '-'),
if (job.patientName != null &&
job.patientName!.isNotEmpty)
_InfoRow(
icon: Icons.person_outline,
label: 'Hasta',
value: job.patientName!,
),
_InfoRow(
icon: Icons.tag_outlined,
label: 'Protokol No',
value: job.patientCode,
),
_InfoRow(
icon: Icons.medical_services_outlined,
label: 'Protez Tipi',
value: job.prostheticType.label),
if (job.prostheticName != null &&
job.prostheticName!.isNotEmpty)
_InfoRow(
icon: Icons.category_outlined,
label: 'Ürün',
value: job.prostheticName!,
),
if (job.workflowType != null)
_InfoRow(
icon: Icons.tune_rounded,
label: 'İş Tipi',
value: job.workflowType!.label,
),
_InfoRow(
icon: Icons.fact_check_outlined,
label: 'Prova',
value: job.provaRequired ? 'Provalı' : 'Provasız',
),
_InfoRow(
icon: Icons.format_list_numbered,
label: 'Üye Sayısı',
@@ -761,4 +793,3 @@ class _JobStepper extends StatelessWidget {
);
}
}
@@ -188,8 +188,11 @@ class _InboundJobCardState extends State<_InboundJobCard> {
@override
Widget build(BuildContext context) {
final job = widget.job;
final title = job.patientName?.trim().isNotEmpty == true
? job.patientName!
: job.patientCode;
return Semantics(
label: job.patientCode,
label: title,
button: true,
excludeSemantics: true,
child: Dismissible(
@@ -246,9 +249,17 @@ class _InboundJobCardState extends State<_InboundJobCard> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.patientCode,
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (job.patientName?.isNotEmpty == true) ...[
const SizedBox(height: 2),
Text(
job.patientCode,
style: TextStyle(
color: AppColors.textMuted, fontSize: 12),
),
],
const SizedBox(height: 2),
Text(
job.clinicName ?? 'Klinik',
@@ -259,7 +270,9 @@ class _InboundJobCardState extends State<_InboundJobCard> {
Row(
children: [
_Chip(
label: job.prostheticType.label,
label: job.prostheticName?.isNotEmpty == true
? '${job.prostheticType.label} · ${job.prostheticName}'
: job.prostheticType.label,
color: AppColors.inProgressBg,
textColor: AppColors.inProgress,
),
@@ -1,11 +1,12 @@
import 'dart:async';
import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart';
import '../../../core/services/finance_service.dart';
import '../../../core/services/job_history_service.dart';
import '../../../models/job.dart';
const _listExpand = 'clinic_tenant_id,lab_tenant_id';
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id';
const _listExpand = 'clinic_tenant_id,lab_tenant_id,patient_id';
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id,prosthetic_id';
class LabJobsRepository {
LabJobsRepository._();
@@ -96,6 +97,7 @@ class LabJobsRepository {
final record = await _pb.collection('jobs').update(jobId, body: {
'status': 'cancelled',
});
await FinanceService.instance.deletePendingEntriesForJob(jobId);
unawaited(JobHistoryService.instance.append(
jobId: jobId,
clinicTenantId: job.clinicTenantId,