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 -3
View File
@@ -1,25 +1,67 @@
import 'dart:convert';
import 'package:pocketbase/pocketbase.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _kAuthKey = 'pb_auth';
const _kRememberSessionKey = 'remember_session';
class PocketBaseClient {
PocketBaseClient._({required this.pb});
PocketBaseClient._({
required this.pb,
required SharedPreferences prefs,
required bool rememberSession,
}) : _prefs = prefs,
_rememberSession = rememberSession;
static PocketBaseClient? _instance;
static PocketBaseClient get instance => _instance!;
final PocketBase pb;
final SharedPreferences _prefs;
bool _rememberSession;
bool get rememberSession => _rememberSession;
static Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
final remember = prefs.getBool(_kRememberSessionKey) ?? true;
final stored = prefs.getString(_kAuthKey);
final store = AsyncAuthStore(
save: (String data) => prefs.setString(_kAuthKey, data),
initial: stored,
save: (String data) async {
final client = _instance;
if (client == null || client._rememberSession) {
await prefs.setString(_kAuthKey, data);
return;
}
await prefs.remove(_kAuthKey);
},
initial: remember ? stored : null,
);
_instance = PocketBaseClient._(
pb: PocketBase('https://pocket.kovaksoft.com', authStore: store),
prefs: prefs,
rememberSession: remember,
);
if (!remember && stored != null) {
await prefs.remove(_kAuthKey);
}
}
Future<void> setRememberSession(bool value) async {
_rememberSession = value;
await _prefs.setBool(_kRememberSessionKey, value);
if (!value) {
await _prefs.remove(_kAuthKey);
} else if (pb.authStore.isValid) {
await _prefs.setString(
_kAuthKey,
jsonEncode({
'token': pb.authStore.token,
'model': pb.authStore.record,
}),
);
}
}
}
+12 -2
View File
@@ -8,8 +8,14 @@ class AuthRepository {
static final instance = AuthRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
PocketBaseClient get _client => PocketBaseClient.instance;
Future<AuthResult> login(String email, String password) async {
Future<AuthResult> login(
String email,
String password, {
required bool rememberSession,
}) async {
await _client.setRememberSession(rememberSession);
await _pb.collection('users').authWithPassword(email, password);
return _buildAuthResult();
}
@@ -43,7 +49,11 @@ class AuthRepository {
if (firstName != null && firstName.isNotEmpty) 'first_name': firstName,
if (lastName != null && lastName.isNotEmpty) 'last_name': lastName,
});
return login(email, password);
return login(
email,
password,
rememberSession: _client.rememberSession,
);
}
Future<AuthResult> refreshSession() async {
+14
View File
@@ -57,6 +57,7 @@ class AppStrings {
required this.tenantKindLab,
required this.signInWelcome,
required this.signInSubtitle,
required this.rememberMe,
required this.emailAddress,
required this.password,
required this.emailRequired,
@@ -95,6 +96,7 @@ class AppStrings {
required this.clinicCategory,
required this.jobsTitle,
required this.dashboardTitle,
required this.homeTitle,
required this.productsTitle,
required this.patientsTitle,
required this.close,
@@ -174,6 +176,7 @@ class AppStrings {
// ── Auth ──────────────────────────────────────────────────────────────────
final String signInWelcome;
final String signInSubtitle;
final String rememberMe;
final String emailAddress;
final String password;
final String emailRequired;
@@ -213,6 +216,7 @@ class AppStrings {
final String clinicCategory;
final String jobsTitle;
final String dashboardTitle;
final String homeTitle;
final String productsTitle;
final String patientsTitle;
@@ -303,6 +307,7 @@ class AppStrings {
tenantKindLab: 'Laboratuvar',
signInWelcome: 'Tekrar hoş geldiniz',
signInSubtitle: 'Hesabınıza giriş yapın',
rememberMe: 'Beni hatırla',
emailAddress: 'E-posta adresi',
password: 'Şifre',
emailRequired: 'E-posta gereklidir',
@@ -338,6 +343,7 @@ class AppStrings {
clinicCategory: 'KLİNİK',
jobsTitle: 'İşler',
dashboardTitle: 'Özet',
homeTitle: 'Ana Sayfa',
productsTitle: 'Ürünler',
patientsTitle: 'Hastalar',
currencyTRY: 'Türk Lirası (₺)',
@@ -410,6 +416,7 @@ class AppStrings {
tenantKindLab: 'Laboratory',
signInWelcome: 'Welcome back',
signInSubtitle: 'Sign in to your account',
rememberMe: 'Remember me',
emailAddress: 'Email address',
password: 'Password',
emailRequired: 'Email is required',
@@ -445,6 +452,7 @@ class AppStrings {
clinicCategory: 'CLINIC',
jobsTitle: 'Jobs',
dashboardTitle: 'Overview',
homeTitle: 'Home',
productsTitle: 'Products',
patientsTitle: 'Patients',
currencyTRY: 'Turkish Lira (₺)',
@@ -517,6 +525,7 @@ class AppStrings {
tenantKindLab: 'Лаборатория',
signInWelcome: 'Добро пожаловать',
signInSubtitle: 'Войдите в свой аккаунт',
rememberMe: 'Запомнить меня',
emailAddress: 'Адрес эл. почты',
password: 'Пароль',
emailRequired: 'Эл. почта обязательна',
@@ -552,6 +561,7 @@ class AppStrings {
clinicCategory: 'КЛИНИКА',
jobsTitle: 'Заказы',
dashboardTitle: 'Обзор',
homeTitle: 'Главная',
productsTitle: 'Продукты',
patientsTitle: 'Пациенты',
currencyTRY: 'Турецкая лира (₺)',
@@ -624,6 +634,7 @@ class AppStrings {
tenantKindLab: 'مختبر',
signInWelcome: 'مرحباً بعودتك',
signInSubtitle: 'سجّل دخولك إلى حسابك',
rememberMe: 'تذكرني',
emailAddress: 'البريد الإلكتروني',
password: 'كلمة المرور',
emailRequired: 'البريد الإلكتروني مطلوب',
@@ -659,6 +670,7 @@ class AppStrings {
clinicCategory: 'العيادة',
jobsTitle: 'الأعمال',
dashboardTitle: 'نظرة عامة',
homeTitle: 'الرئيسية',
productsTitle: 'المنتجات',
patientsTitle: 'المرضى',
currencyTRY: 'ليرة تركية (₺)',
@@ -731,6 +743,7 @@ class AppStrings {
tenantKindLab: 'Labor',
signInWelcome: 'Willkommen zurück',
signInSubtitle: 'Melden Sie sich in Ihrem Konto an',
rememberMe: 'Angemeldet bleiben',
emailAddress: 'E-Mail-Adresse',
password: 'Passwort',
emailRequired: 'E-Mail ist erforderlich',
@@ -766,6 +779,7 @@ class AppStrings {
clinicCategory: 'KLINIK',
jobsTitle: 'Aufträge',
dashboardTitle: 'Übersicht',
homeTitle: 'Startseite',
productsTitle: 'Produkte',
patientsTitle: 'Patienten',
currencyTRY: 'Türkische Lira (₺)',
+10 -2
View File
@@ -78,10 +78,18 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
}
Future<void> signIn(String email, String password) async {
Future<void> signIn(
String email,
String password, {
required bool rememberSession,
}) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final result = await _repo.login(email, password);
final result = await _repo.login(
email,
password,
rememberSession: rememberSession,
);
state = AuthState(
profile: result.user,
memberships: result.tenants,
+79 -83
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../l10n/app_strings.dart';
import '../providers/locale_provider.dart';
import '../theme/app_theme.dart';
import '../widgets/tooth_logo.dart';
import '../providers/auth_provider.dart';
@@ -213,63 +215,60 @@ class _ClinicShell extends ConsumerStatefulWidget {
class _ClinicShellState extends ConsumerState<_ClinicShell> {
String _selectedRoute = routeClinicDashboard;
// Top-level singles before groups
static final _topSingles = [
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: 'Hastalar', visible: (m) => m?.showPatients ?? true),
_NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeClinicAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: 'AI Sohbet', visible: (_) => true),
];
List<_NavItem> _clinicTopSingles(AppStrings s) => [
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true),
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: s.patientsTitle, visible: (m) => m?.showPatients ?? true),
_NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeClinicAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: s.aiAssistant, visible: (_) => true),
];
// Dropdown groups
static final _groups = [
_NavGroup(
title: 'Yönetim',
icon: Icons.tune_rounded,
selectedIcon: Icons.tune_rounded,
items: [
_NavItem(route: routeClinicConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: 'Bağlantılar', visible: (_) => true),
_NavItem(route: routeClinicReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: 'Raporlar', visible: (_) => true),
],
),
];
List<_NavGroup> _clinicGroups(AppStrings s) => [
_NavGroup(
title: s.management,
icon: Icons.tune_rounded,
selectedIcon: Icons.tune_rounded,
items: [
_NavItem(route: routeClinicConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: s.connections, visible: (_) => true),
_NavItem(route: routeClinicReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: s.reports, visible: (_) => true),
],
),
];
// Singles after groups
static final _bottomSingles = [
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
];
List<_NavItem> _clinicBottomSingles(AppStrings s) => [
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true),
];
// Mobile bottom nav: core items; others accessed from settings
static final _mobileItems = [
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: 'Hastalar', visible: (m) => m?.showPatients ?? true),
_NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
];
List<_NavItem> _clinicMobileItems(AppStrings s) => [
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true),
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: s.patientsTitle, visible: (m) => m?.showPatients ?? true),
_NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true),
];
List<_SidebarEntry> _allEntries() {
List<_SidebarEntry> _allEntries(AppStrings s) {
final membership = ref.read(authProvider).activeTenant;
final entries = <_SidebarEntry>[];
for (final s in _topSingles) {
if (s.visible(membership)) entries.add(_SidebarSingleEntry(s));
for (final item in _clinicTopSingles(s)) {
if (item.visible(membership)) entries.add(_SidebarSingleEntry(item));
}
for (final g in _groups) {
if (g.hasVisible(membership)) entries.add(_SidebarGroupEntry(g));
for (final group in _clinicGroups(s)) {
if (group.hasVisible(membership)) entries.add(_SidebarGroupEntry(group));
}
for (final s in _bottomSingles) {
if (s.visible(membership)) entries.add(_SidebarSingleEntry(s));
for (final item in _clinicBottomSingles(s)) {
if (item.visible(membership)) entries.add(_SidebarSingleEntry(item));
}
return entries;
}
@override
Widget build(BuildContext context) {
final s = ref.watch(stringsProvider);
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
final entries = _allEntries();
final entries = _allEntries(s);
return Scaffold(
backgroundColor: AppColors.background,
body: Row(
@@ -290,7 +289,7 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
// Mobile: only core items in bottom nav
final membership = ref.read(authProvider).activeTenant;
final items = _mobileItems.where((it) => it.visible(membership)).toList();
final items = _clinicMobileItems(s).where((it) => it.visible(membership)).toList();
final flatIndex = items.indexWhere((it) => it.route == _selectedRoute);
final clampedIndex = flatIndex >= 0 ? flatIndex : 0;
@@ -339,64 +338,61 @@ class _LabShell extends ConsumerStatefulWidget {
class _LabShellState extends ConsumerState<_LabShell> {
String _selectedRoute = routeLabDashboard;
// Top-level singles before groups
static final _topSingles = [
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: 'Ürünler', visible: (m) => m?.showProducts ?? true),
_NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeLabAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: 'AI Sohbet', visible: (_) => true),
];
List<_NavItem> _labTopSingles(AppStrings s) => [
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true),
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: s.productsTitle, visible: (m) => m?.showProducts ?? true),
_NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeLabAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: s.aiAssistant, visible: (_) => true),
];
// Dropdown groups
static final _groups = [
_NavGroup(
title: 'Yönetim',
icon: Icons.tune_rounded,
selectedIcon: Icons.tune_rounded,
items: [
_NavItem(route: routeLabConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: 'Bağlantılar', visible: (_) => true),
_NavItem(route: routeLabDiscounts, icon: const Icon(Icons.local_offer_outlined), selectedIcon: const Icon(Icons.local_offer_rounded), label: 'İndirimler', visible: (_) => true),
_NavItem(route: routeLabReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: 'Raporlar', visible: (_) => true),
],
),
];
List<_NavGroup> _labGroups(AppStrings s) => [
_NavGroup(
title: s.management,
icon: Icons.tune_rounded,
selectedIcon: Icons.tune_rounded,
items: [
_NavItem(route: routeLabConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: s.connections, visible: (_) => true),
_NavItem(route: routeLabDiscounts, icon: const Icon(Icons.local_offer_outlined), selectedIcon: const Icon(Icons.local_offer_rounded), label: s.discounts, visible: (_) => true),
_NavItem(route: routeLabReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: s.reports, visible: (_) => true),
],
),
];
// Singles after groups
static final _bottomSingles = [
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
];
List<_NavItem> _labBottomSingles(AppStrings s) => [
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true),
];
// Mobile bottom nav: core items; others accessed from settings
static final _mobileItems = [
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: 'Ürünler', visible: (m) => m?.showProducts ?? true),
_NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
];
List<_NavItem> _labMobileItems(AppStrings s) => [
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true),
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true),
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: s.productsTitle, visible: (m) => m?.showProducts ?? true),
_NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true),
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true),
];
List<_SidebarEntry> _allEntries() {
List<_SidebarEntry> _allEntries(AppStrings s) {
final membership = ref.read(authProvider).activeTenant;
final entries = <_SidebarEntry>[];
for (final s in _topSingles) {
if (s.visible(membership)) entries.add(_SidebarSingleEntry(s));
for (final item in _labTopSingles(s)) {
if (item.visible(membership)) entries.add(_SidebarSingleEntry(item));
}
for (final g in _groups) {
if (g.hasVisible(membership)) entries.add(_SidebarGroupEntry(g));
for (final group in _labGroups(s)) {
if (group.hasVisible(membership)) entries.add(_SidebarGroupEntry(group));
}
for (final s in _bottomSingles) {
if (s.visible(membership)) entries.add(_SidebarSingleEntry(s));
for (final item in _labBottomSingles(s)) {
if (item.visible(membership)) entries.add(_SidebarSingleEntry(item));
}
return entries;
}
@override
Widget build(BuildContext context) {
final s = ref.watch(stringsProvider);
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
final entries = _allEntries();
final entries = _allEntries(s);
return Scaffold(
backgroundColor: AppColors.background,
body: Row(
@@ -417,7 +413,7 @@ class _LabShellState extends ConsumerState<_LabShell> {
// Mobile: only core items in bottom nav
final membership = ref.read(authProvider).activeTenant;
final items = _mobileItems.where((it) => it.visible(membership)).toList();
final items = _labMobileItems(s).where((it) => it.visible(membership)).toList();
final flatIndex = items.indexWhere((it) => it.route == _selectedRoute);
final clampedIndex = flatIndex >= 0 ? flatIndex : 0;
+117
View File
@@ -0,0 +1,117 @@
import 'package:pocketbase/pocketbase.dart';
import '../api/pocketbase_client.dart';
class FinanceService {
FinanceService._();
static final instance = FinanceService._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<void> ensureEntriesForJob({
required String jobId,
required String clinicTenantId,
required String labTenantId,
required String clinicName,
required String labName,
required double amount,
required String currency,
}) async {
if (amount <= 0) return;
final existing = await _pb.collection('finance_entries').getFullList(
filter: 'job_id = "$jobId"',
batch: 200,
);
await _upsertEntry(
existing: existing,
jobId: jobId,
tenantId: clinicTenantId,
counterpartyTenantId: labTenantId,
counterpartyName: labName,
type: 'payable',
amount: amount,
currency: currency,
);
await _upsertEntry(
existing: existing,
jobId: jobId,
tenantId: labTenantId,
counterpartyTenantId: clinicTenantId,
counterpartyName: clinicName,
type: 'receivable',
amount: amount,
currency: currency,
);
}
Future<void> markJobPaid(String jobId) async {
final existing = await _pb.collection('finance_entries').getFullList(
filter: 'job_id = "$jobId"',
batch: 200,
);
final paidAt = DateTime.now().toIso8601String();
for (final record in existing) {
await _pb.collection('finance_entries').update(
record.id,
body: {
'status': 'paid',
'paid_at': paidAt,
},
);
}
}
Future<void> deletePendingEntriesForJob(String jobId) async {
final existing = await _pb.collection('finance_entries').getFullList(
filter: 'job_id = "$jobId" && status = "pending"',
batch: 200,
);
for (final record in existing) {
await _pb.collection('finance_entries').delete(record.id);
}
}
Future<void> _upsertEntry({
required List<RecordModel> existing,
required String jobId,
required String tenantId,
required String counterpartyTenantId,
required String counterpartyName,
required String type,
required double amount,
required String currency,
}) async {
RecordModel? match;
try {
match = existing.firstWhere(
(record) =>
record.data['tenant_id'] == tenantId &&
record.data['type'] == type,
);
} catch (_) {
match = null;
}
final body = {
'tenant_id': tenantId,
'job_id': jobId,
'type': type,
'amount': amount,
'currency': currency,
'status': 'pending',
'paid_at': null,
'counterparty_tenant_id': counterpartyTenantId,
'counterparty_name': counterpartyName,
};
if (match == null) {
await _pb.collection('finance_entries').create(body: body);
return;
}
await _pb.collection('finance_entries').update(match.id, body: body);
}
}
+87
View File
@@ -0,0 +1,87 @@
import '../../models/clinic_discount.dart';
import '../../models/job.dart';
import '../../models/prosthetic_product.dart';
class PricingBreakdown {
const PricingBreakdown({
required this.billableUnits,
required this.unitPrice,
required this.baseAmount,
required this.discountAmount,
required this.finalAmount,
required this.appliedDiscounts,
});
final int billableUnits;
final double unitPrice;
final double baseAmount;
final double discountAmount;
final double finalAmount;
final List<ClinicDiscount> appliedDiscounts;
}
class PricingService {
PricingService._();
static final instance = PricingService._();
int billableUnitsForType(ProstheticType type, int memberCount) {
final safeCount = memberCount <= 0 ? 1 : memberCount;
return switch (type) {
ProstheticType.tamProtez || ProstheticType.parsiyel => 1,
_ => safeCount,
};
}
String unitLabelForType(ProstheticType type) {
return switch (type) {
ProstheticType.tamProtez || ProstheticType.parsiyel => 'vaka',
_ => 'diş',
};
}
PricingBreakdown calculate({
required ProstheticProduct product,
required ProstheticType prostheticType,
required int memberCount,
required String clinicTenantId,
required List<ClinicDiscount> discounts,
}) {
final billableUnits = billableUnitsForType(prostheticType, memberCount);
final unitPrice = product.unitPrice ?? 0;
final baseAmount = unitPrice * billableUnits;
final applicable = discounts.where((discount) {
if (!discount.isActive) return false;
if (!(discount.appliesToAll || discount.clinicTenantId == clinicTenantId)) {
return false;
}
if (!(discount.appliesToAllTypes ||
discount.prostheticType == prostheticType.value)) {
return false;
}
if (discount.minQuantity > 0 && billableUnits < discount.minQuantity) {
return false;
}
return true;
}).toList();
double running = baseAmount;
for (final discount in applicable) {
running = discount.discountType == DiscountType.percentage
? running * (1 - discount.discountValue / 100)
: running - discount.discountValue;
}
final finalAmount = running.clamp(0, double.infinity).toDouble();
return PricingBreakdown(
billableUnits: billableUnits,
unitPrice: unitPrice,
baseAmount: baseAmount,
discountAmount: (baseAmount - finalAmount)
.clamp(0, double.infinity)
.toDouble(),
finalAmount: finalAmount,
appliedDiscounts: applicable,
);
}
}