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
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user