Add pricing entry flow and platform admin foundations
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
@@ -9,6 +10,7 @@ import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/tooth_logo.dart';
|
||||
import '../../../core/services/realtime_service.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../../shared/location_completion_banner.dart';
|
||||
import '../jobs/lab_jobs_repository.dart';
|
||||
|
||||
class LabDashboardScreen extends ConsumerStatefulWidget {
|
||||
@@ -20,27 +22,47 @@ class LabDashboardScreen extends ConsumerStatefulWidget {
|
||||
class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
late Future<_DashboardData> _future;
|
||||
bool _acceptingAll = false;
|
||||
late UnsubFn _unsub;
|
||||
UnsubFn? _unsub;
|
||||
Timer? _reloadDebounce;
|
||||
String? _subscribedTenantId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
filter: "lab_tenant_id='$tenantId'",
|
||||
onEvent: (_) { if (mounted) _load(); },
|
||||
);
|
||||
_ensureRealtimeSubscription();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
_reloadDebounce?.cancel();
|
||||
_unsub?.call();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _ensureRealtimeSubscription() {
|
||||
final tenantId = ref.read(authProvider).activeTenant?.tenant.id;
|
||||
if (tenantId == null || tenantId == _subscribedTenantId) return;
|
||||
_unsub?.call();
|
||||
_subscribedTenantId = tenantId;
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
filter: "lab_tenant_id='$tenantId'",
|
||||
onEvent: (_) {
|
||||
_scheduleReload();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _scheduleReload() {
|
||||
_reloadDebounce?.cancel();
|
||||
_reloadDebounce = Timer(const Duration(milliseconds: 250), () {
|
||||
if (mounted) _load();
|
||||
});
|
||||
}
|
||||
|
||||
void _load() {
|
||||
_ensureRealtimeSubscription();
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final now = DateTime.now();
|
||||
final thisMonthStart = DateTime(now.year, now.month, 1);
|
||||
@@ -50,13 +72,19 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
Future.wait<List<Job>>([
|
||||
LabJobsRepository.instance.listInbound(tenantId, status: 'pending'),
|
||||
LabJobsRepository.instance.listInProgress(tenantId),
|
||||
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_lab'),
|
||||
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_clinic'),
|
||||
LabJobsRepository.instance.listInbound(tenantId, status: 'sent', limit: 200),
|
||||
LabJobsRepository.instance.listInbound(tenantId, status: 'delivered', limit: 200),
|
||||
LabJobsRepository.instance
|
||||
.listInProgress(tenantId, location: 'at_lab'),
|
||||
LabJobsRepository.instance
|
||||
.listInProgress(tenantId, location: 'at_clinic'),
|
||||
LabJobsRepository.instance
|
||||
.listInbound(tenantId, status: 'sent', limit: 200),
|
||||
LabJobsRepository.instance
|
||||
.listInbound(tenantId, status: 'delivered', limit: 200),
|
||||
]),
|
||||
LabJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart),
|
||||
LabJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart),
|
||||
LabJobsRepository.instance
|
||||
.countDelivered(tenantId, from: thisMonthStart),
|
||||
LabJobsRepository.instance
|
||||
.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart),
|
||||
]).then((r) {
|
||||
final jobs = r[0] as List<List<Job>>;
|
||||
return _DashboardData(
|
||||
@@ -82,7 +110,8 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e'), behavior: SnackBarBehavior.floating),
|
||||
SnackBar(
|
||||
content: Text('Hata: $e'), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -92,7 +121,10 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final companyName = ref.watch(authProvider).activeTenant?.tenant.companyName ?? '';
|
||||
_ensureRealtimeSubscription();
|
||||
final activeTenant = ref.watch(authProvider).activeTenant?.tenant;
|
||||
final companyName = activeTenant?.companyName ?? '';
|
||||
final showLocationWarning = activeTenant?.hasLocation != true;
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: LayoutBuilder(
|
||||
@@ -109,14 +141,29 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return _DashboardSkeleton(companyName: companyName, hPad: hPad);
|
||||
return _DashboardSkeleton(
|
||||
companyName: companyName, hPad: hPad);
|
||||
}
|
||||
if (snap.hasError) return _ErrorBody(onRetry: _load);
|
||||
final data = snap.data!;
|
||||
final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint;
|
||||
final isDesktop =
|
||||
MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint;
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
_DashboardHeader(companyName: companyName),
|
||||
if (showLocationWarning)
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: LocationCompletionBanner(
|
||||
title: 'Konum kaydı eksik',
|
||||
description:
|
||||
'Haritada görünmek ve kliniklerin sizi yakın laboratuvar olarak bulabilmesi için konumunuzu tamamlayın.',
|
||||
buttonLabel: 'Konumu Tamamla',
|
||||
onTap: () => context.go(routeLabSettings),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isDesktop)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
@@ -137,7 +184,10 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
count: data.pendingJobs.length,
|
||||
loading: _acceptingAll,
|
||||
onTap: _bulkAccept,
|
||||
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.1, end: 0),
|
||||
),
|
||||
),
|
||||
if (isDesktop) ...[
|
||||
@@ -145,14 +195,18 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _MonthlyReportSection(data: data)
|
||||
.animate().fadeIn(duration: 300.ms).slideY(begin: 0.08, end: 0),
|
||||
.animate()
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.08, end: 0),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _GamificationRow(data: data)
|
||||
.animate().fadeIn(duration: 300.ms, delay: 60.ms).slideY(begin: 0.08, end: 0),
|
||||
.animate()
|
||||
.fadeIn(duration: 300.ms, delay: 60.ms)
|
||||
.slideY(begin: 0.08, end: 0),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -163,10 +217,14 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium),
|
||||
Text('Yapılacaklar',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
TextButton(
|
||||
onPressed: () => context.go(routeLabJobsAll),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.accent, padding: const EdgeInsets.symmetric(horizontal: 8)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.accent,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8)),
|
||||
child: const Text('Tümünü Gör'),
|
||||
),
|
||||
],
|
||||
@@ -185,11 +243,13 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: data.atLabJobs.take(5).length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (ctx, i) => _JobCard(job: data.atLabJobs[i])
|
||||
.animate(delay: (i * 60).ms)
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.12, end: 0),
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 10),
|
||||
itemBuilder: (ctx, i) =>
|
||||
_JobCard(job: data.atLabJobs[i])
|
||||
.animate(delay: (i * 60).ms)
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.12, end: 0),
|
||||
),
|
||||
),
|
||||
// ── Klinikte Onay Bekliyor (at_clinic) ───────────────
|
||||
@@ -197,18 +257,21 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text('Klinikte Onay Bekliyor', style: Theme.of(context).textTheme.titleMedium),
|
||||
child: Text('Klinikte Onay Bekliyor',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: data.atClinicJobs.take(5).length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (ctx, i) => _JobCard(job: data.atClinicJobs[i])
|
||||
.animate(delay: (i * 60).ms)
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.12, end: 0),
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 10),
|
||||
itemBuilder: (ctx, i) =>
|
||||
_JobCard(job: data.atClinicJobs[i])
|
||||
.animate(delay: (i * 60).ms)
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.12, end: 0),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -233,7 +296,8 @@ class _DashboardHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
final isDesktop =
|
||||
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
|
||||
if (isDesktop) {
|
||||
return SliverAppBar(
|
||||
@@ -252,15 +316,24 @@ class _DashboardHeader extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)),
|
||||
const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
Text('Genel Bakış',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.8),
|
||||
letterSpacing: 0.3)),
|
||||
const Text('Bugünkü Durum',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go(routeLabSettings),
|
||||
icon: const Icon(Icons.settings_outlined, color: AppColors.textSecondary, size: 22),
|
||||
icon: const Icon(Icons.settings_outlined,
|
||||
color: AppColors.textSecondary, size: 22),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
@@ -291,14 +364,26 @@ class _DashboardHeader extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)),
|
||||
Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
Text('DLS',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.65),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1.5)),
|
||||
Text(companyName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go(routeLabSettings),
|
||||
icon: const Icon(Icons.settings_outlined, color: Colors.white, size: 22),
|
||||
icon: const Icon(Icons.settings_outlined,
|
||||
color: Colors.white, size: 22),
|
||||
),
|
||||
],
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
@@ -317,9 +402,19 @@ class _DashboardHeader extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text('Genel Bakış', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.5)),
|
||||
Text('Genel Bakış',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.65),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.5)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('Bugünkü Durum', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5)),
|
||||
const Text('Bugünkü Durum',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -343,18 +438,47 @@ class _StatsRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint;
|
||||
final isWideDesktop =
|
||||
MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint;
|
||||
|
||||
final pendingCard = _StatCard(label: 'Bekleyen', value: '$pending', icon: Icons.hourglass_top_rounded, color: AppColors.pending, bgColor: AppColors.pendingBg)
|
||||
.animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
final inProgressCard = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg)
|
||||
.animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
final pendingCard = _StatCard(
|
||||
label: 'Bekleyen',
|
||||
value: '$pending',
|
||||
icon: Icons.hourglass_top_rounded,
|
||||
color: AppColors.pending,
|
||||
bgColor: AppColors.pendingBg)
|
||||
.animate()
|
||||
.fadeIn(duration: 350.ms)
|
||||
.slideY(begin: 0.2, end: 0);
|
||||
final inProgressCard = _StatCard(
|
||||
label: 'Devam Eden',
|
||||
value: '$inProgress',
|
||||
icon: Icons.autorenew_rounded,
|
||||
color: AppColors.inProgress,
|
||||
bgColor: AppColors.inProgressBg)
|
||||
.animate(delay: 80.ms)
|
||||
.fadeIn(duration: 350.ms)
|
||||
.slideY(begin: 0.2, end: 0);
|
||||
|
||||
if (isWideDesktop) {
|
||||
final sentCard = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg)
|
||||
.animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
final deliveredCard = _StatCard(label: 'Tamamlanan', value: '$delivered', icon: Icons.task_alt, color: AppColors.success, bgColor: AppColors.successBg)
|
||||
.animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
final sentCard = _StatCard(
|
||||
label: 'Klinik\'te',
|
||||
value: '$sent',
|
||||
icon: Icons.local_hospital_outlined,
|
||||
color: AppColors.accent,
|
||||
bgColor: AppColors.inProgressBg)
|
||||
.animate(delay: 120.ms)
|
||||
.fadeIn(duration: 350.ms)
|
||||
.slideY(begin: 0.2, end: 0);
|
||||
final deliveredCard = _StatCard(
|
||||
label: 'Tamamlanan',
|
||||
value: '$delivered',
|
||||
icon: Icons.task_alt,
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successBg)
|
||||
.animate(delay: 160.ms)
|
||||
.fadeIn(duration: 350.ms)
|
||||
.slideY(begin: 0.2, end: 0);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
@@ -380,7 +504,12 @@ class _StatsRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
const _StatCard({required this.label, required this.value, required this.icon, required this.color, required this.bgColor});
|
||||
const _StatCard(
|
||||
{required this.label,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.bgColor});
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
@@ -394,22 +523,38 @@ class _StatCard extends StatelessWidget {
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4))],
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(12)),
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor, borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, color: color, height: 1)),
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: color,
|
||||
height: 1)),
|
||||
const SizedBox(height: 3),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -419,7 +564,8 @@ class _StatCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _AcceptAllBanner extends StatelessWidget {
|
||||
const _AcceptAllBanner({required this.count, required this.loading, required this.onTap});
|
||||
const _AcceptAllBanner(
|
||||
{required this.count, required this.loading, required this.onTap});
|
||||
final int count;
|
||||
final bool loading;
|
||||
final VoidCallback onTap;
|
||||
@@ -433,30 +579,54 @@ class _AcceptAllBanner extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.pending.withValues(alpha: 0.35))),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border:
|
||||
Border.all(color: AppColors.pending.withValues(alpha: 0.35))),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38, height: 38,
|
||||
decoration: BoxDecoration(color: AppColors.pending.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10)),
|
||||
child: const Icon(Icons.notifications_active_outlined, color: AppColors.pending, size: 18),
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pending.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
child: const Icon(Icons.notifications_active_outlined,
|
||||
color: AppColors.pending, size: 18),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$count yeni iş bekliyor', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const Text('Tümünü hızlıca kabul et', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
Text('$count yeni iş bekliyor',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
const Text('Tümünü hızlıca kabul et',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.pending))
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: AppColors.pending))
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(8)),
|
||||
child: const Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pending,
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: const Text('Kabul Et',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -473,62 +643,97 @@ class _JobCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final due = job.dueDate;
|
||||
final isOverdue = due != null && due.isBefore(DateTime.now());
|
||||
final dueText = due != null ? '${due.day.toString().padLeft(2, '0')}.${due.month.toString().padLeft(2, '0')}.${due.year}' : null;
|
||||
final dueText = due != null
|
||||
? '${due.day.toString().padLeft(2, '0')}.${due.month.toString().padLeft(2, '0')}.${due.year}'
|
||||
: null;
|
||||
return Semantics(
|
||||
label: job.patientCode,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/lab/jobs/${job.id}'),
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.border)),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46, height: 46,
|
||||
decoration: BoxDecoration(color: AppColors.inProgressBg, borderRadius: BorderRadius.circular(12)),
|
||||
child: Center(child: Text('${job.memberCount}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.inProgress))),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(job.patientCode, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(job.clinicName ?? 'Klinik', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
_Tag(label: job.prostheticType.label, color: AppColors.inProgress, bg: AppColors.inProgressBg),
|
||||
if (job.currentStep != null) _Tag(label: job.currentStep!.label, color: AppColors.success, bg: AppColors.successBg),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/lab/jobs/${job.id}'),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border)),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Center(
|
||||
child: Text('${job.memberCount}',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.inProgress))),
|
||||
),
|
||||
),
|
||||
if (dueText != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Icon(Icons.calendar_today_outlined, size: 13, color: isOverdue ? AppColors.cancelled : AppColors.textMuted),
|
||||
const SizedBox(height: 3),
|
||||
Text(dueText, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: isOverdue ? AppColors.cancelled : AppColors.textSecondary)),
|
||||
],
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(job.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(job.clinicName ?? 'Klinik',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
_Tag(
|
||||
label: job.prostheticType.label,
|
||||
color: AppColors.inProgress,
|
||||
bg: AppColors.inProgressBg),
|
||||
if (job.currentStep != null)
|
||||
_Tag(
|
||||
label: job.currentStep!.label,
|
||||
color: AppColors.success,
|
||||
bg: AppColors.successBg),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (dueText != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 13,
|
||||
color: isOverdue
|
||||
? AppColors.cancelled
|
||||
: AppColors.textMuted),
|
||||
const SizedBox(height: 3),
|
||||
Text(dueText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isOverdue
|
||||
? AppColors.cancelled
|
||||
: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -542,13 +747,15 @@ class _Tag extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
|
||||
child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
|
||||
decoration:
|
||||
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 11, fontWeight: FontWeight.w600, color: color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _EmptySection extends StatelessWidget {
|
||||
const _EmptySection({required this.message});
|
||||
final String message;
|
||||
@@ -563,9 +770,12 @@ class _EmptySection extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline_rounded, color: AppColors.textSecondary.withValues(alpha: 0.5), size: 20),
|
||||
Icon(Icons.check_circle_outline_rounded,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5), size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Text(message, style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||
Text(message,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -584,14 +794,25 @@ class _ErrorBody extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64, height: 64,
|
||||
decoration: BoxDecoration(color: AppColors.cancelledBg, borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 30),
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Bağlantı hatası', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const Text('Bağlantı hatası',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(onPressed: onRetry, icon: const Icon(Icons.refresh_rounded, size: 18), label: const Text('Tekrar Dene')),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Tekrar Dene')),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -623,7 +844,9 @@ class _DashboardSkeleton extends StatelessWidget {
|
||||
padding: EdgeInsets.fromLTRB(hPad, 8, hPad, 0),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: 4,
|
||||
itemBuilder: (_, i) => const Padding(padding: EdgeInsets.only(bottom: 10), child: _ShimmerBox(height: 92, radius: 14)),
|
||||
itemBuilder: (_, i) => const Padding(
|
||||
padding: EdgeInsets.only(bottom: 10),
|
||||
child: _ShimmerBox(height: 92, radius: 14)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -639,24 +862,35 @@ class _ShimmerBox extends StatefulWidget {
|
||||
State<_ShimmerBox> createState() => _ShimmerBoxState();
|
||||
}
|
||||
|
||||
class _ShimmerBoxState extends State<_ShimmerBox> with SingleTickerProviderStateMixin {
|
||||
class _ShimmerBoxState extends State<_ShimmerBox>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _ctrl;
|
||||
late Animation<double> _anim;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100))..repeat(reverse: true);
|
||||
_ctrl = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 1100))
|
||||
..repeat(reverse: true);
|
||||
_anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() { _ctrl.dispose(); super.dispose(); }
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (_, __) => Container(
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(widget.radius), color: Color.lerp(const Color(0xFFE2E8F0), const Color(0xFFF1F5F9), _anim.value)),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(widget.radius),
|
||||
color: Color.lerp(
|
||||
const Color(0xFFE2E8F0), const Color(0xFFF1F5F9), _anim.value)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -685,9 +919,14 @@ class _MonthlyReportSection extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.bar_chart_rounded, size: 18, color: AppColors.accent),
|
||||
const Icon(Icons.bar_chart_rounded,
|
||||
size: 18, color: AppColors.accent),
|
||||
const SizedBox(width: 6),
|
||||
Text('Aylık Rapor', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text('Aylık Rapor',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -710,7 +949,8 @@ class _MonthlyReportSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isUp ? AppColors.successBg : AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -719,7 +959,9 @@ class _MonthlyReportSection extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
|
||||
isUp
|
||||
? Icons.trending_up_rounded
|
||||
: Icons.trending_down_rounded,
|
||||
size: 16,
|
||||
color: isUp ? AppColors.success : AppColors.cancelled,
|
||||
),
|
||||
@@ -744,7 +986,8 @@ class _MonthlyReportSection extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _MonthStat extends StatelessWidget {
|
||||
const _MonthStat({required this.label, required this.value, required this.highlighted});
|
||||
const _MonthStat(
|
||||
{required this.label, required this.value, required this.highlighted});
|
||||
final String label;
|
||||
final int value;
|
||||
final bool highlighted;
|
||||
@@ -754,14 +997,22 @@ class _MonthStat extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: highlighted ? AppColors.accent.withValues(alpha: 0.06) : AppColors.background,
|
||||
color: highlighted
|
||||
? AppColors.accent.withValues(alpha: 0.06)
|
||||
: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: highlighted ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) : null,
|
||||
border: highlighted
|
||||
? Border.all(color: AppColors.accent.withValues(alpha: 0.2))
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$value iş',
|
||||
@@ -788,7 +1039,8 @@ class _GamificationRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0);
|
||||
final remaining = (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal);
|
||||
final remaining =
|
||||
(_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -803,7 +1055,11 @@ class _GamificationRow extends StatelessWidget {
|
||||
children: [
|
||||
const Text('🏆', style: TextStyle(fontSize: 16)),
|
||||
const SizedBox(width: 6),
|
||||
Text('Aylık Hedef', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text('Aylık Hedef',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
@@ -813,7 +1069,10 @@ class _GamificationRow extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
'${data.points} puan',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -836,14 +1095,17 @@ class _GamificationRow extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
'${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary),
|
||||
),
|
||||
Text(
|
||||
progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: progress >= 1.0 ? AppColors.success : AppColors.textSecondary,
|
||||
color: progress >= 1.0
|
||||
? AppColors.success
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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 LabFinanceRepository {
|
||||
@@ -15,14 +16,18 @@ class LabFinanceRepository {
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['tenant_id = "$tenantId"', 'type = "receivable"'];
|
||||
if (status != null) filterParts.add('status = "$status"');
|
||||
if (status == FinanceStatus.pending.value) {
|
||||
filterParts.add('(status = "pending" || status = "reported")');
|
||||
} else if (status != null) {
|
||||
filterParts.add('status = "$status"');
|
||||
}
|
||||
|
||||
final result = await _pb.collection('finance_entries').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: 'job_id',
|
||||
);
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: 'job_id',
|
||||
);
|
||||
return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
@@ -31,7 +36,7 @@ class LabFinanceRepository {
|
||||
final all = await listEntries(tenantId, limit: 200);
|
||||
double pending = 0, paid = 0;
|
||||
for (final e in all) {
|
||||
if (e.status == FinanceStatus.pending) {
|
||||
if (e.status.isOpen) {
|
||||
pending += e.amount;
|
||||
} else {
|
||||
paid += e.amount;
|
||||
@@ -40,15 +45,17 @@ class LabFinanceRepository {
|
||||
return {'pending': pending, 'paid': paid};
|
||||
}
|
||||
|
||||
Future<List<CounterpartyFinanceSummary>> byCounterparty(String tenantId) async {
|
||||
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 key =
|
||||
entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown';
|
||||
final current = map[key];
|
||||
final pending = (current?.pendingAmount ?? 0) +
|
||||
(entry.status == FinanceStatus.pending ? entry.amount : 0);
|
||||
(entry.status.isOpen ? entry.amount : 0);
|
||||
final paid = (current?.paidAmount ?? 0) +
|
||||
(entry.status == FinanceStatus.paid ? entry.amount : 0);
|
||||
map[key] = CounterpartyFinanceSummary(
|
||||
@@ -65,4 +72,17 @@ class LabFinanceRepository {
|
||||
list.sort((a, b) => b.pendingAmount.compareTo(a.pendingAmount));
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> confirmPayment(String entryId) async {
|
||||
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.confirmJobPayment(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,8 +76,10 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
switch (_sort) {
|
||||
case _FinanceSort.newestFirst:
|
||||
list.sort((a, b) {
|
||||
final da = a.dateCreated != null ? DateTime.tryParse(a.dateCreated!) : null;
|
||||
final db = b.dateCreated != null ? DateTime.tryParse(b.dateCreated!) : null;
|
||||
final da =
|
||||
a.dateCreated != null ? DateTime.tryParse(a.dateCreated!) : null;
|
||||
final db =
|
||||
b.dateCreated != null ? DateTime.tryParse(b.dateCreated!) : null;
|
||||
if (da == null && db == null) return 0;
|
||||
if (da == null) return 1;
|
||||
if (db == null) return -1;
|
||||
@@ -101,6 +103,49 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmPayment(
|
||||
FinanceEntry entry,
|
||||
String Function(double) formatAmount,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Ödeme Onayla'),
|
||||
content: Text(
|
||||
'${entry.counterpartyName ?? "Bu kayıt"} için '
|
||||
'${formatAmount(entry.amount)} tutarındaki ödemenin '
|
||||
'hesabınıza ulaştığını onaylıyor musunuz?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Onayla'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
try {
|
||||
await LabFinanceRepository.instance.confirmPayment(entry.id);
|
||||
_load();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ödeme onaylandı.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _FinanceSort.newestFirst;
|
||||
@@ -154,8 +199,7 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary)),
|
||||
style: const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
@@ -181,7 +225,7 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.pendingReceivable,
|
||||
label: 'Açık Alacak',
|
||||
amount: formatAmount(pendingTotal),
|
||||
color: AppColors.pending,
|
||||
bgColor: AppColors.pendingBg,
|
||||
@@ -191,7 +235,7 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.collected,
|
||||
label: 'Onaylanan Tahsilat',
|
||||
amount: formatAmount(paidTotal),
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successBg,
|
||||
@@ -226,6 +270,8 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
emptyIcon: Icons.hourglass_empty_rounded,
|
||||
formatDate: _formatDate,
|
||||
formatAmount: formatAmount,
|
||||
onConfirmPayment: (entry) =>
|
||||
_confirmPayment(entry, formatAmount),
|
||||
),
|
||||
_EntriesList(
|
||||
entries: paid,
|
||||
@@ -334,6 +380,7 @@ class _EntriesList extends StatelessWidget {
|
||||
required this.emptyIcon,
|
||||
required this.formatDate,
|
||||
required this.formatAmount,
|
||||
this.onConfirmPayment,
|
||||
});
|
||||
|
||||
final List<FinanceEntry> entries;
|
||||
@@ -341,6 +388,7 @@ class _EntriesList extends StatelessWidget {
|
||||
final IconData emptyIcon;
|
||||
final String Function(String?) formatDate;
|
||||
final String Function(double) formatAmount;
|
||||
final Future<void> Function(FinanceEntry entry)? onConfirmPayment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -374,103 +422,139 @@ class _EntriesList extends StatelessWidget {
|
||||
itemBuilder: (ctx, i) {
|
||||
final entry = entries[i];
|
||||
final isPending = entry.status == FinanceStatus.pending;
|
||||
final statusColor = isPending ? AppColors.pending : AppColors.success;
|
||||
final statusBg = isPending ? AppColors.pendingBg : AppColors.successBg;
|
||||
final isReported = entry.status == FinanceStatus.reported;
|
||||
final statusColor = isPending
|
||||
? AppColors.pending
|
||||
: isReported
|
||||
? AppColors.accent
|
||||
: AppColors.success;
|
||||
final statusBg = isPending
|
||||
? AppColors.pendingBg
|
||||
: isReported
|
||||
? AppColors.inProgressBg
|
||||
: AppColors.successBg;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: isReported && onConfirmPayment != null
|
||||
? () => onConfirmPayment!(entry)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(
|
||||
isPending
|
||||
? Icons.hourglass_empty_rounded
|
||||
: Icons.check_circle_outline,
|
||||
color: statusColor,
|
||||
size: 22,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.counterpartyName ?? 'Klinik',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
if (entry.patientCode != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Protokol: ${entry.patientCode}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
if (entry.dateCreated != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
formatDate(entry.dateCreated),
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
formatAmount(entry.amount),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(
|
||||
isPending
|
||||
? Icons.hourglass_empty_rounded
|
||||
: isReported
|
||||
? Icons.verified_outlined
|
||||
: Icons.check_circle_outline,
|
||||
color: statusColor,
|
||||
fontSize: 15,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.counterpartyName ?? 'Klinik',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
if (entry.patientCode != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Protokol: ${entry.patientCode}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
if (entry.dateCreated != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
formatDate(entry.dateCreated),
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
if (isReported) ...[
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Dokunarak ödeme onayı verebilirsiniz.',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary),
|
||||
),
|
||||
] else if (isPending) ...[
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Klinikten ödeme bildirimi bekleniyor.',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
entry.status.label,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
formatAmount(entry.amount),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: statusColor,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
entry.status.label,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -14,13 +14,13 @@ import 'lab_jobs_repository.dart';
|
||||
// ── Adaptive sheet helper ────────────────────────────────────────────────────
|
||||
|
||||
void _showAdaptive(BuildContext context, Widget content) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
final isDesktop =
|
||||
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
if (isDesktop) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: content,
|
||||
@@ -51,33 +51,66 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
||||
String? _loadError;
|
||||
bool _isActing = false;
|
||||
late Future<List<JobFile>> _filesFuture;
|
||||
late UnsubFn _unsub;
|
||||
late Future<List<JobHistoryEntry>> _historyFuture;
|
||||
final List<UnsubFn> _unsubs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_loadFiles();
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
_loadHistory();
|
||||
_unsubs.add(RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
topic: widget.jobId,
|
||||
onEvent: (_) { if (mounted && !_isActing) _load(); },
|
||||
);
|
||||
onEvent: (_) {
|
||||
if (mounted && !_isActing) _load();
|
||||
},
|
||||
));
|
||||
_unsubs.add(RealtimeService.instance.watch(
|
||||
'job_files',
|
||||
filter: 'job_id="${widget.jobId}"',
|
||||
onEvent: (_) {
|
||||
if (mounted) _loadFiles();
|
||||
},
|
||||
));
|
||||
_unsubs.add(RealtimeService.instance.watch(
|
||||
'job_status_history',
|
||||
filter: 'job_id="${widget.jobId}"',
|
||||
onEvent: (_) {
|
||||
if (mounted) _loadHistory();
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
for (final unsub in _unsubs) {
|
||||
unsub();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() { _loadingJob = true; _loadError = null; });
|
||||
setState(() {
|
||||
_loadingJob = true;
|
||||
_loadError = null;
|
||||
});
|
||||
try {
|
||||
final job = await LabJobsRepository.instance.getJob(widget.jobId);
|
||||
if (mounted) setState(() { _job = job; _loadingJob = false; });
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_job = job;
|
||||
_loadingJob = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loadError = e.toString(); _loadingJob = false; });
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadError = e.toString();
|
||||
_loadingJob = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,14 +120,23 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _loadHistory() {
|
||||
setState(() {
|
||||
_historyFuture = JobHistoryService.instance.listForJob(widget.jobId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _cancelJob(Job job) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('İşi İptal Et'),
|
||||
content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
|
||||
content: const Text(
|
||||
'Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Vazgeç')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
@@ -108,13 +150,18 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
||||
try {
|
||||
final updated = await LabJobsRepository.instance.cancelJob(job.id, job);
|
||||
if (mounted) {
|
||||
setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
|
||||
setState(() {
|
||||
_job = _job!.copyWith(status: updated.status);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,7 +171,11 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
||||
try {
|
||||
final updated = await LabJobsRepository.instance.acceptJob(job);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
setState(() {
|
||||
_job = updated.copyWith(
|
||||
clinicName: job.clinicName, labName: job.labName);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş kabul edildi')),
|
||||
);
|
||||
@@ -132,7 +183,8 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +195,10 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
||||
_HandToClinicSheet(
|
||||
job: job,
|
||||
onDone: (Job updated) {
|
||||
if (mounted) setState(() => _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName));
|
||||
if (mounted) {
|
||||
setState(() => _job = updated.copyWith(
|
||||
clinicName: job.clinicName, labName: job.labName));
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -170,7 +225,8 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt, {bool withTime = false}) {
|
||||
final d = '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
|
||||
final d =
|
||||
'${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
|
||||
if (!withTime || (dt.hour == 0 && dt.minute == 0)) return d;
|
||||
return '$d ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
@@ -234,260 +290,261 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
||||
job.location == JobLocation.atLab;
|
||||
final canAccept = !isDeliveryOnly && job.status == JobStatus.pending;
|
||||
|
||||
return ListView(
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Header card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Header card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
job.patientName?.isNotEmpty == true
|
||||
? job.patientName!
|
||||
: job.patientCode,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: _statusBg(job.status),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
job.status.label,
|
||||
style: TextStyle(
|
||||
color: _statusColor(job.status),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_InfoRow(
|
||||
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ı',
|
||||
value: '${job.memberCount} üye'),
|
||||
if (job.color != null)
|
||||
_InfoRow(
|
||||
icon: Icons.color_lens_outlined,
|
||||
label: 'Renk',
|
||||
value: job.color!),
|
||||
if (job.dueDate != null)
|
||||
_InfoRow(
|
||||
icon: Icons.calendar_today,
|
||||
label: 'Teslim Tarihi',
|
||||
value: _formatDate(job.dueDate!, withTime: true),
|
||||
valueColor: job.dueDate!.isBefore(DateTime.now())
|
||||
? AppColors.cancelled
|
||||
: null),
|
||||
_InfoRow(
|
||||
icon: Icons.add_circle_outline,
|
||||
label: 'Oluşturulma',
|
||||
value: _formatDate(job.dateCreated)),
|
||||
if (job.price != null && job.currency != null)
|
||||
_InfoRow(
|
||||
icon: Icons.attach_money,
|
||||
label: 'Fiyat',
|
||||
value:
|
||||
'${job.price!.toStringAsFixed(2)} ${job.currency}'),
|
||||
if (job.description != null &&
|
||||
job.description!.isNotEmpty)
|
||||
_InfoRow(
|
||||
icon: Icons.notes,
|
||||
label: 'Açıklama',
|
||||
value: job.description!),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Stepper
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'İş Adımları',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: job.provaRequired
|
||||
? AppColors.inProgressBg
|
||||
: AppColors.successBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
job.provaRequired ? 'Provalı' : 'Provasız',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: job.provaRequired
|
||||
? AppColors.inProgress
|
||||
: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_JobStepper(
|
||||
steps: job.stepTemplate,
|
||||
currentStep: job.currentStep,
|
||||
historyFuture: JobHistoryService.instance
|
||||
.listForJob(job.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action buttons
|
||||
if (_isActing)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Center(child: CircularProgressIndicator(color: AppColors.accent)),
|
||||
)
|
||||
else ...[
|
||||
if (canAccept)
|
||||
FilledButton.icon(
|
||||
onPressed: () => _acceptJob(job),
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text('Kabul Et'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
),
|
||||
|
||||
if (canSendToClinic)
|
||||
FilledButton.icon(
|
||||
onPressed: () => _showHandToClinicSheet(job),
|
||||
icon: const Icon(Icons.send_outlined),
|
||||
label: Text(
|
||||
(job.isLastStep)
|
||||
? 'Son Prova - Teslime Gönder'
|
||||
: 'Prova için Kliniğe Gönder',
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
backgroundColor: (job.isLastStep)
|
||||
? AppColors.success
|
||||
: AppColors.inProgress,
|
||||
),
|
||||
),
|
||||
|
||||
if (canCancelJobs && job.status == JobStatus.pending) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _cancelJob(job),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: const Text('İşi İptal Et'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
foregroundColor: AppColors.cancelled,
|
||||
side: const BorderSide(color: AppColors.cancelled),
|
||||
),
|
||||
),
|
||||
],
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
job.patientName?.isNotEmpty == true
|
||||
? job.patientName!
|
||||
: job.patientCode,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: _statusBg(job.status),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
job.status.label,
|
||||
style: TextStyle(
|
||||
color: _statusColor(job.status),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_InfoRow(
|
||||
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.route_outlined,
|
||||
label: 'Akış',
|
||||
value: job.workflowPreset.title,
|
||||
),
|
||||
_InfoRow(
|
||||
icon: Icons.fact_check_outlined,
|
||||
label: 'Prova',
|
||||
value: job.provaRequired ? 'Provalı' : 'Provasız',
|
||||
),
|
||||
_InfoRow(
|
||||
icon: Icons.format_list_numbered,
|
||||
label: 'Üye Sayısı',
|
||||
value: '${job.memberCount} üye'),
|
||||
if (job.color != null)
|
||||
_InfoRow(
|
||||
icon: Icons.color_lens_outlined,
|
||||
label: 'Renk',
|
||||
value: job.color!),
|
||||
if (job.dueDate != null)
|
||||
_InfoRow(
|
||||
icon: Icons.calendar_today,
|
||||
label: 'Teslim Tarihi',
|
||||
value: _formatDate(job.dueDate!, withTime: true),
|
||||
valueColor: job.dueDate!.isBefore(DateTime.now())
|
||||
? AppColors.cancelled
|
||||
: null),
|
||||
_InfoRow(
|
||||
icon: Icons.add_circle_outline,
|
||||
label: 'Oluşturulma',
|
||||
value: _formatDate(job.dateCreated)),
|
||||
if (job.price != null && job.currency != null)
|
||||
_InfoRow(
|
||||
icon: Icons.attach_money,
|
||||
label: 'Fiyat',
|
||||
value:
|
||||
'${job.price!.toStringAsFixed(2)} ${job.currency}'),
|
||||
if (job.description != null && job.description!.isNotEmpty)
|
||||
_InfoRow(
|
||||
icon: Icons.notes,
|
||||
label: 'Açıklama',
|
||||
value: job.description!),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
JobFilesPanel(
|
||||
job: job,
|
||||
filesFuture: _filesFuture,
|
||||
onRefresh: _loadFiles,
|
||||
// Stepper
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'İş Adımları',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: job.provaRequired
|
||||
? AppColors.inProgressBg
|
||||
: AppColors.successBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
job.provaRequired ? 'Provalı' : 'Provasız',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: job.provaRequired
|
||||
? AppColors.inProgress
|
||||
: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_JobStepper(
|
||||
steps: job.stepTemplate,
|
||||
currentStep: job.currentStep,
|
||||
isDelivered: job.status == JobStatus.delivered,
|
||||
historyFuture: _historyFuture,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action buttons
|
||||
if (_isActing)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent)),
|
||||
)
|
||||
else ...[
|
||||
if (canAccept)
|
||||
FilledButton.icon(
|
||||
onPressed: () => _acceptJob(job),
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text('Kabul Et'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
),
|
||||
if (canSendToClinic)
|
||||
FilledButton.icon(
|
||||
onPressed: () => _showHandToClinicSheet(job),
|
||||
icon: const Icon(Icons.send_outlined),
|
||||
label: Text(
|
||||
(job.isLastStep)
|
||||
? 'Son Prova - Teslime Gönder'
|
||||
: 'Prova için Kliniğe Gönder',
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
backgroundColor: (job.isLastStep)
|
||||
? AppColors.success
|
||||
: AppColors.inProgress,
|
||||
),
|
||||
),
|
||||
if (canCancelJobs && job.status == JobStatus.pending) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _cancelJob(job),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: const Text('İşi İptal Et'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
foregroundColor: AppColors.cancelled,
|
||||
side: const BorderSide(color: AppColors.cancelled),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
JobFilesPanel(
|
||||
job: job,
|
||||
filesFuture: _filesFuture,
|
||||
onRefresh: _loadFiles,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,12 +572,19 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
final isDesktop =
|
||||
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
final currentStep = widget.job.currentStep;
|
||||
final isLast = widget.job.isLastStep;
|
||||
final stepLabel = widget.job.currentStep?.label ?? '';
|
||||
final stepLabel = currentStep?.label ?? '';
|
||||
final requiresClinicApproval = currentStep?.requiresClinicApproval ?? true;
|
||||
final buttonLabel = isLast
|
||||
? (widget.job.provaRequired ? 'Son Prova · Teslime Gönder' : 'Teslime Gönder')
|
||||
: '$stepLabel için Kliniğe Gönder';
|
||||
? (widget.job.provaRequired
|
||||
? 'Son Prova · Teslime Gönder'
|
||||
: 'Teslime Gönder')
|
||||
: requiresClinicApproval
|
||||
? '$stepLabel için Kliniğe Gönder'
|
||||
: '$stepLabel tamamlandı, sonraki adıma geç';
|
||||
final buttonColor = isLast ? AppColors.success : AppColors.inProgress;
|
||||
|
||||
return Container(
|
||||
@@ -534,9 +598,7 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 24,
|
||||
bottom: isDesktop
|
||||
? 24
|
||||
: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
bottom: isDesktop ? 24 : MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -544,17 +606,16 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
|
||||
children: [
|
||||
Text(
|
||||
buttonLabel,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold, color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isLast
|
||||
? 'İş teslim edilecek olarak işaretlenecek.'
|
||||
: 'İş klinikteki prova için gönderilecek.',
|
||||
: requiresClinicApproval
|
||||
? 'İş klinikteki prova veya onay için gönderilecek.'
|
||||
: 'Bu iç adım tamamlanacak ve iş laboratuvarda ilerleyecek.',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -575,7 +636,8 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final updated = await LabJobsRepository.instance.handToClinic(
|
||||
final updated =
|
||||
await LabJobsRepository.instance.handToClinic(
|
||||
widget.job.id,
|
||||
widget.job,
|
||||
note: _noteController.text.trim().isEmpty
|
||||
@@ -587,7 +649,9 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
|
||||
SnackBar(
|
||||
content: Text(isLast
|
||||
? 'İş teslim için gönderildi'
|
||||
: 'Prova için klinik\'e gönderildi')),
|
||||
: requiresClinicApproval
|
||||
? 'Onay için kliniğe gönderildi'
|
||||
: 'İş bir sonraki iç adıma geçirildi')),
|
||||
);
|
||||
if (context.mounted) widget.onDone(updated);
|
||||
} catch (e) {
|
||||
@@ -645,7 +709,8 @@ class _InfoRow extends StatelessWidget {
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
|
||||
style:
|
||||
const TextStyle(color: AppColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
@@ -670,10 +735,12 @@ class _JobStepper extends StatelessWidget {
|
||||
const _JobStepper({
|
||||
required this.steps,
|
||||
required this.currentStep,
|
||||
required this.isDelivered,
|
||||
required this.historyFuture,
|
||||
});
|
||||
final List<JobStep> steps;
|
||||
final JobStep? currentStep;
|
||||
final bool isDelivered;
|
||||
final Future<List<JobHistoryEntry>> historyFuture;
|
||||
|
||||
@override
|
||||
@@ -686,7 +753,8 @@ class _JobStepper extends StatelessWidget {
|
||||
final Map<JobStep, int> revisionCounts = {};
|
||||
final Map<JobStep, List<JobHistoryEntry>> notesByStep = {};
|
||||
for (final e in history) {
|
||||
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
|
||||
if (e.action == JobHistoryAction.revisionRequested &&
|
||||
e.step != null) {
|
||||
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
|
||||
}
|
||||
if (e.step != null && e.note != null && e.note!.trim().isNotEmpty) {
|
||||
@@ -699,8 +767,8 @@ class _JobStepper extends StatelessWidget {
|
||||
return Column(
|
||||
children: List.generate(steps.length, (i) {
|
||||
final step = steps[i];
|
||||
final isCompleted = i < currentIndex;
|
||||
final isCurrent = i == currentIndex;
|
||||
final isCompleted = isDelivered || i < currentIndex;
|
||||
final isCurrent = !isDelivered && i == currentIndex;
|
||||
final isLastItem = i == steps.length - 1;
|
||||
final revCount = revisionCounts[step] ?? 0;
|
||||
final stepNotes = notesByStep[step] ?? const <JobHistoryEntry>[];
|
||||
@@ -728,7 +796,7 @@ class _JobStepper extends StatelessWidget {
|
||||
Container(
|
||||
width: 2,
|
||||
height: 44,
|
||||
color: i < currentIndex
|
||||
color: isDelivered || i < currentIndex
|
||||
? AppColors.success.withValues(alpha: 0.35)
|
||||
: AppColors.border,
|
||||
),
|
||||
@@ -788,7 +856,8 @@ class _JobStepper extends StatelessWidget {
|
||||
),
|
||||
if (stepNotes.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
...stepNotes.map((entry) => _StepNoteCard(entry: entry)),
|
||||
...stepNotes
|
||||
.map((entry) => _StepNoteCard(entry: entry)),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -846,6 +915,7 @@ class _StepNoteCard extends StatelessWidget {
|
||||
String _label(JobHistoryAction action) {
|
||||
return switch (action) {
|
||||
JobHistoryAction.revisionRequested => 'Revizyon Notu',
|
||||
JobHistoryAction.stepCompleted => 'İç Adım Notu',
|
||||
JobHistoryAction.handedToClinic => 'Laboratuvar Notu',
|
||||
JobHistoryAction.approved => 'Onay Notu',
|
||||
JobHistoryAction.delivered => 'Teslim Notu',
|
||||
|
||||
@@ -24,26 +24,32 @@ class LabJobsRepository {
|
||||
if (status != null) filterParts.add('status = "$status"');
|
||||
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
|
||||
}
|
||||
|
||||
Future<List<Job>> listInProgress(String labTenantId, {int limit = 50, String? location}) async {
|
||||
final filterParts = ['lab_tenant_id = "$labTenantId"', 'status = "in_progress"'];
|
||||
Future<List<Job>> listInProgress(String labTenantId,
|
||||
{int limit = 50, String? location}) async {
|
||||
final filterParts = [
|
||||
'lab_tenant_id = "$labTenantId"',
|
||||
'status = "in_progress"'
|
||||
];
|
||||
if (location != null) filterParts.add('location = "$location"');
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) {
|
||||
if (a.dueDate == null && b.dueDate == null) return b.dateCreated.compareTo(a.dateCreated);
|
||||
if (a.dueDate == null && b.dueDate == null) {
|
||||
return b.dateCreated.compareTo(a.dateCreated);
|
||||
}
|
||||
if (a.dueDate == null) return 1;
|
||||
if (b.dueDate == null) return -1;
|
||||
final cmp = a.dueDate!.compareTo(b.dueDate!);
|
||||
@@ -52,7 +58,8 @@ class LabJobsRepository {
|
||||
}
|
||||
|
||||
Future<Job> getJob(String jobId) async {
|
||||
final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand);
|
||||
final record =
|
||||
await _pb.collection('jobs').getOne(jobId, expand: _detailExpand);
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
@@ -75,10 +82,21 @@ class LabJobsRepository {
|
||||
}
|
||||
|
||||
Future<Job> handToClinic(String jobId, Job job, {String? note}) async {
|
||||
final isFinal = job.currentStep == JobStep.cilaBitim;
|
||||
final currentStep = job.currentStep;
|
||||
if (currentStep == null) {
|
||||
throw Exception('Geçerli bir iş adımı bulunamadı.');
|
||||
}
|
||||
|
||||
final isFinal = currentStep == JobStep.cilaBitim;
|
||||
final nextStep = job.nextStep;
|
||||
final patch = isFinal
|
||||
? {'status': 'sent', 'location': 'at_clinic'}
|
||||
: {'location': 'at_clinic'};
|
||||
: currentStep.requiresClinicApproval
|
||||
? {'location': 'at_clinic'}
|
||||
: {
|
||||
'current_step': nextStep?.value,
|
||||
'location': 'at_lab',
|
||||
};
|
||||
|
||||
final record = await _pb.collection('jobs').update(jobId, body: patch);
|
||||
final updated = Job.fromJson(record.toJson());
|
||||
@@ -86,8 +104,10 @@ class LabJobsRepository {
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.handedToClinic,
|
||||
step: job.currentStep,
|
||||
action: currentStep.requiresClinicApproval || isFinal
|
||||
? JobHistoryAction.handedToClinic
|
||||
: JobHistoryAction.stepCompleted,
|
||||
step: currentStep,
|
||||
note: note,
|
||||
));
|
||||
return updated;
|
||||
@@ -109,7 +129,8 @@ class LabJobsRepository {
|
||||
}
|
||||
|
||||
Future<void> bulkAcceptPending(String labTenantId) async {
|
||||
final pending = await listInbound(labTenantId, status: 'pending', limit: 200);
|
||||
final pending =
|
||||
await listInbound(labTenantId, status: 'pending', limit: 200);
|
||||
await Future.wait(pending.map((j) => acceptJob(j)));
|
||||
}
|
||||
|
||||
@@ -121,11 +142,14 @@ class LabJobsRepository {
|
||||
return r.totalItems;
|
||||
}
|
||||
|
||||
Future<int> countDelivered(String labTenantId, {DateTime? from, DateTime? to}) async {
|
||||
Future<int> countDelivered(String labTenantId,
|
||||
{DateTime? from, DateTime? to}) async {
|
||||
final parts = ['lab_tenant_id = "$labTenantId"', 'status = "delivered"'];
|
||||
if (from != null) parts.add('updated >= "${_date(from)}"');
|
||||
if (to != null) parts.add('updated < "${_date(to)}"');
|
||||
final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && '));
|
||||
final r = await _pb
|
||||
.collection('jobs')
|
||||
.getList(perPage: 1, filter: parts.join(' && '));
|
||||
return r.totalItems;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,12 @@ import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/providers/locale_provider.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../../../models/tenant.dart';
|
||||
import '../../shared/location_completion_banner.dart';
|
||||
import '../../shared/tenant_team_screen.dart';
|
||||
import '../../shared/location_picker_sheet.dart';
|
||||
import '../../shared/tenant_location_data.dart';
|
||||
import '../connections/lab_connections_screen.dart';
|
||||
|
||||
class LabSettingsScreen extends ConsumerWidget {
|
||||
@@ -29,6 +33,17 @@ class LabSettingsScreen extends ConsumerWidget {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (tenant?.hasLocation != true) ...[
|
||||
LocationCompletionBanner(
|
||||
title: 'Konum eksik',
|
||||
description:
|
||||
'Laboratuvarınızın haritada görünmesi ve kliniklerin sizi yakın sonuçlarda bulabilmesi için koordinat kaydı tamamlanmalı.',
|
||||
buttonLabel: 'Konumu Düzenle',
|
||||
onTap: () => _showEditSheet(context, ref, tenant, s),
|
||||
compact: true,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
// User card
|
||||
_SectionHeader(title: s.userInfo),
|
||||
_UserCard(profile: profile),
|
||||
@@ -60,7 +75,9 @@ class LabSettingsScreen extends ConsumerWidget {
|
||||
_InfoTileBadge(
|
||||
icon: Icons.circle_outlined,
|
||||
label: s.status,
|
||||
value: tenant?.status == 'active' ? s.active : (tenant?.status ?? '-'),
|
||||
value: tenant?.status == 'active'
|
||||
? s.active
|
||||
: (tenant?.status ?? '-'),
|
||||
badgeColor: AppColors.success,
|
||||
badgeBg: AppColors.successBg,
|
||||
),
|
||||
@@ -69,9 +86,42 @@ class LabSettingsScreen extends ConsumerWidget {
|
||||
label: s.role,
|
||||
value: _roleLabel(membership?.role, s),
|
||||
),
|
||||
_InfoTile(
|
||||
icon: Icons.place_outlined,
|
||||
label: 'Konum',
|
||||
value: tenant?.locationLabel.isNotEmpty == true
|
||||
? tenant!.locationLabel
|
||||
: '-',
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
if (tenant != null && tenant.isLab) ...[
|
||||
_SectionHeader(
|
||||
title: 'İş Akışı',
|
||||
action: canEdit
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.tune_rounded,
|
||||
size: 18, color: AppColors.accent),
|
||||
tooltip: 'Akışı Düzenle',
|
||||
onPressed: () => _showWorkflowSheet(context, ref, tenant),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
_InfoCard(
|
||||
children: [
|
||||
_WorkflowPreviewTile(
|
||||
enabledSteps: tenant.workflowOverrideSteps,
|
||||
canEdit: canEdit,
|
||||
onTap: canEdit
|
||||
? () => _showWorkflowSheet(context, ref, tenant)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Connections
|
||||
if (membership?.showConnections ?? false) ...[
|
||||
_SectionHeader(title: s.connections),
|
||||
@@ -107,7 +157,9 @@ class LabSettingsScreen extends ConsumerWidget {
|
||||
onTap: () {
|
||||
ref.read(authProvider.notifier).setActiveTenant(m);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))),
|
||||
SnackBar(
|
||||
content:
|
||||
Text(s.tenantSelected(m.tenant.companyName))),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -127,8 +179,7 @@ class LabSettingsScreen extends ConsumerWidget {
|
||||
subtitle: s.teamSub,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const TenantTeamScreen()),
|
||||
MaterialPageRoute(builder: (_) => const TenantTeamScreen()),
|
||||
),
|
||||
),
|
||||
_NavTile(
|
||||
@@ -155,6 +206,14 @@ class LabSettingsScreen extends ConsumerWidget {
|
||||
subtitle: s.aiAssistantSub,
|
||||
onTap: () => context.push(routeLabAi),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.workspace_premium_outlined,
|
||||
iconColor: AppColors.primary,
|
||||
iconBg: const Color(0xFFEFF6FF),
|
||||
title: 'Paketler ve AI Kredileri',
|
||||
subtitle: 'Trial ve paket görünümünü incele',
|
||||
onTap: () => context.push(routeWelcome),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
@@ -167,7 +226,8 @@ class LabSettingsScreen extends ConsumerWidget {
|
||||
iconColor: AppColors.accent,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.appLanguage,
|
||||
subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s),
|
||||
subtitle: _currentLanguageLabel(
|
||||
ref.watch(localeProvider).languageCode, s),
|
||||
onTap: () => _showLanguagePicker(context, ref, s),
|
||||
),
|
||||
]),
|
||||
@@ -191,7 +251,8 @@ class LabSettingsScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
|
||||
void _showEditSheet(
|
||||
BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
|
||||
if (tenant == null) return;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -200,11 +261,12 @@ class LabSettingsScreen extends ConsumerWidget {
|
||||
builder: (_) => _EditTenantSheet(
|
||||
tenant: tenant,
|
||||
s: s,
|
||||
onSave: (name, currency) async {
|
||||
onSave: (name, currency, location) async {
|
||||
await ref.read(authProvider.notifier).updateTenantInfo(
|
||||
tenantId: tenant.id,
|
||||
companyName: name,
|
||||
defaultCurrency: currency,
|
||||
location: location,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -219,6 +281,29 @@ class LabSettingsScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showWorkflowSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Tenant tenant,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _WorkflowSettingsSheet(
|
||||
tenant: tenant,
|
||||
onSave: (steps) async {
|
||||
await ref.read(authProvider.notifier).updateTenantInfo(
|
||||
tenantId: tenant.id,
|
||||
companyName: tenant.companyName,
|
||||
defaultCurrency: tenant.defaultCurrency,
|
||||
workflowOverrides: steps.map((step) => step.value).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _tenantKindLabel(TenantKind? kind, AppStrings s) =>
|
||||
switch (kind) {
|
||||
TenantKind.clinic => s.tenantKindClinic,
|
||||
@@ -226,7 +311,8 @@ class LabSettingsScreen extends ConsumerWidget {
|
||||
null => '-',
|
||||
};
|
||||
|
||||
static String _currentLanguageLabel(String code, AppStrings s) => switch (code) {
|
||||
static String _currentLanguageLabel(String code, AppStrings s) =>
|
||||
switch (code) {
|
||||
'en' => s.languageEnglish,
|
||||
'ru' => s.languageRussian,
|
||||
'ar' => s.languageArabic,
|
||||
@@ -334,7 +420,11 @@ class _EditTenantSheet extends StatefulWidget {
|
||||
});
|
||||
final Tenant tenant;
|
||||
final AppStrings s;
|
||||
final Future<void> Function(String companyName, String currency) onSave;
|
||||
final Future<void> Function(
|
||||
String companyName,
|
||||
String currency,
|
||||
TenantLocationData location,
|
||||
) onSave;
|
||||
|
||||
@override
|
||||
State<_EditTenantSheet> createState() => _EditTenantSheetState();
|
||||
@@ -342,7 +432,11 @@ class _EditTenantSheet extends StatefulWidget {
|
||||
|
||||
class _EditTenantSheetState extends State<_EditTenantSheet> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _addressController;
|
||||
late final TextEditingController _cityController;
|
||||
late final TextEditingController _districtController;
|
||||
late String _selectedCurrency;
|
||||
late TenantLocationData _location;
|
||||
bool _saving = false;
|
||||
|
||||
static const _currencies = [
|
||||
@@ -358,26 +452,39 @@ class _EditTenantSheetState extends State<_EditTenantSheet> {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.tenant.companyName);
|
||||
_selectedCurrency = widget.tenant.defaultCurrency;
|
||||
_location = TenantLocationData.fromTenant(widget.tenant);
|
||||
_addressController = TextEditingController(text: _location.address ?? '');
|
||||
_cityController = TextEditingController(text: _location.city ?? '');
|
||||
_districtController = TextEditingController(text: _location.district ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_addressController.dispose();
|
||||
_cityController.dispose();
|
||||
_districtController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
final location = _location.copyWith(
|
||||
address: _addressController.text.trim(),
|
||||
city: _cityController.text.trim(),
|
||||
district: _districtController.text.trim(),
|
||||
);
|
||||
if (!location.hasDetails) return;
|
||||
setState(() => _saving = true);
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await widget.onSave(name, _selectedCurrency);
|
||||
await widget.onSave(name, _selectedCurrency, location);
|
||||
navigator.pop();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
|
||||
messenger
|
||||
.showSnackBar(SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
@@ -431,7 +538,7 @@ class _EditTenantSheetState extends State<_EditTenantSheet> {
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedCurrency,
|
||||
initialValue: _selectedCurrency,
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
@@ -454,13 +561,91 @@ class _EditTenantSheetState extends State<_EditTenantSheet> {
|
||||
if (v != null) setState(() => _selectedCurrency = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Konum',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_location.fullLabel.isNotEmpty
|
||||
? _location.fullLabel
|
||||
: 'Henüz konum veya adres bilgisi girilmedi.',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final picked = await showLocationPickerSheet(
|
||||
context,
|
||||
initialLocation: _location,
|
||||
title: 'Laboratuvar Konumu',
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => _location = picked);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
label: const Text('Haritadan Konum Seç'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _addressController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Açık Adres',
|
||||
hintText: 'Cadde, sokak, mahalle bilgisi',
|
||||
),
|
||||
maxLines: 2,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _cityController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Şehir',
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _districtController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'İlçe',
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_saving)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent))
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
onPressed: _saving ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 48)),
|
||||
child: Text(s.save),
|
||||
@@ -593,7 +778,10 @@ class _InfoCard extends StatelessWidget {
|
||||
children[i],
|
||||
if (i < children.length - 1)
|
||||
const Divider(
|
||||
height: 1, indent: 16, endIndent: 16, color: AppColors.border),
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: AppColors.border),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -662,12 +850,11 @@ class _InfoTileBadge extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textMuted)),
|
||||
style:
|
||||
const TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -704,8 +891,7 @@ class _NavTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -720,13 +906,170 @@ class _NavTile extends StatelessWidget {
|
||||
? Text(subtitle!,
|
||||
style: const TextStyle(color: AppColors.textSecondary))
|
||||
: null,
|
||||
trailing:
|
||||
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
|
||||
trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WorkflowPreviewTile extends StatelessWidget {
|
||||
const _WorkflowPreviewTile({
|
||||
required this.enabledSteps,
|
||||
required this.canEdit,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final List<JobStep> enabledSteps;
|
||||
final bool canEdit;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final summary = enabledSteps.isEmpty
|
||||
? 'Varsayılan preset akışı kullanılıyor.'
|
||||
: 'Ekstra adımlar: ${enabledSteps.map((step) => step.label).join(', ')}';
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child:
|
||||
const Icon(Icons.route_outlined, color: AppColors.accent, size: 18),
|
||||
),
|
||||
title: const Text(
|
||||
'Ekstra Laboratuvar Adımları',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
summary,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
trailing: canEdit
|
||||
? const Icon(Icons.chevron_right, color: AppColors.textSecondary)
|
||||
: null,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WorkflowSettingsSheet extends StatefulWidget {
|
||||
const _WorkflowSettingsSheet({
|
||||
required this.tenant,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
final Tenant tenant;
|
||||
final Future<void> Function(List<JobStep> steps) onSave;
|
||||
|
||||
@override
|
||||
State<_WorkflowSettingsSheet> createState() => _WorkflowSettingsSheetState();
|
||||
}
|
||||
|
||||
class _WorkflowSettingsSheetState extends State<_WorkflowSettingsSheet> {
|
||||
late final Set<JobStep> _selected =
|
||||
widget.tenant.workflowOverrideSteps.toSet();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 24,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Ekstra İş Adımları',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Bunlar preset akışın üstüne eklenir. Bazı adımlar klinik onayı ister, bazıları laboratuvar içidir.',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: optionalLabStepCatalog.map((step) {
|
||||
final selected = _selected.contains(step);
|
||||
return CheckboxListTile(
|
||||
value: selected,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selected.add(step);
|
||||
} else {
|
||||
_selected.remove(step);
|
||||
}
|
||||
});
|
||||
},
|
||||
title: Text(step.label),
|
||||
subtitle: Text(
|
||||
'${step.description} · ${step.requiresClinicApproval ? "Klinik onayı gerekir" : "Laboratuvar iç adımı"}',
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _saving
|
||||
? null
|
||||
: () async {
|
||||
final navigator = Navigator.of(context);
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await widget.onSave(_selected.toList());
|
||||
if (mounted) navigator.pop();
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Kaydet'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignOutCard extends StatelessWidget {
|
||||
const _SignOutCard({required this.ref, required this.s});
|
||||
final WidgetRef ref;
|
||||
@@ -747,16 +1090,14 @@ class _SignOutCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(9)),
|
||||
child: const Icon(Icons.logout,
|
||||
color: AppColors.cancelled, size: 18),
|
||||
child: const Icon(Icons.logout, color: AppColors.cancelled, size: 18),
|
||||
),
|
||||
title: Text(s.signOut,
|
||||
style: const TextStyle(
|
||||
|
||||
Reference in New Issue
Block a user