Files
lab-app/lib/features/lab/jobs/lab_all_jobs_screen.dart
T
Emre Emir 8bbc9dbff2 Initial commit: DLS - Dental Lab System
- Flutter + PocketBase dental lab management system
- Clinic & lab dashboards, job tracking, patient management
- Product catalog, finance tracking, multi-language support
- AI assistant integration, realtime notifications
- Windows installer (Inno Setup) included
- Developed by kovakyazilim.com
2026-06-11 15:57:31 +03:00

897 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../core/widgets/pill_tabs.dart';
import '../../../models/job.dart';
import 'lab_jobs_repository.dart';
enum _JobSort { newestFirst, oldestFirst, byDueDate, byType }
const _kSortLabels = [
'Yeniden Eskiye',
'Eskiden Yeniye',
'Vade Tarihine Göre',
'Türe Göre',
];
class LabAllJobsScreen extends ConsumerStatefulWidget {
const LabAllJobsScreen({super.key});
@override
ConsumerState<LabAllJobsScreen> createState() => _LabAllJobsScreenState();
}
class _LabAllJobsScreenState extends ConsumerState<LabAllJobsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _searchController = TextEditingController();
String _searchQuery = '';
_JobSort _sort = _JobSort.newestFirst;
bool _bulkAccepting = false;
final Map<String, int?> _counts = {
'all': null,
'pending': null,
'in_progress': null,
'sent': null,
'delivered': null,
};
final _pendingTabKey = GlobalKey<_PendingJobsTabState>();
// null entry = Tümü (bütün statüsler)
static const List<String?> _statuses = [null, 'pending', 'in_progress', 'sent', 'delivered'];
static const _tabLabels = ['Tümü', 'Onay Bekleyen', 'Devam Eden', 'Gönderildi', 'Teslim Edildi'];
String _countKey(String? s) => s ?? 'all';
@override
void initState() {
super.initState();
final isDelivery = ref.read(authProvider).activeTenant?.isDeliveryOnly ?? false;
_tabController = TabController(length: 5, vsync: this, initialIndex: isDelivery ? 3 : 0);
_tabController.addListener(() {
if (mounted) setState(() {});
});
_fetchAllCounts();
}
Future<void> _fetchAllCounts() async {
final tenantId = ref.read(authProvider).activeTenant?.tenant.id;
if (tenantId == null) return;
final results = await Future.wait(
_statuses.map((s) => LabJobsRepository.instance.countByStatus(tenantId, s)),
);
if (!mounted) return;
setState(() {
for (var i = 0; i < _statuses.length; i++) {
_counts[_countKey(_statuses[i])] = results[i];
}
});
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String value) {
setState(() => _searchQuery = value);
}
Future<void> _showSortOptions() async {
final result = await showSortSheet(
context,
title: 'Sıralama',
options: _kSortLabels,
current: _sort.index,
);
if (result != null) {
setState(() => _sort = _JobSort.values[result]);
}
}
Future<void> _bulkAccept() async {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() => _bulkAccepting = true);
try {
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
_pendingTabKey.currentState?._load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tüm işler kabul edildi')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
} finally {
if (mounted) setState(() => _bulkAccepting = false);
}
}
@override
Widget build(BuildContext context) {
final isSortActive = _sort != _JobSort.newestFirst;
final onPendingTab = _tabController.index == 1;
final pendingCount = _counts['pending'];
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: 'İşler',
category: 'LABORATUVAR',
searchController: _searchController,
onSearchChanged: _onSearchChanged,
searchHint: 'Protokol, klinik veya tür ara...',
actions: [
if (!onPendingTab)
IconButton(
onPressed: _showSortOptions,
tooltip: 'Sırala',
icon: Badge(
isLabelVisible: isSortActive,
smallSize: 8,
backgroundColor: AppColors.accent,
child: const Icon(Icons.sort_rounded),
),
),
],
),
floatingActionButton: onPendingTab && (pendingCount == null || pendingCount > 0)
? FloatingActionButton.extended(
onPressed: _bulkAccepting ? null : _bulkAccept,
icon: _bulkAccepting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.done_all),
label: Text(_bulkAccepting ? 'Kabul ediliyor...' : 'Tümünü Kabul Et'),
backgroundColor: AppColors.pending,
foregroundColor: Colors.white,
)
: null,
body: Column(
children: [
PillTabs(
tabs: _tabLabels,
selected: _tabController.index,
onSelect: (i) => _tabController.animateTo(i),
counts: _statuses.map((s) => _counts[_countKey(s)]).toList(),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_LabJobsTab(
status: null,
searchQuery: _searchQuery,
sort: _sort,
onCountLoaded: (c) => setState(() => _counts['all'] = c),
),
_PendingJobsTab(
key: _pendingTabKey,
searchQuery: _searchQuery,
onCountLoaded: (c) => setState(() => _counts['pending'] = c),
),
_LabJobsTab(
status: 'in_progress',
searchQuery: _searchQuery,
sort: _sort,
onCountLoaded: (c) => setState(() => _counts['in_progress'] = c),
),
_LabJobsTab(
status: 'sent',
searchQuery: _searchQuery,
sort: _sort,
onCountLoaded: (c) => setState(() => _counts['sent'] = c),
),
_LabJobsTab(
status: 'delivered',
searchQuery: _searchQuery,
sort: _sort,
onCountLoaded: (c) => setState(() => _counts['delivered'] = c),
),
],
),
),
],
),
);
}
}
// ── Pending (Onay Bekleyen) tab ───────────────────────────────────────────────
class _PendingJobsTab extends ConsumerStatefulWidget {
const _PendingJobsTab({super.key, required this.searchQuery, this.onCountLoaded});
final String searchQuery;
final void Function(int)? onCountLoaded;
@override
ConsumerState<_PendingJobsTab> createState() => _PendingJobsTabState();
}
class _PendingJobsTabState extends ConsumerState<_PendingJobsTab> {
late Future<List<Job>> _future;
late UnsubFn _unsub;
@override
void initState() {
super.initState();
_load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_unsub = RealtimeService.instance.watch(
'jobs',
filter: 'lab_tenant_id="$tenantId" && status="pending"',
onEvent: (_) { if (mounted) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = LabJobsRepository.instance.listInbound(tenantId, status: 'pending', limit: 50);
});
}
Future<void> _acceptJob(Job job) async {
try {
await LabJobsRepository.instance.acceptJob(job);
_load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş kabul edildi')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
List<Job> _filtered(List<Job> jobs) {
final q = widget.searchQuery.toLowerCase().trim();
if (q.isEmpty) return jobs;
return jobs.where((j) =>
j.patientCode.toLowerCase().contains(q) ||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
j.prostheticType.label.toLowerCase().contains(q)
).toList();
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<List<Job>>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator(color: AppColors.accent));
}
if (snap.hasError) {
return Center(
child: Column(
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),
),
const SizedBox(height: 16),
Text('Hata: ${snap.error}', style: const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final all = snap.data!;
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onCountLoaded?.call(all.length);
});
final jobs = _filtered(all);
if (jobs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.successBg,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(Icons.inbox_outlined, color: AppColors.success, size: 32),
),
const SizedBox(height: 16),
Text(
widget.searchQuery.isNotEmpty ? 'Sonuç bulunamadı' : 'Onay bekleyen iş yok',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
),
if (widget.searchQuery.isEmpty) ...[
const SizedBox(height: 6),
const Text('Tüm işler kabul edildi', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
],
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
itemCount: jobs.length,
itemBuilder: (ctx, i) {
final job = jobs[i];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _PendingJobCard(
job: job,
onAccept: () => _acceptJob(job),
),
);
},
);
},
),
);
}
}
class _PendingJobCard extends StatefulWidget {
const _PendingJobCard({required this.job, required this.onAccept});
final Job job;
final VoidCallback onAccept;
@override
State<_PendingJobCard> createState() => _PendingJobCardState();
}
class _PendingJobCardState extends State<_PendingJobCard> {
bool _accepting = false;
@override
Widget build(BuildContext context) {
final job = widget.job;
return Dismissible(
key: ValueKey(job.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: AppColors.success,
borderRadius: BorderRadius.circular(14),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_rounded, color: Colors.white, size: 28),
SizedBox(height: 4),
Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
],
),
),
confirmDismiss: (_) async {
setState(() => _accepting = true);
try {
await LabJobsRepository.instance.acceptJob(job);
return true;
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
return false;
} finally {
if (mounted) setState(() => _accepting = false);
}
},
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () => context.push('/lab/jobs/${job.id}'),
borderRadius: BorderRadius.circular(14),
child: Container(
decoration: BoxDecoration(
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: IntrinsicHeight(
child: Row(
children: [
Container(
width: 4,
decoration: const BoxDecoration(
color: AppColors.pending,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(14),
bottomLeft: Radius.circular(14),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.patientCode,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.local_hospital_outlined, size: 12, color: AppColors.textMuted),
const SizedBox(width: 4),
Expanded(
child: Text(
job.clinicName ?? 'Klinik',
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: AppColors.pendingBg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
job.prostheticType.label,
style: const TextStyle(fontSize: 11, color: AppColors.pending, fontWeight: FontWeight.w600),
),
),
if (job.dueDate != null) ...[
const SizedBox(width: 6),
const Icon(Icons.calendar_today_outlined, size: 11, color: AppColors.textMuted),
const SizedBox(width: 3),
Text(
'${job.dueDate!.day.toString().padLeft(2, '0')}.${job.dueDate!.month.toString().padLeft(2, '0')}.${job.dueDate!.year}',
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
],
],
),
],
),
),
const SizedBox(width: 8),
_accepting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.success),
)
: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: AppColors.successBg,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.success.withValues(alpha: 0.3)),
),
child: const Text(
'Kabul Et',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.success),
),
),
],
),
),
),
],
),
),
),
),
),
);
}
}
class _LabJobsTab extends ConsumerStatefulWidget {
const _LabJobsTab({
required this.status,
required this.searchQuery,
required this.sort,
this.onCountLoaded,
});
final String? status; // null = tüm statüsler
final String searchQuery;
final _JobSort sort;
final void Function(int)? onCountLoaded;
@override
ConsumerState<_LabJobsTab> createState() => _LabJobsTabState();
}
class _LabJobsTabState extends ConsumerState<_LabJobsTab> {
late Future<List<Job>> _future;
late UnsubFn _unsub;
@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(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = LabJobsRepository.instance
.listInbound(tenantId, status: widget.status, limit: 50);
});
}
List<Job> _applyFilters(List<Job> jobs) {
var list = jobs;
final q = widget.searchQuery.toLowerCase().trim();
if (q.isNotEmpty) {
list = list.where((j) {
return j.patientCode.toLowerCase().contains(q) ||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
j.prostheticType.label.toLowerCase().contains(q) ||
(j.currentStep?.label.toLowerCase().contains(q) ?? false);
}).toList();
}
final sorted = List<Job>.from(list);
switch (widget.sort) {
case _JobSort.newestFirst:
sorted.sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
case _JobSort.oldestFirst:
sorted.sort((a, b) => a.dateCreated.compareTo(b.dateCreated));
case _JobSort.byDueDate:
sorted.sort((a, b) {
if (a.dueDate == null && b.dueDate == null) return 0;
if (a.dueDate == null) return 1;
if (b.dueDate == null) return -1;
return a.dueDate!.compareTo(b.dueDate!);
});
case _JobSort.byType:
sorted.sort(
(a, b) => a.prostheticType.label.compareTo(b.prostheticType.label));
}
return sorted;
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<List<Job>>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(color: AppColors.accent));
}
if (snap.hasError) {
return Center(
child: Column(
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),
),
const SizedBox(height: 16),
Text('Hata: ${snap.error}',
style:
const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final all = snap.data!;
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onCountLoaded?.call(all.length);
});
final jobs = _applyFilters(all);
if (jobs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(20)),
child: const Icon(Icons.work_off_outlined,
color: AppColors.inProgress, size: 32),
),
const SizedBox(height: 16),
Text(
widget.searchQuery.isNotEmpty
? 'Sonuç bulunamadı'
: 'Henüz iş yok',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount: jobs.length,
itemBuilder: (ctx, i) {
final job = jobs[i];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _LabJobCard(
job: job,
onTap: () => context.push('/lab/jobs/${job.id}'),
),
);
},
);
},
),
);
}
}
class _LabJobCard extends StatelessWidget {
const _LabJobCard({required this.job, required this.onTap});
final Job job;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final isOverdue =
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
final accentColor = _statusColor(job.status);
return Semantics(
label: job.patientCode,
button: true,
excludeSemantics: true,
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
decoration: BoxDecoration(
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: IntrinsicHeight(
child: Row(
children: [
Container(
width: 4,
decoration: BoxDecoration(
color: accentColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(14),
bottomLeft: Radius.circular(14),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
job.patientCode,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
),
if (job.currentStep != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
job.currentStep!.label,
style: const TextStyle(
color: AppColors.inProgress,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 5),
Row(
children: [
const Icon(Icons.local_hospital_outlined,
size: 12, color: AppColors.textMuted),
const SizedBox(width: 4),
Expanded(
child: Text(
job.clinicName ?? 'Klinik',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 5),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(6),
),
child: Text(
job.prostheticType.label,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
if (job.dueDate != null) ...[
const SizedBox(width: 8),
Icon(Icons.calendar_today_outlined,
size: 11,
color: isOverdue
? AppColors.cancelled
: AppColors.textMuted),
const SizedBox(width: 3),
Text(
_fmt(job.dueDate!),
style: TextStyle(
fontSize: 11,
color: isOverdue
? AppColors.cancelled
: AppColors.textMuted,
fontWeight: isOverdue
? FontWeight.w600
: FontWeight.normal,
),
),
],
],
),
],
),
),
),
const Padding(
padding: EdgeInsets.only(right: 10),
child: Icon(Icons.chevron_right,
color: AppColors.textMuted, size: 20),
),
],
),
),
),
),
),
);
}
String _fmt(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
Color _statusColor(JobStatus status) {
switch (status) {
case JobStatus.pending:
return AppColors.pending;
case JobStatus.inProgress:
return AppColors.inProgress;
case JobStatus.sent:
return AppColors.accent;
case JobStatus.delivered:
return AppColors.success;
case JobStatus.cancelled:
return AppColors.cancelled;
}
}
}