8bbc9dbff2
- 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
571 lines
17 KiB
Dart
571 lines
17 KiB
Dart
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/router/app_router.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 'clinic_jobs_repository.dart';
|
||
|
||
enum _JobSort { newestFirst, oldestFirst, byDueDate, byType }
|
||
|
||
const _kSortLabels = [
|
||
'Yeniden Eskiye',
|
||
'Eskiden Yeniye',
|
||
'Vade Tarihine Göre',
|
||
'Türe Göre',
|
||
];
|
||
|
||
class ClinicJobsScreen extends ConsumerStatefulWidget {
|
||
const ClinicJobsScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<ClinicJobsScreen> createState() => _ClinicJobsScreenState();
|
||
}
|
||
|
||
class _ClinicJobsScreenState extends ConsumerState<ClinicJobsScreen>
|
||
with SingleTickerProviderStateMixin {
|
||
late TabController _tabController;
|
||
final _searchController = TextEditingController();
|
||
String _searchQuery = '';
|
||
_JobSort _sort = _JobSort.newestFirst;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_tabController = TabController(length: 5, vsync: this);
|
||
_tabController.addListener(() {
|
||
if (mounted) setState(() {});
|
||
});
|
||
}
|
||
|
||
@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]);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isSortActive = _sort != _JobSort.newestFirst;
|
||
|
||
return Scaffold(
|
||
backgroundColor: AppColors.background,
|
||
appBar: GradientAppBar(
|
||
title: 'İşlerim',
|
||
category: 'KLİNİK',
|
||
searchController: _searchController,
|
||
onSearchChanged: _onSearchChanged,
|
||
searchHint: 'Protokol, laboratuvar veya tür ara...',
|
||
actions: [
|
||
IconButton(
|
||
onPressed: _showSortOptions,
|
||
tooltip: 'Sırala',
|
||
icon: Badge(
|
||
isLabelVisible: isSortActive,
|
||
smallSize: 8,
|
||
backgroundColor: AppColors.accent,
|
||
child: const Icon(Icons.sort_rounded),
|
||
),
|
||
),
|
||
if (ref.watch(authProvider).activeTenant?.canCreateJobs ?? true)
|
||
IconButton(
|
||
onPressed: () => context.push(routeClinicJobNew),
|
||
tooltip: 'Yeni İş',
|
||
icon: const Icon(Icons.add_rounded),
|
||
),
|
||
],
|
||
),
|
||
body: Column(
|
||
children: [
|
||
PillTabs(
|
||
tabs: const ['Tümü', 'Onay Bekleyen', 'Lab\'da', 'Teslimat', 'Teslim Alındı'],
|
||
selected: _tabController.index,
|
||
onSelect: (i) => _tabController.animateTo(i),
|
||
),
|
||
Expanded(
|
||
child: TabBarView(
|
||
controller: _tabController,
|
||
children: [
|
||
_JobsTab(
|
||
statuses: const ['pending', 'in_progress', 'sent', 'delivered'],
|
||
searchQuery: _searchQuery,
|
||
sort: _sort,
|
||
),
|
||
_JobsTab(
|
||
statuses: const ['in_progress'],
|
||
location: 'at_clinic',
|
||
searchQuery: _searchQuery,
|
||
sort: _sort,
|
||
),
|
||
_JobsTab(
|
||
filterExtra: 'status = "pending" || (status = "in_progress" && location = "at_lab")',
|
||
searchQuery: _searchQuery,
|
||
sort: _sort,
|
||
),
|
||
_JobsTab(
|
||
statuses: const ['sent'],
|
||
searchQuery: _searchQuery,
|
||
sort: _sort,
|
||
),
|
||
_JobsTab(
|
||
statuses: const ['delivered'],
|
||
searchQuery: _searchQuery,
|
||
sort: _sort,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _JobsTab extends ConsumerStatefulWidget {
|
||
const _JobsTab({
|
||
this.statuses,
|
||
this.location,
|
||
this.filterExtra,
|
||
required this.searchQuery,
|
||
required this.sort,
|
||
});
|
||
|
||
final List<String>? statuses;
|
||
final String? location;
|
||
final String? filterExtra;
|
||
final String searchQuery;
|
||
final _JobSort sort;
|
||
|
||
@override
|
||
ConsumerState<_JobsTab> createState() => _JobsTabState();
|
||
}
|
||
|
||
class _JobsTabState extends ConsumerState<_JobsTab> {
|
||
final List<Job> _jobs = [];
|
||
bool _isLoading = false;
|
||
bool _hasMore = true;
|
||
int _page = 1;
|
||
static const _limit = 20;
|
||
String? _error;
|
||
late UnsubFn _unsub;
|
||
|
||
final _scrollController = ScrollController();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
_scrollController.addListener(_onScroll);
|
||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||
_unsub = RealtimeService.instance.watch(
|
||
'jobs',
|
||
filter: 'clinic_tenant_id="$tenantId"',
|
||
onEvent: (_) { if (mounted) _load(); },
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_unsub();
|
||
_scrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onScroll() {
|
||
if (_scrollController.position.pixels >=
|
||
_scrollController.position.maxScrollExtent - 200 &&
|
||
!_isLoading &&
|
||
_hasMore) {
|
||
_loadMore();
|
||
}
|
||
}
|
||
|
||
Future<void> _load() async {
|
||
if (_isLoading) return;
|
||
setState(() {
|
||
_isLoading = true;
|
||
_error = null;
|
||
_page = 1;
|
||
_jobs.clear();
|
||
_hasMore = true;
|
||
});
|
||
await _fetch();
|
||
}
|
||
|
||
Future<void> _loadMore() async {
|
||
if (_isLoading || !_hasMore) return;
|
||
_page++;
|
||
await _fetch();
|
||
}
|
||
|
||
Future<void> _fetch() async {
|
||
setState(() => _isLoading = true);
|
||
try {
|
||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||
final results = await ClinicJobsRepository.instance.listOutbound(
|
||
tenantId,
|
||
statuses: widget.statuses,
|
||
location: widget.location,
|
||
filterExtra: widget.filterExtra,
|
||
page: _page,
|
||
limit: _limit,
|
||
);
|
||
setState(() {
|
||
_jobs.addAll(results);
|
||
_hasMore = results.length == _limit;
|
||
_isLoading = false;
|
||
});
|
||
} catch (e) {
|
||
setState(() {
|
||
_error = e.toString();
|
||
_isLoading = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
List<Job> get _filtered {
|
||
var list = _jobs.toList();
|
||
|
||
final q = widget.searchQuery.toLowerCase().trim();
|
||
if (q.isNotEmpty) {
|
||
list = list.where((j) {
|
||
return j.patientCode.toLowerCase().contains(q) ||
|
||
(j.labName?.toLowerCase().contains(q) ?? false) ||
|
||
j.prostheticType.label.toLowerCase().contains(q);
|
||
}).toList();
|
||
}
|
||
|
||
switch (widget.sort) {
|
||
case _JobSort.newestFirst:
|
||
list.sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
|
||
case _JobSort.oldestFirst:
|
||
list.sort((a, b) => a.dateCreated.compareTo(b.dateCreated));
|
||
case _JobSort.byDueDate:
|
||
list.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:
|
||
list.sort(
|
||
(a, b) => a.prostheticType.label.compareTo(b.prostheticType.label));
|
||
}
|
||
return list;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_error != null && _jobs.isEmpty) {
|
||
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: $_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'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
if (_isLoading && _jobs.isEmpty) {
|
||
return const Center(
|
||
child: CircularProgressIndicator(color: AppColors.accent));
|
||
}
|
||
|
||
final filtered = _filtered;
|
||
|
||
if (filtered.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 RefreshIndicator(
|
||
color: AppColors.accent,
|
||
onRefresh: _load,
|
||
child: ListView.builder(
|
||
controller: _scrollController,
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||
itemCount:
|
||
filtered.length + (_hasMore && widget.searchQuery.isEmpty ? 1 : 0),
|
||
itemBuilder: (context, index) {
|
||
if (index == filtered.length) {
|
||
return const Padding(
|
||
padding: EdgeInsets.all(16),
|
||
child: Center(
|
||
child: CircularProgressIndicator(color: AppColors.accent)),
|
||
);
|
||
}
|
||
final job = filtered[index];
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: _JobListCard(
|
||
job: job,
|
||
onTap: () => context.push('/clinic/jobs/${job.id}'),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _JobListCard extends StatelessWidget {
|
||
const _JobListCard({required this.job, required this.onTap});
|
||
|
||
final Job job;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final statusColor = _statusColor(job);
|
||
final statusBg = _statusBg(job);
|
||
final isOverdue =
|
||
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
|
||
|
||
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(
|
||
padding: const EdgeInsets.all(14),
|
||
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: Row(
|
||
children: [
|
||
Container(
|
||
width: 44,
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
color: statusBg,
|
||
borderRadius: BorderRadius.circular(12)),
|
||
child: Icon(Icons.medical_services_outlined,
|
||
color: statusColor, size: 20),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
job.patientCode,
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary),
|
||
),
|
||
),
|
||
_StatusBadge(status: job.status, location: job.location),
|
||
],
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(job.prostheticType.label,
|
||
style: const TextStyle(
|
||
fontSize: 12, color: AppColors.textSecondary)),
|
||
if (job.labName != null) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
job.labName!,
|
||
style: const TextStyle(
|
||
fontSize: 12, color: AppColors.textMuted),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
if (job.dueDate != null) ...[
|
||
const SizedBox(height: 2),
|
||
Row(
|
||
children: [
|
||
Icon(Icons.calendar_today_outlined,
|
||
size: 11,
|
||
color: isOverdue
|
||
? AppColors.cancelled
|
||
: AppColors.textMuted),
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
_fmt(job.dueDate!),
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: isOverdue
|
||
? AppColors.cancelled
|
||
: AppColors.textMuted,
|
||
fontWeight: isOverdue
|
||
? FontWeight.w600
|
||
: FontWeight.normal,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
const 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(Job job) {
|
||
if (job.status == JobStatus.inProgress && job.location == JobLocation.atClinic) return AppColors.pending;
|
||
switch (job.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;
|
||
}
|
||
}
|
||
|
||
Color _statusBg(Job job) {
|
||
if (job.status == JobStatus.inProgress && job.location == JobLocation.atClinic) return AppColors.pendingBg;
|
||
switch (job.status) {
|
||
case JobStatus.pending: return AppColors.pendingBg;
|
||
case JobStatus.inProgress: return AppColors.inProgressBg;
|
||
case JobStatus.sent: return AppColors.inProgressBg;
|
||
case JobStatus.delivered: return AppColors.successBg;
|
||
case JobStatus.cancelled: return AppColors.cancelledBg;
|
||
}
|
||
}
|
||
}
|
||
|
||
class _StatusBadge extends StatelessWidget {
|
||
const _StatusBadge({required this.status, required this.location});
|
||
|
||
final JobStatus status;
|
||
final JobLocation location;
|
||
|
||
String get _label {
|
||
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return 'Onay Bekliyor';
|
||
if (status == JobStatus.sent) return 'Teslimat Bekliyor';
|
||
return status.label;
|
||
}
|
||
|
||
Color get _color {
|
||
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return AppColors.pending;
|
||
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;
|
||
}
|
||
}
|
||
|
||
Color get _bg {
|
||
if (status == JobStatus.inProgress && location == JobLocation.atClinic) return AppColors.pendingBg;
|
||
switch (status) {
|
||
case JobStatus.pending: return AppColors.pendingBg;
|
||
case JobStatus.inProgress: return AppColors.inProgressBg;
|
||
case JobStatus.sent: return AppColors.inProgressBg;
|
||
case JobStatus.delivered: return AppColors.successBg;
|
||
case JobStatus.cancelled: return AppColors.cancelledBg;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: _bg,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
_label,
|
||
style: TextStyle(
|
||
color: _color,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|