feat: improve patient flow and pricing workflow
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (₺)',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user