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,581 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/connection.dart';
|
||||
import '../../../models/job.dart';
|
||||
import 'connection_stats_repository.dart';
|
||||
|
||||
class ConnectionDetailScreen extends StatefulWidget {
|
||||
const ConnectionDetailScreen({
|
||||
super.key,
|
||||
required this.connection,
|
||||
required this.labTenantId,
|
||||
});
|
||||
|
||||
final Connection connection;
|
||||
final String labTenantId;
|
||||
|
||||
@override
|
||||
State<ConnectionDetailScreen> createState() => _ConnectionDetailScreenState();
|
||||
}
|
||||
|
||||
class _ConnectionDetailScreenState extends State<ConnectionDetailScreen> {
|
||||
late Future<ConnectionStats> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
setState(() {
|
||||
_future = ConnectionStatsRepository.instance.fetchStats(
|
||||
labTenantId: widget.labTenantId,
|
||||
clinicTenantId: widget.connection.clinicTenantId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final conn = widget.connection;
|
||||
final clinicName = conn.clinicName ?? 'Klinik';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: 140,
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF0F172A), AppColors.primary],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 48, 20, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: const Icon(Icons.local_hospital_outlined, color: Colors.white, size: 26),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
clinicName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
_StatusBadge(status: conn.status),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: FutureBuilder<ConnectionStats>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 80),
|
||||
child: Center(child: CircularProgressIndicator(color: AppColors.accent)),
|
||||
);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text('Hata: ${snap.error}', style: const TextStyle(color: AppColors.textSecondary), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 16),
|
||||
label: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final stats = snap.data!;
|
||||
return _StatsBody(stats: stats);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats body ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _StatsBody extends StatelessWidget {
|
||||
const _StatsBody({required this.stats});
|
||||
final ConnectionStats stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// KPI row
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _KpiCard(label: 'Toplam İş', value: '${stats.totalJobs}', icon: Icons.work_outline_rounded, color: AppColors.accent)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _KpiCard(label: 'Aktif', value: '${stats.activeJobs}', icon: Icons.pending_outlined, color: AppColors.inProgress)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _KpiCard(label: 'Teslim', value: '${stats.deliveredJobs}', icon: Icons.check_circle_outline, color: AppColors.success)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _KpiCard(label: 'Bu Ay', value: '${stats.thisMonthJobs}', icon: Icons.calendar_month_outlined, color: AppColors.accent)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _KpiCard(label: 'Geçen Ay', value: '${stats.lastMonthJobs}', icon: Icons.history_rounded, color: AppColors.textSecondary)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _KpiCard(label: 'İptal', value: '${stats.cancelledJobs}', icon: Icons.cancel_outlined, color: AppColors.cancelled)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Revenue
|
||||
_SectionTitle('Finans'),
|
||||
const SizedBox(height: 8),
|
||||
_RevenueCard(stats: stats),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Prosthetic type breakdown
|
||||
if (stats.byType.isNotEmpty) ...[
|
||||
_SectionTitle('Ürün Tipi Dağılımı'),
|
||||
const SizedBox(height: 8),
|
||||
_TypeBreakdownCard(byType: stats.byType, total: stats.totalJobs),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Monthly trend
|
||||
_SectionTitle('Aylık Karşılaştırma'),
|
||||
const SizedBox(height: 8),
|
||||
_MonthCompareCard(stats: stats),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Recent jobs
|
||||
if (stats.recentJobs.isNotEmpty) ...[
|
||||
_SectionTitle('Son İşler'),
|
||||
const SizedBox(height: 8),
|
||||
_RecentJobsCard(jobs: stats.recentJobs),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── KPI card ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _KpiCard extends StatelessWidget {
|
||||
const _KpiCard({required this.label, required this.value, required this.icon, required this.color});
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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.03), blurRadius: 6, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(height: 8),
|
||||
Text(value, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, color: color)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Revenue card ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _RevenueCard extends StatelessWidget {
|
||||
const _RevenueCard({required this.stats});
|
||||
final ConnectionStats stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final collected = stats.totalRevenue - stats.pendingRevenue;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_RevRow(
|
||||
label: 'Toplam Gelir',
|
||||
value: _fmt(stats.totalRevenue),
|
||||
color: AppColors.textPrimary,
|
||||
bold: true,
|
||||
),
|
||||
const Divider(height: 20),
|
||||
_RevRow(label: 'Tahsil Edilen', value: _fmt(collected), color: AppColors.success),
|
||||
const SizedBox(height: 8),
|
||||
_RevRow(label: 'Bekleyen Alacak', value: _fmt(stats.pendingRevenue), color: AppColors.pending),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmt(double v) => '${v.toStringAsFixed(0)} TL';
|
||||
}
|
||||
|
||||
class _RevRow extends StatelessWidget {
|
||||
const _RevRow({required this.label, required this.value, required this.color, this.bold = false});
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
final bool bold;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = TextStyle(
|
||||
fontSize: bold ? 15 : 14,
|
||||
fontWeight: bold ? FontWeight.w700 : FontWeight.w500,
|
||||
color: color,
|
||||
);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: style.copyWith(color: bold ? AppColors.textPrimary : AppColors.textSecondary)),
|
||||
Text(value, style: style),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Type breakdown ────────────────────────────────────────────────────────────
|
||||
|
||||
class _TypeBreakdownCard extends StatelessWidget {
|
||||
const _TypeBreakdownCard({required this.byType, required this.total});
|
||||
final Map<String, int> byType;
|
||||
final int total;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < sorted.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 10),
|
||||
_TypeBar(
|
||||
label: _typeLabel(sorted[i].key),
|
||||
count: sorted[i].value,
|
||||
total: total,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _typeLabel(String key) => switch (key) {
|
||||
'metal_porselen' => 'Metal Porselen',
|
||||
'zirkonyum' => 'Zirkonyum',
|
||||
'implant_ustu_zirkonyum'=> 'İmplant Üstü Zirkonyum',
|
||||
'gecici' => 'Geçici',
|
||||
'e_max' => 'E-Max',
|
||||
'tam_protez' => 'Tam Protez',
|
||||
'parsiyel' => 'Parsiyel Protez',
|
||||
_ => 'Diğer',
|
||||
};
|
||||
}
|
||||
|
||||
class _TypeBar extends StatelessWidget {
|
||||
const _TypeBar({required this.label, required this.count, required this.total});
|
||||
final String label;
|
||||
final int count;
|
||||
final int total;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pct = total > 0 ? count / total : 0.0;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
|
||||
Text('$count adet · ${(pct * 100).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: pct,
|
||||
minHeight: 7,
|
||||
backgroundColor: AppColors.border,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monthly compare ───────────────────────────────────────────────────────────
|
||||
|
||||
class _MonthCompareCard extends StatelessWidget {
|
||||
const _MonthCompareCard({required this.stats});
|
||||
final ConnectionStats stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thisMonth = stats.thisMonthJobs;
|
||||
final lastMonth = stats.lastMonthJobs;
|
||||
final maxBar = (thisMonth > lastMonth ? thisMonth : lastMonth).clamp(1, 999);
|
||||
final trend = lastMonth == 0
|
||||
? null
|
||||
: ((thisMonth - lastMonth) / lastMonth * 100).toStringAsFixed(0);
|
||||
final isUp = thisMonth >= lastMonth;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trend != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
|
||||
size: 18,
|
||||
color: isUp ? AppColors.success : AppColors.cancelled,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isUp ? '+$trend% geçen aya göre' : '$trend% geçen aya göre',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isUp ? AppColors.success : AppColors.cancelled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_BarColumn(label: 'Geçen Ay', count: lastMonth, maxCount: maxBar, color: AppColors.border),
|
||||
const SizedBox(width: 16),
|
||||
_BarColumn(label: 'Bu Ay', count: thisMonth, maxCount: maxBar, color: AppColors.accent),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BarColumn extends StatelessWidget {
|
||||
const _BarColumn({required this.label, required this.count, required this.maxCount, required this.color});
|
||||
final String label;
|
||||
final int count;
|
||||
final int maxCount;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final height = maxCount > 0 ? (count / maxCount * 80).clamp(4.0, 80.0) : 4.0;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text('$count', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: color == AppColors.border ? AppColors.textSecondary : AppColors.accent)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 40,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recent jobs ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _RecentJobsCard extends StatelessWidget {
|
||||
const _RecentJobsCard({required this.jobs});
|
||||
final List<Job> jobs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < jobs.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 1),
|
||||
_RecentJobRow(job: jobs[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentJobRow extends StatelessWidget {
|
||||
const _RecentJobRow({required this.job});
|
||||
final Job job;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = job.status == JobStatus.delivered ? AppColors.success : AppColors.inProgress;
|
||||
final bg = job.status == JobStatus.delivered ? AppColors.successBg : AppColors.inProgressBg;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(10)),
|
||||
child: Center(child: Icon(Icons.assignment_outlined, size: 18, color: color)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(job.patientCode, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
Text(job.prostheticType.label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
|
||||
child: Text(job.status.label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
const _SectionTitle(this.text);
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(text, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textMuted, letterSpacing: 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status});
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color) = switch (status) {
|
||||
ConnectionStatus.approved => ('Onaylı', AppColors.success),
|
||||
ConnectionStatus.pending => ('Bekliyor', AppColors.pending),
|
||||
ConnectionStatus.rejected => ('Reddedildi', AppColors.cancelled),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/job.dart';
|
||||
|
||||
class ConnectionStats {
|
||||
const ConnectionStats({
|
||||
required this.totalJobs,
|
||||
required this.byStatus,
|
||||
required this.byType,
|
||||
required this.totalRevenue,
|
||||
required this.pendingRevenue,
|
||||
required this.thisMonthJobs,
|
||||
required this.lastMonthJobs,
|
||||
required this.revisionCount,
|
||||
required this.recentJobs,
|
||||
});
|
||||
|
||||
final int totalJobs;
|
||||
final Map<String, int> byStatus; // 'in_progress', 'sent', 'delivered', 'cancelled'
|
||||
final Map<String, int> byType; // prosthetic_type -> count
|
||||
final double totalRevenue;
|
||||
final double pendingRevenue;
|
||||
final int thisMonthJobs;
|
||||
final int lastMonthJobs;
|
||||
final int revisionCount;
|
||||
final List<Job> recentJobs;
|
||||
|
||||
int get deliveredJobs => byStatus['delivered'] ?? 0;
|
||||
int get activeJobs => (byStatus['in_progress'] ?? 0) + (byStatus['sent'] ?? 0);
|
||||
int get cancelledJobs => byStatus['cancelled'] ?? 0;
|
||||
double get revisionRate => totalJobs > 0 ? revisionCount / totalJobs * 100 : 0;
|
||||
double get completionRate => totalJobs > 0 ? deliveredJobs / totalJobs * 100 : 0;
|
||||
}
|
||||
|
||||
class ConnectionStatsRepository {
|
||||
ConnectionStatsRepository._();
|
||||
static final instance = ConnectionStatsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<ConnectionStats> fetchStats({
|
||||
required String labTenantId,
|
||||
required String clinicTenantId,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
final thisMonthStart = DateTime(now.year, now.month, 1);
|
||||
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
|
||||
final lastMonthEnd = thisMonthStart.subtract(const Duration(seconds: 1));
|
||||
|
||||
final filter = 'lab_tenant_id = "$labTenantId" && clinic_tenant_id = "$clinicTenantId"';
|
||||
|
||||
final results = await Future.wait([
|
||||
_pb.collection('jobs').getList(
|
||||
filter: filter,
|
||||
perPage: 500,
|
||||
expand: 'clinic_tenant_id,lab_tenant_id',
|
||||
),
|
||||
_pb.collection('finance_entries').getList(
|
||||
filter: 'tenant_id = "$labTenantId" && job_id.clinic_tenant_id = "$clinicTenantId"',
|
||||
perPage: 500,
|
||||
),
|
||||
]);
|
||||
|
||||
final jobsResult = results[0];
|
||||
final financeResult = results[1];
|
||||
|
||||
final allJobs = jobsResult.items
|
||||
.map((r) => Job.fromJson(r.toJson()))
|
||||
.toList();
|
||||
|
||||
// Status breakdown
|
||||
final byStatus = <String, int>{};
|
||||
final byType = <String, int>{};
|
||||
int thisMonthJobs = 0;
|
||||
int lastMonthJobs = 0;
|
||||
int revisionCount = 0;
|
||||
|
||||
for (final job in allJobs) {
|
||||
// Status
|
||||
final s = job.status.value;
|
||||
byStatus[s] = (byStatus[s] ?? 0) + 1;
|
||||
|
||||
// Type
|
||||
final t = job.prostheticType.value;
|
||||
byType[t] = (byType[t] ?? 0) + 1;
|
||||
|
||||
// Monthly
|
||||
final created = job.dateCreated;
|
||||
if (created.isAfter(thisMonthStart)) thisMonthJobs++;
|
||||
else if (created.isAfter(lastMonthStart) && created.isBefore(lastMonthEnd)) lastMonthJobs++;
|
||||
|
||||
// Revision
|
||||
if (job.status == JobStatus.inProgress && job.currentStep == null) revisionCount++;
|
||||
}
|
||||
|
||||
// Finance
|
||||
double totalRevenue = 0;
|
||||
double pendingRevenue = 0;
|
||||
for (final r in financeResult.items) {
|
||||
final j = r.toJson();
|
||||
final amount = (j['amount'] as num?)?.toDouble() ?? 0;
|
||||
totalRevenue += amount;
|
||||
if (j['status'] == 'pending') pendingRevenue += amount;
|
||||
}
|
||||
|
||||
// Recent jobs (last 5 delivered or sent)
|
||||
final recent = allJobs
|
||||
.where((j) => j.status == JobStatus.delivered || j.status == JobStatus.sent)
|
||||
.toList()
|
||||
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated));
|
||||
|
||||
return ConnectionStats(
|
||||
totalJobs: allJobs.length,
|
||||
byStatus: byStatus,
|
||||
byType: byType,
|
||||
totalRevenue: totalRevenue,
|
||||
pendingRevenue: pendingRevenue,
|
||||
thisMonthJobs: thisMonthJobs,
|
||||
lastMonthJobs: lastMonthJobs,
|
||||
revisionCount: revisionCount,
|
||||
recentJobs: recent.take(5).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/connection.dart';
|
||||
|
||||
class LabConnectionsRepository {
|
||||
LabConnectionsRepository._();
|
||||
static final instance = LabConnectionsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<Connection>> listConnections(String labTenantId) async {
|
||||
final result = await _pb.collection('connections').getList(
|
||||
filter: 'lab_tenant_id = "$labTenantId"',
|
||||
expand: 'clinic_tenant_id,lab_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
return (result.items.map((r) => Connection.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
|
||||
Future<Connection> respondToRequest({
|
||||
required String connectionId,
|
||||
required bool approve,
|
||||
}) async {
|
||||
final record = await _pb.collection('connections').update(connectionId, body: {
|
||||
'status': approve ? 'approved' : 'rejected',
|
||||
});
|
||||
return Connection.fromJson(record.toJson());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../models/connection.dart';
|
||||
import 'connection_detail_screen.dart';
|
||||
import 'lab_connections_repository.dart';
|
||||
|
||||
class LabConnectionsScreen extends ConsumerStatefulWidget {
|
||||
const LabConnectionsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LabConnectionsScreen> createState() =>
|
||||
_LabConnectionsScreenState();
|
||||
}
|
||||
|
||||
class _LabConnectionsScreenState extends ConsumerState<LabConnectionsScreen> {
|
||||
late Future<List<Connection>> _future;
|
||||
final _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = LabConnectionsRepository.instance.listConnections(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _respond(String connectionId, bool approve) async {
|
||||
try {
|
||||
await LabConnectionsRepository.instance.respondToRequest(
|
||||
connectionId: connectionId,
|
||||
approve: approve,
|
||||
);
|
||||
_load();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
approve ? 'Bağlantı onaylandı' : 'Bağlantı reddedildi'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Color _statusColor(ConnectionStatus status) {
|
||||
return switch (status) {
|
||||
ConnectionStatus.pending => AppColors.pending,
|
||||
ConnectionStatus.approved => AppColors.success,
|
||||
ConnectionStatus.rejected => AppColors.cancelled,
|
||||
};
|
||||
}
|
||||
|
||||
Color _statusBg(ConnectionStatus status) {
|
||||
return switch (status) {
|
||||
ConnectionStatus.pending => AppColors.pendingBg,
|
||||
ConnectionStatus.approved => AppColors.successBg,
|
||||
ConnectionStatus.rejected => AppColors.cancelledBg,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'Bağlantılar',
|
||||
category: 'LABORATUVAR',
|
||||
searchController: _searchController,
|
||||
onSearchChanged: (v) => setState(() => _searchQuery = v),
|
||||
searchHint: 'Klinik adı ara...',
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<Connection>>(
|
||||
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 allConnections = snap.data!;
|
||||
final q = _searchQuery.toLowerCase().trim();
|
||||
final connections = q.isEmpty
|
||||
? allConnections
|
||||
: allConnections.where((c) =>
|
||||
(c.clinicName ?? '').toLowerCase().contains(q)).toList();
|
||||
|
||||
if (allConnections.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.link_off,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Henüz bağlantı yok',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Kliniklerden gelen istekler burada görünür.',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 13),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final pending = connections
|
||||
.where((c) => c.status == ConnectionStatus.pending)
|
||||
.toList();
|
||||
final others = connections
|
||||
.where((c) => c.status != ConnectionStatus.pending)
|
||||
.toList();
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
children: [
|
||||
if (pending.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
label: 'Bekleyen İstekler',
|
||||
count: pending.length,
|
||||
countColor: AppColors.pending,
|
||||
countBg: AppColors.pendingBg,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...pending.map((c) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _ConnectionCard(
|
||||
connection: c,
|
||||
statusColor: _statusColor(c.status),
|
||||
statusBg: _statusBg(c.status),
|
||||
onApprove: () => _respond(c.id, true),
|
||||
onReject: () => _respond(c.id, false),
|
||||
),
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (others.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
label: 'Bağlantılar',
|
||||
count: others.length,
|
||||
countColor: AppColors.textSecondary,
|
||||
countBg: AppColors.surfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...others.map((c) {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _ConnectionCard(
|
||||
connection: c,
|
||||
statusColor: _statusColor(c.status),
|
||||
statusBg: _statusBg(c.status),
|
||||
onTap: c.status == ConnectionStatus.approved
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ConnectionDetailScreen(
|
||||
connection: c,
|
||||
labTenantId: tenantId,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.countColor,
|
||||
required this.countBg,
|
||||
});
|
||||
final String label;
|
||||
final int count;
|
||||
final Color countColor;
|
||||
final Color countBg;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: countBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
color: countColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionCard extends StatefulWidget {
|
||||
const _ConnectionCard({
|
||||
required this.connection,
|
||||
required this.statusColor,
|
||||
required this.statusBg,
|
||||
this.onApprove,
|
||||
this.onReject,
|
||||
this.onTap,
|
||||
});
|
||||
final Connection connection;
|
||||
final Color statusColor;
|
||||
final Color statusBg;
|
||||
final VoidCallback? onApprove;
|
||||
final VoidCallback? onReject;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
State<_ConnectionCard> createState() => _ConnectionCardState();
|
||||
}
|
||||
|
||||
class _ConnectionCardState extends State<_ConnectionCard> {
|
||||
bool _loading = false;
|
||||
|
||||
Future<void> _act(VoidCallback? cb) async {
|
||||
if (cb == null) return;
|
||||
setState(() => _loading = true);
|
||||
cb();
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
String _formatDate(String? raw) {
|
||||
if (raw == null) return '';
|
||||
try {
|
||||
final dt = DateTime.parse(raw);
|
||||
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = widget.connection;
|
||||
final isPending = c.status == ConnectionStatus.pending;
|
||||
|
||||
return Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: widget.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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(Icons.business_outlined,
|
||||
color: widget.statusColor, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
c.clinicName ?? 'Klinik',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
if (c.dateCreated != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatDate(c.dateCreated),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.statusBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
c.status.label,
|
||||
style: TextStyle(
|
||||
color: widget.statusColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.onTap != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right_rounded,
|
||||
size: 18, color: AppColors.textMuted),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (isPending) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed:
|
||||
_loading ? null : () => _act(widget.onReject),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.cancelled,
|
||||
side: const BorderSide(color: AppColors.cancelled)),
|
||||
child: const Text('Reddet'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed:
|
||||
_loading ? null : () => _act(widget.onApprove),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.success),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text('Onayla'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_animate/flutter_animate.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/theme/app_theme.dart';
|
||||
import '../../../core/widgets/tooth_logo.dart';
|
||||
import '../../../core/services/realtime_service.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../jobs/lab_jobs_repository.dart';
|
||||
|
||||
class LabDashboardScreen extends ConsumerStatefulWidget {
|
||||
const LabDashboardScreen({super.key});
|
||||
@override
|
||||
ConsumerState<LabDashboardScreen> createState() => _LabDashboardScreenState();
|
||||
}
|
||||
|
||||
class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
late Future<_DashboardData> _future;
|
||||
bool _acceptingAll = false;
|
||||
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;
|
||||
final now = DateTime.now();
|
||||
final thisMonthStart = DateTime(now.year, now.month, 1);
|
||||
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
|
||||
setState(() {
|
||||
_future = Future.wait([
|
||||
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.countDelivered(tenantId, from: thisMonthStart),
|
||||
LabJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart),
|
||||
]).then((r) {
|
||||
final jobs = r[0] as List<List<Job>>;
|
||||
return _DashboardData(
|
||||
pendingJobs: jobs[0],
|
||||
inProgressJobs: jobs[1],
|
||||
atLabJobs: jobs[2],
|
||||
atClinicJobs: jobs[3],
|
||||
sentCount: jobs[4].length,
|
||||
deliveredCount: jobs[5].length,
|
||||
thisMonthDelivered: r[1] as int,
|
||||
lastMonthDelivered: r[2] as int,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _bulkAccept() async {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() => _acceptingAll = true);
|
||||
try {
|
||||
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
|
||||
_load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e'), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _acceptingAll = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final companyName = ref.watch(authProvider).activeTenant?.tenant.companyName ?? '';
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const maxContent = 1040.0;
|
||||
final hPad = constraints.maxWidth > maxContent
|
||||
? (constraints.maxWidth - maxContent) / 2
|
||||
: 16.0;
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<_DashboardData>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
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;
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
_DashboardHeader(companyName: companyName),
|
||||
if (isDesktop)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _StatsRow(
|
||||
pending: data.pendingJobs.length,
|
||||
inProgress: data.inProgressJobs.length,
|
||||
sent: data.sentCount,
|
||||
delivered: data.deliveredCount,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (data.pendingJobs.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _AcceptAllBanner(
|
||||
count: data.pendingJobs.length,
|
||||
loading: _acceptingAll,
|
||||
onTap: _bulkAccept,
|
||||
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0),
|
||||
),
|
||||
),
|
||||
if (isDesktop) ...[
|
||||
SliverPadding(
|
||||
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),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
// ── Yapılacaklar (at_lab) ────────────────────────────
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
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)),
|
||||
child: const Text('Tümünü Gör'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (data.atLabJobs.isEmpty)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: _EmptySection(message: 'Yapılacak iş yok'),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
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),
|
||||
),
|
||||
),
|
||||
// ── Klinikte Onay Bekliyor (at_clinic) ───────────────
|
||||
if (data.atClinicJobs.isNotEmpty) ...[
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||
sliver: SliverToBoxAdapter(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashboardHeader extends StatelessWidget {
|
||||
const _DashboardHeader({required this.companyName});
|
||||
final String companyName;
|
||||
|
||||
// Must stay in sync with _DesktopSidebar.headerHeight in app_router.dart
|
||||
static const double _desktopToolbarHeight = 64;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
|
||||
if (isDesktop) {
|
||||
return SliverAppBar(
|
||||
pinned: true,
|
||||
toolbarHeight: _desktopToolbarHeight,
|
||||
backgroundColor: AppColors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: false,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go(routeLabSettings),
|
||||
icon: const Icon(Icons.settings_outlined, color: AppColors.textSecondary, size: 22),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: 148,
|
||||
backgroundColor: AppColors.primary,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
centerTitle: false,
|
||||
leadingWidth: 60,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Center(child: ToothLogo(size: 20, color: Colors.white)),
|
||||
),
|
||||
),
|
||||
titleSpacing: 8,
|
||||
title: Column(
|
||||
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),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go(routeLabSettings),
|
||||
icon: const Icon(Icons.settings_outlined, color: Colors.white, size: 22),
|
||||
),
|
||||
],
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.primary, AppColors.accent],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||
child: Column(
|
||||
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)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('Bugünkü Durum', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsRow extends StatelessWidget {
|
||||
const _StatsRow({
|
||||
required this.pending,
|
||||
required this.inProgress,
|
||||
required this.sent,
|
||||
required this.delivered,
|
||||
});
|
||||
final int pending;
|
||||
final int inProgress;
|
||||
final int sent;
|
||||
final int delivered;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: pendingCard),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: inProgressCard),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: sentCard),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: deliveredCard),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: pendingCard),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: inProgressCard),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
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;
|
||||
final Color color;
|
||||
final Color bgColor;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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.06), blurRadius: 12, offset: const Offset(0, 4))],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
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)),
|
||||
const SizedBox(height: 3),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AcceptAllBanner extends StatelessWidget {
|
||||
const _AcceptAllBanner({required this.count, required this.loading, required this.onTap});
|
||||
final int count;
|
||||
final bool loading;
|
||||
final VoidCallback onTap;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.pendingBg,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: loading ? null : onTap,
|
||||
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))),
|
||||
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),
|
||||
),
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading
|
||||
? 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JobCard extends StatelessWidget {
|
||||
const _JobCard({required this.job});
|
||||
final Job job;
|
||||
@override
|
||||
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;
|
||||
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}'),
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tag extends StatelessWidget {
|
||||
const _Tag({required this.label, required this.color, required this.bg});
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color bg;
|
||||
@override
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _EmptySection extends StatelessWidget {
|
||||
const _EmptySection({required this.message});
|
||||
final String message;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorBody extends StatelessWidget {
|
||||
const _ErrorBody({required this.onRetry});
|
||||
final VoidCallback onRetry;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
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),
|
||||
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')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashboardSkeleton extends StatelessWidget {
|
||||
const _DashboardSkeleton({required this.companyName, required this.hPad});
|
||||
final String companyName;
|
||||
final double hPad;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
_DashboardHeader(companyName: companyName),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0),
|
||||
sliver: const SliverToBoxAdapter(
|
||||
child: Row(children: [
|
||||
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
|
||||
SizedBox(width: 12),
|
||||
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShimmerBox extends StatefulWidget {
|
||||
const _ShimmerBox({required this.height, required this.radius});
|
||||
final double height;
|
||||
final double radius;
|
||||
@override
|
||||
State<_ShimmerBox> createState() => _ShimmerBoxState();
|
||||
}
|
||||
|
||||
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);
|
||||
_anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut);
|
||||
}
|
||||
@override
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monthly Report ──────────────────────────────────────────────────────────
|
||||
|
||||
class _MonthlyReportSection extends StatelessWidget {
|
||||
const _MonthlyReportSection({required this.data});
|
||||
final _DashboardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pct = data.changePercent;
|
||||
final isUp = pct >= 0;
|
||||
final pctStr = '${isUp ? '+' : ''}${pct.toStringAsFixed(0)}%';
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
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)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MonthStat(
|
||||
label: 'Bu Ay',
|
||||
value: data.thisMonthDelivered,
|
||||
highlighted: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _MonthStat(
|
||||
label: 'Geçen Ay',
|
||||
value: data.lastMonthDelivered,
|
||||
highlighted: false,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isUp ? AppColors.successBg : AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
|
||||
size: 16,
|
||||
color: isUp ? AppColors.success : AppColors.cancelled,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
pctStr,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isUp ? AppColors.success : AppColors.cancelled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthStat extends StatelessWidget {
|
||||
const _MonthStat({required this.label, required this.value, required this.highlighted});
|
||||
final String label;
|
||||
final int value;
|
||||
final bool highlighted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
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,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$value iş',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: highlighted ? AppColors.accent : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gamification Row ─────────────────────────────────────────────────────────
|
||||
|
||||
const _monthlyGoal = 50;
|
||||
|
||||
class _GamificationRow extends StatelessWidget {
|
||||
const _GamificationRow({required this.data});
|
||||
final _DashboardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0);
|
||||
final remaining = (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
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)),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'${data.points} puan',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 8,
|
||||
backgroundColor: AppColors.background,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
progress >= 1.0 ? AppColors.success : AppColors.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi',
|
||||
style: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data Model ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _DashboardData {
|
||||
final List<Job> pendingJobs;
|
||||
final List<Job> inProgressJobs;
|
||||
final List<Job> atLabJobs;
|
||||
final List<Job> atClinicJobs;
|
||||
final int sentCount;
|
||||
final int deliveredCount;
|
||||
final int thisMonthDelivered;
|
||||
final int lastMonthDelivered;
|
||||
const _DashboardData({
|
||||
required this.pendingJobs,
|
||||
required this.inProgressJobs,
|
||||
required this.atLabJobs,
|
||||
required this.atClinicJobs,
|
||||
required this.sentCount,
|
||||
required this.deliveredCount,
|
||||
required this.thisMonthDelivered,
|
||||
required this.lastMonthDelivered,
|
||||
});
|
||||
|
||||
int get points => thisMonthDelivered * 10;
|
||||
double get changePercent => lastMonthDelivered == 0
|
||||
? (thisMonthDelivered > 0 ? 100 : 0)
|
||||
: (thisMonthDelivered - lastMonthDelivered) / lastMonthDelivered * 100;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/clinic_discount.dart';
|
||||
|
||||
class DiscountRepository {
|
||||
DiscountRepository._();
|
||||
static final instance = DiscountRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<ClinicDiscount>> listDiscounts(String labTenantId) async {
|
||||
final result = await _pb.collection('clinic_discounts').getList(
|
||||
filter: 'lab_tenant_id = "$labTenantId"',
|
||||
expand: 'clinic_tenant_id',
|
||||
perPage: 200,
|
||||
);
|
||||
final list = result.items
|
||||
.map((r) => ClinicDiscount.fromJson(r.toJson()))
|
||||
.toList();
|
||||
list.sort((a, b) {
|
||||
// Active first, then by clinic name
|
||||
if (a.isActive != b.isActive) return a.isActive ? -1 : 1;
|
||||
final ca = a.clinicName ?? '';
|
||||
final cb = b.clinicName ?? '';
|
||||
return ca.compareTo(cb);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<ClinicDiscount> createDiscount({
|
||||
required String labTenantId,
|
||||
String? clinicTenantId,
|
||||
String? prostheticType,
|
||||
required DiscountType discountType,
|
||||
required double discountValue,
|
||||
int minQuantity = 0,
|
||||
bool isActive = true,
|
||||
String? notes,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'lab_tenant_id': labTenantId,
|
||||
'discount_type': discountType.value,
|
||||
'discount_value': discountValue,
|
||||
'is_active': isActive,
|
||||
};
|
||||
if (clinicTenantId != null && clinicTenantId.isNotEmpty) {
|
||||
body['clinic_tenant_id'] = clinicTenantId;
|
||||
}
|
||||
if (prostheticType != null && prostheticType.isNotEmpty) {
|
||||
body['prosthetic_type'] = prostheticType;
|
||||
}
|
||||
if (minQuantity > 0) body['min_quantity'] = minQuantity;
|
||||
if (notes != null && notes.isNotEmpty) body['notes'] = notes;
|
||||
|
||||
final record = await _pb.collection('clinic_discounts').create(
|
||||
body: body,
|
||||
expand: 'clinic_tenant_id',
|
||||
);
|
||||
return ClinicDiscount.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<ClinicDiscount> updateDiscount(
|
||||
String id, {
|
||||
String? clinicTenantId,
|
||||
String? prostheticType,
|
||||
DiscountType? discountType,
|
||||
double? discountValue,
|
||||
int? minQuantity,
|
||||
bool? isActive,
|
||||
String? notes,
|
||||
}) async {
|
||||
final body = <String, dynamic>{};
|
||||
if (clinicTenantId != null) body['clinic_tenant_id'] = clinicTenantId.isEmpty ? null : clinicTenantId;
|
||||
if (prostheticType != null) body['prosthetic_type'] = prostheticType.isEmpty ? '' : prostheticType;
|
||||
if (discountType != null) body['discount_type'] = discountType.value;
|
||||
if (discountValue != null) body['discount_value'] = discountValue;
|
||||
if (minQuantity != null) body['min_quantity'] = minQuantity;
|
||||
if (isActive != null) body['is_active'] = isActive;
|
||||
if (notes != null) body['notes'] = notes;
|
||||
|
||||
final record = await _pb.collection('clinic_discounts').update(
|
||||
id,
|
||||
body: body,
|
||||
expand: 'clinic_tenant_id',
|
||||
);
|
||||
return ClinicDiscount.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<void> deleteDiscount(String id) async {
|
||||
await _pb.collection('clinic_discounts').delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../models/clinic_discount.dart';
|
||||
import 'discount_repository.dart';
|
||||
|
||||
// Simple local record for clinic picker
|
||||
class _ClinicOption {
|
||||
const _ClinicOption({required this.id, required this.name});
|
||||
final String id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class DiscountsScreen extends ConsumerStatefulWidget {
|
||||
const DiscountsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DiscountsScreen> createState() => _DiscountsScreenState();
|
||||
}
|
||||
|
||||
class _DiscountsScreenState extends ConsumerState<DiscountsScreen> {
|
||||
late Future<List<ClinicDiscount>> _future;
|
||||
String _searchQuery = '';
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = DiscountRepository.instance.listDiscounts(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showSheet({ClinicDiscount? existing}) async {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final result = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _DiscountSheet(
|
||||
labTenantId: tenantId,
|
||||
existing: existing,
|
||||
),
|
||||
);
|
||||
if (result == true) _load();
|
||||
}
|
||||
|
||||
Future<void> _delete(ClinicDiscount discount) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('İndirimi Sil'),
|
||||
content: Text(
|
||||
'${discount.clinicName ?? 'Tüm Klinikler'} — ${discount.displayValue} indirimi silinsin mi?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal')),
|
||||
FilledButton(
|
||||
style:
|
||||
FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Sil'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await DiscountRepository.instance.deleteDiscount(discount.id);
|
||||
_load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Hata: $e'),
|
||||
backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleActive(ClinicDiscount discount) async {
|
||||
try {
|
||||
await DiscountRepository.instance
|
||||
.updateDiscount(discount.id, isActive: !discount.isActive);
|
||||
_load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Hata: $e'),
|
||||
backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'İndirimler',
|
||||
category: 'LABORATUVAR',
|
||||
searchController: _searchController,
|
||||
onSearchChanged: (v) => setState(() => _searchQuery = v),
|
||||
searchHint: 'Klinik veya ürün tipi ara...',
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _showSheet(),
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: Colors.white,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Yeni İndirim'),
|
||||
),
|
||||
body: FutureBuilder<List<ClinicDiscount>>(
|
||||
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: [
|
||||
const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
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: 16),
|
||||
label: const Text('Tekrar Dene')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final allDiscounts = snap.data!;
|
||||
final q = _searchQuery.toLowerCase().trim();
|
||||
final discounts = q.isEmpty
|
||||
? allDiscounts
|
||||
: allDiscounts
|
||||
.where((d) =>
|
||||
(d.clinicName ?? 'tüm klinikler')
|
||||
.toLowerCase()
|
||||
.contains(q) ||
|
||||
d.prostheticLabel.toLowerCase().contains(q))
|
||||
.toList();
|
||||
|
||||
if (allDiscounts.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.discount_outlined,
|
||||
size: 32, color: AppColors.inProgress),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Henüz indirim tanımlanmadı',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Klinik ve ürün bazlı özel indirimler ekleyin.',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 13),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _showSheet(),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('İlk İndirimi Ekle'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (discounts.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Sonuç bulunamadı',
|
||||
style: TextStyle(color: AppColors.textSecondary)),
|
||||
);
|
||||
}
|
||||
|
||||
final active = discounts.where((d) => d.isActive).toList();
|
||||
final inactive = discounts.where((d) => !d.isActive).toList();
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
|
||||
children: [
|
||||
if (active.isNotEmpty) ...[
|
||||
_GroupHeader('Aktif (${active.length})'),
|
||||
for (final d in active)
|
||||
_DiscountCard(
|
||||
discount: d,
|
||||
onEdit: () => _showSheet(existing: d),
|
||||
onDelete: () => _delete(d),
|
||||
onToggle: () => _toggleActive(d),
|
||||
),
|
||||
],
|
||||
if (inactive.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
_GroupHeader('Pasif (${inactive.length})'),
|
||||
for (final d in inactive)
|
||||
_DiscountCard(
|
||||
discount: d,
|
||||
onEdit: () => _showSheet(existing: d),
|
||||
onDelete: () => _delete(d),
|
||||
onToggle: () => _toggleActive(d),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group header ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _GroupHeader extends StatelessWidget {
|
||||
const _GroupHeader(this.text);
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8, top: 4),
|
||||
child: Text(text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textMuted,
|
||||
letterSpacing: 0.5)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Discount card ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _DiscountCard extends StatelessWidget {
|
||||
const _DiscountCard({
|
||||
required this.discount,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
final ClinicDiscount discount;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final d = discount;
|
||||
final isActive = d.isActive;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Material(
|
||||
color: isActive ? AppColors.surface : AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onEdit,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isActive ? AppColors.border : AppColors.muted),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2))
|
||||
]
|
||||
: [],
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
isActive ? AppColors.success : AppColors.border,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(14),
|
||||
bottomLeft: Radius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? AppColors.successBg
|
||||
: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${d.displayValue} İndirim',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isActive
|
||||
? AppColors.success
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (d.minQuantity > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pendingBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'≥${d.minQuantity} adet',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.pending),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
Transform.scale(
|
||||
scale: 0.8,
|
||||
child: Switch(
|
||||
value: isActive,
|
||||
onChanged: (_) => onToggle(),
|
||||
activeColor: AppColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_Tag(
|
||||
icon: Icons.local_hospital_outlined,
|
||||
label: d.appliesToAll
|
||||
? 'Tüm Klinikler'
|
||||
: (d.clinicName ?? 'Klinik'),
|
||||
color: AppColors.inProgress,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_Tag(
|
||||
icon: Icons.science_outlined,
|
||||
label: d.prostheticLabel,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (d.notes != null && d.notes!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(d.notes!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Icons.delete_outline_rounded,
|
||||
size: 18, color: AppColors.cancelled),
|
||||
tooltip: 'Sil',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tag extends StatelessWidget {
|
||||
const _Tag(
|
||||
{required this.icon, required this.label, required this.color});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 11, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Discount sheet ────────────────────────────────────────────────────────────
|
||||
|
||||
class _DiscountSheet extends StatefulWidget {
|
||||
const _DiscountSheet({required this.labTenantId, this.existing});
|
||||
final String labTenantId;
|
||||
final ClinicDiscount? existing;
|
||||
|
||||
@override
|
||||
State<_DiscountSheet> createState() => _DiscountSheetState();
|
||||
}
|
||||
|
||||
class _DiscountSheetState extends State<_DiscountSheet> {
|
||||
final _valueCtrl = TextEditingController();
|
||||
final _minQtyCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
|
||||
DiscountType _discountType = DiscountType.percentage;
|
||||
String? _selectedClinicId;
|
||||
String? _selectedType;
|
||||
bool _isActive = true;
|
||||
bool _saving = false;
|
||||
|
||||
List<_ClinicOption>? _clinics;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final e = widget.existing;
|
||||
if (e != null) {
|
||||
_valueCtrl.text = e.discountValue.toStringAsFixed(
|
||||
e.discountValue % 1 == 0 ? 0 : 2);
|
||||
_minQtyCtrl.text =
|
||||
e.minQuantity > 0 ? e.minQuantity.toString() : '';
|
||||
_notesCtrl.text = e.notes ?? '';
|
||||
_discountType = e.discountType;
|
||||
_selectedClinicId = e.clinicTenantId;
|
||||
_selectedType = e.prostheticType;
|
||||
_isActive = e.isActive;
|
||||
}
|
||||
_loadClinics();
|
||||
}
|
||||
|
||||
Future<void> _loadClinics() async {
|
||||
try {
|
||||
final pb = PocketBaseClient.instance.pb;
|
||||
final result = await pb.collection('tenant_connections').getList(
|
||||
filter:
|
||||
'lab_tenant_id = "${widget.labTenantId}" && status = "approved"',
|
||||
expand: 'clinic_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
final clinics = result.items.map((r) {
|
||||
final j = r.toJson();
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final clinic =
|
||||
expand?['clinic_tenant_id'] as Map<String, dynamic>?;
|
||||
return _ClinicOption(
|
||||
id: j['clinic_tenant_id'] as String? ?? '',
|
||||
name: clinic?['company_name'] as String? ?? 'Klinik',
|
||||
);
|
||||
}).where((c) => c.id.isNotEmpty).toList();
|
||||
if (mounted) setState(() => _clinics = clinics);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _clinics = []);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_valueCtrl.dispose();
|
||||
_minQtyCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final valueStr = _valueCtrl.text.trim().replaceAll(',', '.');
|
||||
final value = double.tryParse(valueStr);
|
||||
if (value == null || value <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Geçerli bir indirim değeri girin.'),
|
||||
backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_discountType == DiscountType.percentage && value > 100) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Yüzde indirim 100'ü geçemez."),
|
||||
backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final minQty = int.tryParse(_minQtyCtrl.text.trim()) ?? 0;
|
||||
setState(() => _saving = true);
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
if (widget.existing != null) {
|
||||
await DiscountRepository.instance.updateDiscount(
|
||||
widget.existing!.id,
|
||||
clinicTenantId: _selectedClinicId ?? '',
|
||||
prostheticType: _selectedType ?? '',
|
||||
discountType: _discountType,
|
||||
discountValue: value,
|
||||
minQuantity: minQty,
|
||||
isActive: _isActive,
|
||||
notes: _notesCtrl.text.trim(),
|
||||
);
|
||||
} else {
|
||||
await DiscountRepository.instance.createDiscount(
|
||||
labTenantId: widget.labTenantId,
|
||||
clinicTenantId: _selectedClinicId,
|
||||
prostheticType: _selectedType,
|
||||
discountType: _discountType,
|
||||
discountValue: value,
|
||||
minQuantity: minQty,
|
||||
isActive: _isActive,
|
||||
notes: _notesCtrl.text.trim(),
|
||||
);
|
||||
}
|
||||
navigator.pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Hata: $e'),
|
||||
backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const _prostheticTypes = [
|
||||
('', 'Tüm Türler'),
|
||||
('metal_porselen', 'Metal Porselen'),
|
||||
('zirkonyum', 'Zirkonyum'),
|
||||
('implant_ustu_zirkonyum', 'İmplant Üstü Zirkonyum'),
|
||||
('gecici', 'Geçici'),
|
||||
('e_max', 'E-Max'),
|
||||
('tam_protez', 'Tam Protez'),
|
||||
('parsiyel', 'Parsiyel Protez'),
|
||||
('diger', 'Diğer'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.paddingOf(context).bottom;
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: bottom),
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom + 20,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.existing != null
|
||||
? 'İndirimi Düzenle'
|
||||
: 'Yeni İndirim',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
const Text('İndirim Türü',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: 'Yüzde (%)',
|
||||
icon: Icons.percent_rounded,
|
||||
selected: _discountType == DiscountType.percentage,
|
||||
onTap: () => setState(
|
||||
() => _discountType = DiscountType.percentage),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: 'Sabit Tutar',
|
||||
icon: Icons.currency_lira_rounded,
|
||||
selected: _discountType == DiscountType.fixed,
|
||||
onTap: () =>
|
||||
setState(() => _discountType = DiscountType.fixed),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text('İndirim Değeri',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _valueCtrl,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
hintText: _discountType == DiscountType.percentage
|
||||
? 'Örn: 10'
|
||||
: 'Örn: 150',
|
||||
suffixText:
|
||||
_discountType == DiscountType.percentage ? '%' : 'TL',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text('Klinik',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
_ClinicDropdown(
|
||||
selectedId: _selectedClinicId,
|
||||
clinics: _clinics,
|
||||
onChanged: (id, _) => setState(() {
|
||||
_selectedClinicId = id;
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text('Ürün Tipi',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedType ?? '',
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 12),
|
||||
),
|
||||
items: _prostheticTypes
|
||||
.map((t) =>
|
||||
DropdownMenuItem(value: t.$1, child: Text(t.$2)))
|
||||
.toList(),
|
||||
onChanged: (v) =>
|
||||
setState(() => _selectedType = v == '' ? null : v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text('Minimum Sipariş Adedi (İsteğe Bağlı)',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Aylık bu adede ulaşılınca indirim devreye girer. 0 = koşulsuz.',
|
||||
style:
|
||||
TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _minQtyCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
hintText: '0',
|
||||
suffixText: 'adet',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text('Not (İsteğe Bağlı)',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _notesCtrl,
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Açıklama...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Aktif',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
Text('Pasif indirimler uygulanmaz.',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: _isActive,
|
||||
onChanged: (v) => setState(() => _isActive = v),
|
||||
activeColor: AppColors.success,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.accent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: Text(
|
||||
widget.existing != null ? 'Güncelle' : 'Kaydet',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeButton extends StatelessWidget {
|
||||
const _TypeButton({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.accent : AppColors.background,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: selected ? AppColors.accent : AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon,
|
||||
size: 16,
|
||||
color: selected ? Colors.white : AppColors.textSecondary),
|
||||
const SizedBox(width: 6),
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selected
|
||||
? Colors.white
|
||||
: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClinicDropdown extends StatelessWidget {
|
||||
const _ClinicDropdown({
|
||||
required this.selectedId,
|
||||
required this.clinics,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String? selectedId;
|
||||
final List<_ClinicOption>? clinics;
|
||||
final void Function(String? id, String? name) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (clinics == null) {
|
||||
return Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
);
|
||||
}
|
||||
|
||||
final items = <DropdownMenuItem<String>>[
|
||||
const DropdownMenuItem(value: '', child: Text('Tüm Klinikler')),
|
||||
for (final c in clinics!)
|
||||
DropdownMenuItem(value: c.id, child: Text(c.name)),
|
||||
];
|
||||
|
||||
return DropdownButtonFormField<String>(
|
||||
value: selectedId ?? '',
|
||||
decoration: InputDecoration(
|
||||
border:
|
||||
OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
),
|
||||
items: items,
|
||||
onChanged: (v) {
|
||||
if (v == null || v.isEmpty) {
|
||||
onChanged(null, null);
|
||||
} else {
|
||||
final clinic = clinics!.firstWhere((c) => c.id == v);
|
||||
onChanged(v, clinic.name);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/finance_entry.dart';
|
||||
|
||||
class LabFinanceRepository {
|
||||
LabFinanceRepository._();
|
||||
static final instance = LabFinanceRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<FinanceEntry>> listEntries(
|
||||
String tenantId, {
|
||||
String? status,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['tenant_id = "$tenantId"', 'type = "receivable"'];
|
||||
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',
|
||||
);
|
||||
return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
|
||||
Future<Map<String, double>> summary(String tenantId) async {
|
||||
final all = await listEntries(tenantId, limit: 200);
|
||||
double pending = 0, paid = 0;
|
||||
for (final e in all) {
|
||||
if (e.status == FinanceStatus.pending) {
|
||||
pending += e.amount;
|
||||
} else {
|
||||
paid += e.amount;
|
||||
}
|
||||
}
|
||||
return {'pending': pending, 'paid': paid};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/providers/locale_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/currency_formatter.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../core/widgets/pill_tabs.dart';
|
||||
import '../../../models/finance_entry.dart';
|
||||
import 'lab_finance_repository.dart';
|
||||
|
||||
enum _FinanceSort { newestFirst, byAmountDesc, byAmountAsc }
|
||||
|
||||
class LabFinanceScreen extends ConsumerStatefulWidget {
|
||||
const LabFinanceScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LabFinanceScreen> createState() => _LabFinanceScreenState();
|
||||
}
|
||||
|
||||
class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late Future<_FinanceData> _future;
|
||||
_FinanceSort _sort = _FinanceSort.newestFirst;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = Future.wait([
|
||||
LabFinanceRepository.instance.listEntries(tenantId, status: 'pending'),
|
||||
LabFinanceRepository.instance.listEntries(tenantId, status: 'paid'),
|
||||
LabFinanceRepository.instance.summary(tenantId),
|
||||
]).then((results) => _FinanceData(
|
||||
pending: results[0] as List<FinanceEntry>,
|
||||
paid: results[1] as List<FinanceEntry>,
|
||||
summary: results[2] as Map<String, double>,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showSortOptions() async {
|
||||
final s = ref.read(stringsProvider);
|
||||
final result = await showSortSheet(
|
||||
context,
|
||||
title: s.sort,
|
||||
options: [s.sortNewest, s.sortAmountDesc, s.sortAmountAsc],
|
||||
current: _sort.index,
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() => _sort = _FinanceSort.values[result]);
|
||||
}
|
||||
}
|
||||
|
||||
List<FinanceEntry> _sorted(List<FinanceEntry> entries) {
|
||||
final list = List<FinanceEntry>.from(entries);
|
||||
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;
|
||||
if (da == null && db == null) return 0;
|
||||
if (da == null) return 1;
|
||||
if (db == null) return -1;
|
||||
return db.compareTo(da);
|
||||
});
|
||||
case _FinanceSort.byAmountDesc:
|
||||
list.sort((a, b) => b.amount.compareTo(a.amount));
|
||||
case _FinanceSort.byAmountAsc:
|
||||
list.sort((a, b) => a.amount.compareTo(b.amount));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
String _formatDate(String? raw) {
|
||||
if (raw == null) return '';
|
||||
try {
|
||||
final dt = DateTime.parse(raw);
|
||||
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _FinanceSort.newestFirst;
|
||||
final s = ref.watch(stringsProvider);
|
||||
final currencyCode =
|
||||
ref.watch(authProvider).activeTenant?.tenant.defaultCurrency ?? 'TRY';
|
||||
String formatAmount(double amount) =>
|
||||
CurrencyFormatter.format(amount, currencyCode);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: s.finance,
|
||||
category: s.laboratoryCategory,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _showSortOptions,
|
||||
tooltip: 'Sırala',
|
||||
icon: Badge(
|
||||
isLabelVisible: isSortActive,
|
||||
smallSize: 8,
|
||||
backgroundColor: AppColors.accent,
|
||||
child: const Icon(Icons.sort_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<_FinanceData>(
|
||||
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 data = snap.data!;
|
||||
final pendingTotal = data.summary['pending'] ?? 0.0;
|
||||
final paidTotal = data.summary['paid'] ?? 0.0;
|
||||
final pending = _sorted(data.pending);
|
||||
final paid = _sorted(data.paid);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.pendingReceivable,
|
||||
amount: formatAmount(pendingTotal),
|
||||
color: AppColors.pending,
|
||||
bgColor: AppColors.pendingBg,
|
||||
icon: Icons.hourglass_empty_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.collected,
|
||||
amount: formatAmount(paidTotal),
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successBg,
|
||||
icon: Icons.check_circle_outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PillTabs(
|
||||
tabs: [s.pending, s.collected],
|
||||
selected: _tabController.index,
|
||||
onSelect: (i) => _tabController.animateTo(i),
|
||||
counts: [pending.length, paid.length],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_EntriesList(
|
||||
entries: pending,
|
||||
emptyMessage: s.noPendingEntries,
|
||||
emptyIcon: Icons.hourglass_empty_rounded,
|
||||
formatDate: _formatDate,
|
||||
formatAmount: formatAmount,
|
||||
),
|
||||
_EntriesList(
|
||||
entries: paid,
|
||||
emptyMessage: s.noPaidEntries,
|
||||
emptyIcon: Icons.check_circle_outline,
|
||||
formatDate: _formatDate,
|
||||
formatAmount: formatAmount,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FinanceData {
|
||||
const _FinanceData({
|
||||
required this.pending,
|
||||
required this.paid,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
final List<FinanceEntry> pending;
|
||||
final List<FinanceEntry> paid;
|
||||
final Map<String, double> summary;
|
||||
}
|
||||
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
const _SummaryCard({
|
||||
required this.label,
|
||||
required this.amount,
|
||||
required this.color,
|
||||
required this.bgColor,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String amount;
|
||||
final Color color;
|
||||
final Color bgColor;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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.06),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor, borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
amount,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: color,
|
||||
height: 1),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EntriesList extends StatelessWidget {
|
||||
const _EntriesList({
|
||||
required this.entries,
|
||||
required this.emptyMessage,
|
||||
required this.emptyIcon,
|
||||
required this.formatDate,
|
||||
required this.formatAmount,
|
||||
});
|
||||
|
||||
final List<FinanceEntry> entries;
|
||||
final String emptyMessage;
|
||||
final IconData emptyIcon;
|
||||
final String Function(String?) formatDate;
|
||||
final String Function(double) formatAmount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (entries.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: Icon(emptyIcon, size: 32, color: AppColors.inProgress),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(emptyMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
itemCount: entries.length,
|
||||
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;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
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))
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
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 '../../../models/job.dart';
|
||||
import '../../../models/job_file.dart';
|
||||
import '../../../features/shared/job_files_repository.dart';
|
||||
import '../../../features/shared/job_files_panel.dart';
|
||||
import '../../../core/services/job_history_service.dart';
|
||||
import 'lab_jobs_repository.dart';
|
||||
|
||||
// ── Adaptive sheet helper ────────────────────────────────────────────────────
|
||||
|
||||
void _showAdaptive(BuildContext context, Widget content) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
if (isDesktop) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LabJobDetailScreen extends ConsumerStatefulWidget {
|
||||
const LabJobDetailScreen({super.key, required this.jobId});
|
||||
final String jobId;
|
||||
|
||||
@override
|
||||
ConsumerState<LabJobDetailScreen> createState() => _LabJobDetailScreenState();
|
||||
}
|
||||
|
||||
class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
||||
Job? _job;
|
||||
bool _loadingJob = false;
|
||||
String? _loadError;
|
||||
bool _isActing = false;
|
||||
late Future<List<JobFile>> _filesFuture;
|
||||
late UnsubFn _unsub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_loadFiles();
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
topic: widget.jobId,
|
||||
onEvent: (_) { if (mounted && !_isActing) _load(); },
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() { _loadingJob = true; _loadError = null; });
|
||||
try {
|
||||
final job = await LabJobsRepository.instance.getJob(widget.jobId);
|
||||
if (mounted) setState(() { _job = job; _loadingJob = false; });
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loadError = e.toString(); _loadingJob = false; });
|
||||
}
|
||||
}
|
||||
|
||||
void _loadFiles() {
|
||||
setState(() {
|
||||
_filesFuture = JobFilesRepository.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?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('İptal Et'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _isActing = true);
|
||||
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.')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _acceptJob(Job job) async {
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await LabJobsRepository.instance.acceptJob(job);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş kabul edildi')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showHandToClinicSheet(Job job) {
|
||||
_showAdaptive(
|
||||
context,
|
||||
_HandToClinicSheet(
|
||||
job: job,
|
||||
onDone: (Job updated) {
|
||||
if (mounted) setState(() => _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _statusColor(JobStatus status) {
|
||||
return switch (status) {
|
||||
JobStatus.pending => AppColors.pending,
|
||||
JobStatus.inProgress => AppColors.inProgress,
|
||||
JobStatus.sent => AppColors.accent,
|
||||
JobStatus.delivered => AppColors.success,
|
||||
JobStatus.cancelled => AppColors.cancelled,
|
||||
};
|
||||
}
|
||||
|
||||
Color _statusBg(JobStatus status) {
|
||||
return switch (status) {
|
||||
JobStatus.pending => AppColors.pendingBg,
|
||||
JobStatus.inProgress => AppColors.inProgressBg,
|
||||
JobStatus.sent => AppColors.inProgressBg,
|
||||
JobStatus.delivered => AppColors.successBg,
|
||||
JobStatus.cancelled => AppColors.cancelledBg,
|
||||
};
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt, {bool withTime = false}) {
|
||||
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')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('İş Detayı'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loadingJob && _job == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (_loadError != null && _job == null) {
|
||||
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: $_loadError',
|
||||
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 (_job == null) return const SizedBox.shrink();
|
||||
|
||||
{
|
||||
final job = _job!;
|
||||
final membership = ref.read(authProvider).activeTenant;
|
||||
final isDeliveryOnly = membership?.isDeliveryOnly ?? false;
|
||||
final canCancelJobs = membership?.canCancelJobs ?? true;
|
||||
final canSendToClinic = !isDeliveryOnly &&
|
||||
job.status == JobStatus.inProgress &&
|
||||
job.location == JobLocation.atLab;
|
||||
final canAccept = !isDeliveryOnly && job.status == JobStatus.pending;
|
||||
|
||||
return ListView(
|
||||
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.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 ?? '-'),
|
||||
_InfoRow(
|
||||
icon: Icons.medical_services_outlined,
|
||||
label: 'Protez Tipi',
|
||||
value: job.prostheticType.label),
|
||||
_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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
JobFilesPanel(
|
||||
job: job,
|
||||
filesFuture: _filesFuture,
|
||||
onRefresh: _loadFiles,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hand to Clinic Sheet ─────────────────────────────────────────────────────
|
||||
|
||||
class _HandToClinicSheet extends StatefulWidget {
|
||||
const _HandToClinicSheet({required this.job, required this.onDone});
|
||||
final Job job;
|
||||
final void Function(Job updatedJob) onDone;
|
||||
|
||||
@override
|
||||
State<_HandToClinicSheet> createState() => _HandToClinicSheetState();
|
||||
}
|
||||
|
||||
class _HandToClinicSheetState extends State<_HandToClinicSheet> {
|
||||
final _noteController = TextEditingController();
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_noteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
final isLast = widget.job.isLastStep;
|
||||
final stepLabel = widget.job.currentStep?.label ?? '';
|
||||
final buttonLabel = isLast
|
||||
? (widget.job.provaRequired ? 'Son Prova · Teslime Gönder' : 'Teslime Gönder')
|
||||
: '$stepLabel için Kliniğe Gönder';
|
||||
final buttonColor = isLast ? AppColors.success : AppColors.inProgress;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isDesktop ? Radius.zero : const Radius.circular(20),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 24,
|
||||
bottom: isDesktop
|
||||
? 24
|
||||
: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
buttonLabel,
|
||||
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.',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _noteController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Not (isteğe bağlı)',
|
||||
hintText: 'Klinik için not ekleyin...',
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _sending
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _sending = true);
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final updated = await LabJobsRepository.instance.handToClinic(
|
||||
widget.job.id,
|
||||
widget.job,
|
||||
note: _noteController.text.trim().isEmpty
|
||||
? null
|
||||
: _noteController.text.trim(),
|
||||
);
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isLast
|
||||
? 'İş teslim için gönderildi'
|
||||
: 'Prova için klinik\'e gönderildi')),
|
||||
);
|
||||
if (context.mounted) widget.onDone(updated);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
setState(() => _sending = false);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
backgroundColor: buttonColor,
|
||||
),
|
||||
child: _sending
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: Text(buttonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Info Row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.valueColor,
|
||||
});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppColors.textMuted),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: valueColor ?? AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Job Stepper ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _JobStepper extends StatelessWidget {
|
||||
const _JobStepper({
|
||||
required this.steps,
|
||||
required this.currentStep,
|
||||
required this.historyFuture,
|
||||
});
|
||||
final List<JobStep> steps;
|
||||
final JobStep? currentStep;
|
||||
final Future<List<JobHistoryEntry>> historyFuture;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<JobHistoryEntry>>(
|
||||
future: historyFuture,
|
||||
builder: (ctx, snap) {
|
||||
final history = snap.data ?? [];
|
||||
// Revizyon sayısı per adım
|
||||
final Map<JobStep, int> revisionCounts = {};
|
||||
for (final e in history) {
|
||||
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
|
||||
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
final currentIndex =
|
||||
currentStep != null ? steps.indexOf(currentStep!) : -1;
|
||||
|
||||
return Column(
|
||||
children: List.generate(steps.length, (i) {
|
||||
final step = steps[i];
|
||||
final isCompleted = i < currentIndex;
|
||||
final isCurrent = i == currentIndex;
|
||||
final isLastItem = i == steps.length - 1;
|
||||
final revCount = revisionCounts[step] ?? 0;
|
||||
|
||||
Color dotColor;
|
||||
IconData dotIcon;
|
||||
if (isCompleted) {
|
||||
dotColor = AppColors.success;
|
||||
dotIcon = Icons.check_circle;
|
||||
} else if (isCurrent) {
|
||||
dotColor = AppColors.inProgress;
|
||||
dotIcon = Icons.radio_button_checked;
|
||||
} else {
|
||||
dotColor = AppColors.muted;
|
||||
dotIcon = Icons.radio_button_unchecked;
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Icon(dotIcon, color: dotColor, size: 24),
|
||||
if (!isLastItem)
|
||||
Container(
|
||||
width: 2,
|
||||
height: 44,
|
||||
color: i < currentIndex
|
||||
? AppColors.success.withValues(alpha: 0.35)
|
||||
: AppColors.border,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
step.label,
|
||||
style: TextStyle(
|
||||
fontWeight: isCurrent
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: isCompleted
|
||||
? AppColors.success
|
||||
: isCurrent
|
||||
? AppColors.inProgress
|
||||
: AppColors.textMuted,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
if (revCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'$revCount revizyon',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.cancelled,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (isCurrent)
|
||||
Text(
|
||||
step.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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 '../../../models/job.dart';
|
||||
import 'lab_jobs_repository.dart';
|
||||
|
||||
class LabJobsInboundScreen extends ConsumerStatefulWidget {
|
||||
const LabJobsInboundScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LabJobsInboundScreen> createState() =>
|
||||
_LabJobsInboundScreenState();
|
||||
}
|
||||
|
||||
class _LabJobsInboundScreenState extends ConsumerState<LabJobsInboundScreen> {
|
||||
late Future<List<Job>> _future;
|
||||
bool _acceptingAll = false;
|
||||
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: 'pending');
|
||||
});
|
||||
}
|
||||
|
||||
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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bulkAccept() async {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() => _acceptingAll = true);
|
||||
try {
|
||||
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
|
||||
_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(() => _acceptingAll = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'Gelen İşler',
|
||||
category: 'LABORATUVAR',
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _acceptingAll ? null : _bulkAccept,
|
||||
icon: _acceptingAll
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.done_all),
|
||||
label:
|
||||
Text(_acceptingAll ? 'Kabul ediliyor...' : 'Tümünü Kabul Et'),
|
||||
backgroundColor: AppColors.pending,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<Job>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Hata: ${snap.error}'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _load,
|
||||
child: const Text('Tekrar Dene'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final jobs = snap.data!;
|
||||
|
||||
if (jobs.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 64, color: AppColors.textMuted),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Bekleyen iş yok',
|
||||
style: TextStyle(
|
||||
fontSize: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
|
||||
itemCount: jobs.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final job = jobs[i];
|
||||
return _InboundJobCard(
|
||||
job: job,
|
||||
onAccept: () => _acceptJob(job),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InboundJobCard extends StatefulWidget {
|
||||
const _InboundJobCard({required this.job, required this.onAccept});
|
||||
final Job job;
|
||||
final VoidCallback onAccept;
|
||||
|
||||
@override
|
||||
State<_InboundJobCard> createState() => _InboundJobCardState();
|
||||
}
|
||||
|
||||
class _InboundJobCardState extends State<_InboundJobCard> {
|
||||
bool _accepting = false;
|
||||
|
||||
String _formatDate(DateTime dt) =>
|
||||
'${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final job = widget.job;
|
||||
return Semantics(
|
||||
label: job.patientCode,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: Dismissible(
|
||||
key: ValueKey(job.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check, color: Colors.white, size: 28),
|
||||
SizedBox(height: 4),
|
||||
Text('Kabul Et',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: AppColors.pendingBg,
|
||||
child: const Icon(Icons.assignment_outlined,
|
||||
color: AppColors.pending),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
job.patientCode,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
job.clinicName ?? 'Klinik',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
_Chip(
|
||||
label: job.prostheticType.label,
|
||||
color: AppColors.inProgressBg,
|
||||
textColor: AppColors.inProgress,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_Chip(
|
||||
label: '${job.memberCount} üye',
|
||||
color: AppColors.background,
|
||||
textColor: AppColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatDate(job.dateCreated),
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_accepting
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: FilledButton(
|
||||
onPressed: widget.onAccept,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.success,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('Kabul Et',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Chip extends StatelessWidget {
|
||||
const _Chip(
|
||||
{required this.label,
|
||||
required this.color,
|
||||
required this.textColor});
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color textColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'dart:async';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../core/services/job_history_service.dart';
|
||||
import '../../../models/job.dart';
|
||||
|
||||
const _listExpand = 'clinic_tenant_id,lab_tenant_id';
|
||||
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id';
|
||||
|
||||
class LabJobsRepository {
|
||||
LabJobsRepository._();
|
||||
static final instance = LabJobsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<Job>> listInbound(
|
||||
String labTenantId, {
|
||||
String? status,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['lab_tenant_id = "$labTenantId"'];
|
||||
if (status != null) filterParts.add('status = "$status"');
|
||||
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
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"'];
|
||||
if (location != null) filterParts.add('location = "$location"');
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
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) return 1;
|
||||
if (b.dueDate == null) return -1;
|
||||
final cmp = a.dueDate!.compareTo(b.dueDate!);
|
||||
return cmp != 0 ? cmp : b.dateCreated.compareTo(a.dateCreated);
|
||||
}));
|
||||
}
|
||||
|
||||
Future<Job> getJob(String jobId) async {
|
||||
final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand);
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<Job> acceptJob(Job pendingJob) async {
|
||||
final firstStep = pendingJob.stepTemplate.first;
|
||||
final record = await _pb.collection('jobs').update(pendingJob.id, body: {
|
||||
'status': 'in_progress',
|
||||
'current_step': firstStep.value,
|
||||
'location': 'at_lab',
|
||||
});
|
||||
final job = Job.fromJson(record.toJson());
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: pendingJob.id,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.accepted,
|
||||
step: firstStep,
|
||||
));
|
||||
return job;
|
||||
}
|
||||
|
||||
Future<Job> handToClinic(String jobId, Job job, {String? note}) async {
|
||||
final isFinal = job.currentStep == JobStep.cilaBitim;
|
||||
final patch = isFinal
|
||||
? {'status': 'sent', 'location': 'at_clinic'}
|
||||
: {'location': 'at_clinic'};
|
||||
|
||||
final record = await _pb.collection('jobs').update(jobId, body: patch);
|
||||
final updated = Job.fromJson(record.toJson());
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.handedToClinic,
|
||||
step: job.currentStep,
|
||||
note: note,
|
||||
));
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Job> cancelJob(String jobId, Job job) async {
|
||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||
'status': 'cancelled',
|
||||
});
|
||||
unawaited(JobHistoryService.instance.append(
|
||||
jobId: jobId,
|
||||
clinicTenantId: job.clinicTenantId,
|
||||
labTenantId: job.labTenantId,
|
||||
action: JobHistoryAction.cancelled,
|
||||
step: job.currentStep,
|
||||
));
|
||||
return Job.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<void> bulkAcceptPending(String labTenantId) async {
|
||||
final pending = await listInbound(labTenantId, status: 'pending', limit: 200);
|
||||
await Future.wait(pending.map((j) => acceptJob(j)));
|
||||
}
|
||||
|
||||
Future<int> countByStatus(String labTenantId, String? status) async {
|
||||
final filter = status != null
|
||||
? 'lab_tenant_id = "$labTenantId" && status = "$status"'
|
||||
: 'lab_tenant_id = "$labTenantId"';
|
||||
final r = await _pb.collection('jobs').getList(perPage: 1, filter: filter);
|
||||
return r.totalItems;
|
||||
}
|
||||
|
||||
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(' && '));
|
||||
return r.totalItems;
|
||||
}
|
||||
|
||||
static String _date(DateTime d) => d.toIso8601String().split('T').first;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/prosthetic_product.dart';
|
||||
|
||||
class LabProductsRepository {
|
||||
LabProductsRepository._();
|
||||
static final instance = LabProductsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<ProstheticProduct>> listProducts(
|
||||
String labTenantId, {
|
||||
bool? isActive,
|
||||
}) async {
|
||||
final filterParts = ['lab_tenant_id = "$labTenantId"'];
|
||||
if (isActive != null) filterParts.add('is_active = $isActive');
|
||||
|
||||
final result = await _pb.collection('prosthetic_products').getList(
|
||||
filter: filterParts.join(' && '),
|
||||
perPage: 200,
|
||||
);
|
||||
return (result.items.map((r) => ProstheticProduct.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => a.name.compareTo(b.name)));
|
||||
}
|
||||
|
||||
Future<ProstheticProduct> createProduct(ProstheticProduct product) async {
|
||||
final record = await _pb.collection('prosthetic_products').create(body: product.toJson());
|
||||
return ProstheticProduct.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<ProstheticProduct> updateProduct(String id, Map<String, dynamic> patch) async {
|
||||
final record = await _pb.collection('prosthetic_products').update(id, body: patch);
|
||||
return ProstheticProduct.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<void> deleteProduct(String id) async {
|
||||
await _pb.collection('prosthetic_products').delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../../models/prosthetic_product.dart';
|
||||
import 'lab_products_repository.dart';
|
||||
|
||||
const _prostheticTypes = [
|
||||
('metal_porselen', 'Metal Porselen'),
|
||||
('zirkonyum', 'Zirkonyum'),
|
||||
('implant_ustu_zirkonyum', 'İmplant Üstü Zirkonyum'),
|
||||
('gecici', 'Geçici'),
|
||||
('e_max', 'E-Max'),
|
||||
('diger', 'Diğer'),
|
||||
];
|
||||
|
||||
String _typeLabel(String value) {
|
||||
for (final t in _prostheticTypes) {
|
||||
if (t.$1 == value) return t.$2;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Adaptive sheet helper ────────────────────────────────────────────────────
|
||||
|
||||
void _showAdaptive(BuildContext context, Widget content) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
if (isDesktop) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LabProductsScreen extends ConsumerStatefulWidget {
|
||||
const LabProductsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LabProductsScreen> createState() => _LabProductsScreenState();
|
||||
}
|
||||
|
||||
class _LabProductsScreenState extends ConsumerState<LabProductsScreen> {
|
||||
late Future<List<ProstheticProduct>> _future;
|
||||
final _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = LabProductsRepository.instance.listProducts(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _toggleActive(ProstheticProduct product) async {
|
||||
try {
|
||||
await LabProductsRepository.instance
|
||||
.updateProduct(product.id, {'is_active': !product.isActive});
|
||||
_load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteProduct(ProstheticProduct product) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Ürünü Sil'),
|
||||
content: Text(
|
||||
'"${product.name}" ürününü silmek istediğinize emin misiniz?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('İptal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cancelled),
|
||||
child: const Text('Sil'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await LabProductsRepository.instance.deleteProduct(product.id);
|
||||
_load();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ürün silindi')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showProductSheet({ProstheticProduct? existing}) {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
_showAdaptive(
|
||||
context,
|
||||
_ProductForm(
|
||||
labTenantId: tenantId,
|
||||
existing: existing,
|
||||
onSaved: () {
|
||||
_load();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'Ürün Kataloğu',
|
||||
category: 'LABORATUVAR',
|
||||
searchController: _searchController,
|
||||
onSearchChanged: (v) => setState(() => _searchQuery = v),
|
||||
searchHint: 'Ürün adı veya türü ara...',
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _showProductSheet(),
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: Colors.white,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Yeni Ürün'),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.accent,
|
||||
onRefresh: () async => _load(),
|
||||
child: FutureBuilder<List<ProstheticProduct>>(
|
||||
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 allProducts = snap.data!;
|
||||
final q = _searchQuery.toLowerCase().trim();
|
||||
final products = q.isEmpty
|
||||
? allProducts
|
||||
: allProducts.where((p) =>
|
||||
p.name.toLowerCase().contains(q) ||
|
||||
_typeLabel(p.prostheticType).toLowerCase().contains(q)).toList();
|
||||
|
||||
if (allProducts.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.inventory_2_outlined,
|
||||
size: 32, color: AppColors.inProgress),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
q.isNotEmpty ? 'Sonuç bulunamadı' : 'Henüz ürün eklenmedi',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (q.isEmpty) FilledButton.icon(
|
||||
onPressed: () => _showProductSheet(),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('İlk Ürünü Ekle'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (products.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.search_off_rounded,
|
||||
size: 32, color: AppColors.inProgress),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Sonuç bulunamadı',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
|
||||
itemCount: products.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final product = products[i];
|
||||
final statusColor =
|
||||
product.isActive ? AppColors.inProgress : AppColors.textMuted;
|
||||
final statusBg =
|
||||
product.isActive ? AppColors.inProgressBg : AppColors.surfaceVariant;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: GestureDetector(
|
||||
onLongPress: () => _deleteProduct(product),
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: () => _showProductSheet(existing: product),
|
||||
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: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product.name,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: product.isActive
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textMuted),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_typeLabel(product.prostheticType),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary),
|
||||
),
|
||||
if (product.unitPrice != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${product.unitPrice!.toStringAsFixed(2)} ${product.currency ?? 'TRY'}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.success),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Switch(
|
||||
value: product.isActive,
|
||||
onChanged: (_) => _toggleActive(product),
|
||||
activeTrackColor: AppColors.accent,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined,
|
||||
color: AppColors.textSecondary,
|
||||
size: 20),
|
||||
onPressed: () =>
|
||||
_showProductSheet(existing: product),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Product Form ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _ProductForm extends StatefulWidget {
|
||||
const _ProductForm({
|
||||
required this.labTenantId,
|
||||
required this.onSaved,
|
||||
this.existing,
|
||||
});
|
||||
|
||||
final String labTenantId;
|
||||
final ProstheticProduct? existing;
|
||||
final VoidCallback onSaved;
|
||||
|
||||
@override
|
||||
State<_ProductForm> createState() => _ProductFormState();
|
||||
}
|
||||
|
||||
class _ProductFormState extends State<_ProductForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameCtrl;
|
||||
late final TextEditingController _priceCtrl;
|
||||
late final TextEditingController _descCtrl;
|
||||
late String _selectedType;
|
||||
late String _currency;
|
||||
late bool _isActive;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final p = widget.existing;
|
||||
_nameCtrl = TextEditingController(text: p?.name ?? '');
|
||||
_priceCtrl = TextEditingController(
|
||||
text: p?.unitPrice != null ? p!.unitPrice!.toString() : '');
|
||||
_descCtrl = TextEditingController(text: p?.description ?? '');
|
||||
_selectedType = p?.prostheticType ?? _prostheticTypes.first.$1;
|
||||
_currency = p?.currency ?? 'TRY';
|
||||
_isActive = p?.isActive ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_priceCtrl.dispose();
|
||||
_descCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _saving = true);
|
||||
|
||||
final price = double.tryParse(_priceCtrl.text.trim());
|
||||
final product = ProstheticProduct(
|
||||
id: widget.existing?.id ?? '',
|
||||
labTenantId: widget.labTenantId,
|
||||
name: _nameCtrl.text.trim(),
|
||||
prostheticType: _selectedType,
|
||||
unitPrice: price,
|
||||
currency: _currency,
|
||||
isActive: _isActive,
|
||||
description:
|
||||
_descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(),
|
||||
);
|
||||
|
||||
try {
|
||||
if (widget.existing != null) {
|
||||
await LabProductsRepository.instance.updateProduct(
|
||||
widget.existing!.id,
|
||||
product.toJson(),
|
||||
);
|
||||
} else {
|
||||
await LabProductsRepository.instance.createProduct(product);
|
||||
}
|
||||
widget.onSaved();
|
||||
// Pop using form's own context to ensure correct Navigator inside dialog/sheet
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
final isEdit = widget.existing != null;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isDesktop ? Radius.zero : const Radius.circular(20),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 24,
|
||||
bottom: isDesktop
|
||||
? 24
|
||||
: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
isEdit ? 'Ürünü Düzenle' : 'Yeni Ürün',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close,
|
||||
color: AppColors.textSecondary),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Name
|
||||
TextFormField(
|
||||
controller: _nameCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Ürün Adı *'),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Ürün adı gerekli' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Prosthetic type dropdown
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedType,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Protez Tipi *'),
|
||||
items: _prostheticTypes
|
||||
.map((t) => DropdownMenuItem(
|
||||
value: t.$1,
|
||||
child: Text(t.$2),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _selectedType = v!),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Price + currency row
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextFormField(
|
||||
controller: _priceCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Birim Fiyat'),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true),
|
||||
validator: (v) {
|
||||
if (v != null && v.isNotEmpty) {
|
||||
if (double.tryParse(v) == null) {
|
||||
return 'Geçerli fiyat girin';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _currency,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Para Birimi'),
|
||||
items: ['TRY', 'USD', 'EUR']
|
||||
.map((c) => DropdownMenuItem(
|
||||
value: c,
|
||||
child: Text(c),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _currency = v!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Description
|
||||
TextFormField(
|
||||
controller: _descCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Açıklama (isteğe bağlı)'),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Active toggle
|
||||
SwitchListTile(
|
||||
title: const Text('Aktif',
|
||||
style: TextStyle(color: AppColors.textPrimary)),
|
||||
value: _isActive,
|
||||
onChanged: (v) => setState(() => _isActive = v),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeTrackColor: AppColors.accent,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48)),
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: Text(isEdit ? 'Kaydet' : 'Ekle'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/l10n/app_strings.dart';
|
||||
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/tenant.dart';
|
||||
import '../../shared/tenant_team_screen.dart';
|
||||
import '../connections/lab_connections_screen.dart';
|
||||
|
||||
class LabSettingsScreen extends ConsumerWidget {
|
||||
const LabSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authProvider);
|
||||
final s = ref.watch(stringsProvider);
|
||||
final profile = auth.profile;
|
||||
final membership = auth.activeTenant;
|
||||
final tenant = membership?.tenant;
|
||||
final canEdit = membership?.isAdmin ?? false;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(s.settings)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// User card
|
||||
_SectionHeader(title: s.userInfo),
|
||||
_UserCard(profile: profile),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Lab info
|
||||
_SectionHeader(
|
||||
title: s.labInfo,
|
||||
action: canEdit
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.edit_outlined,
|
||||
size: 18, color: AppColors.accent),
|
||||
tooltip: s.edit,
|
||||
onPressed: () => _showEditSheet(context, ref, tenant, s),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
_InfoCard(children: [
|
||||
_InfoTile(
|
||||
icon: Icons.science_outlined,
|
||||
label: s.labName,
|
||||
value: tenant?.companyName ?? '-',
|
||||
),
|
||||
_InfoTile(
|
||||
icon: Icons.payments_outlined,
|
||||
label: s.currency,
|
||||
value: tenant?.defaultCurrency ?? 'TRY',
|
||||
),
|
||||
_InfoTileBadge(
|
||||
icon: Icons.circle_outlined,
|
||||
label: s.status,
|
||||
value: tenant?.status == 'active' ? s.active : (tenant?.status ?? '-'),
|
||||
badgeColor: AppColors.success,
|
||||
badgeBg: AppColors.successBg,
|
||||
),
|
||||
_InfoTile(
|
||||
icon: Icons.star_outline,
|
||||
label: s.role,
|
||||
value: _roleLabel(membership?.role, s),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Connections
|
||||
if (membership?.showConnections ?? false) ...[
|
||||
_SectionHeader(title: s.connections),
|
||||
_InfoCard(children: [
|
||||
_NavTile(
|
||||
icon: Icons.link_rounded,
|
||||
iconColor: AppColors.inProgress,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.clinicConnections,
|
||||
subtitle: s.clinicConnectionsSub,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LabConnectionsScreen()),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Other memberships
|
||||
if (auth.memberships.length > 1) ...[
|
||||
_SectionHeader(title: s.otherMemberships),
|
||||
_InfoCard(children: [
|
||||
for (final m
|
||||
in auth.memberships.where((m) => m.id != membership?.id))
|
||||
_NavTile(
|
||||
icon: Icons.switch_account_outlined,
|
||||
iconColor: AppColors.inProgress,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: m.tenant.companyName,
|
||||
subtitle: _tenantKindLabel(m.tenant.kind, s),
|
||||
onTap: () {
|
||||
ref.read(authProvider.notifier).setActiveTenant(m);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))),
|
||||
);
|
||||
},
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Team management + Reports
|
||||
if (membership?.canManageUsers ?? false) ...[
|
||||
_SectionHeader(title: s.management),
|
||||
_InfoCard(children: [
|
||||
_NavTile(
|
||||
icon: Icons.group_outlined,
|
||||
iconColor: AppColors.inProgress,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.team,
|
||||
subtitle: s.teamSub,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const TenantTeamScreen()),
|
||||
),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.discount_outlined,
|
||||
iconColor: AppColors.success,
|
||||
iconBg: AppColors.successBg,
|
||||
title: s.discounts,
|
||||
subtitle: s.discountsSub,
|
||||
onTap: () => context.push(routeLabDiscounts),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.bar_chart_rounded,
|
||||
iconColor: AppColors.accent,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.reports,
|
||||
subtitle: s.reportsSub,
|
||||
onTap: () => context.push(routeLabReports),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
iconColor: const Color(0xFF7C3AED),
|
||||
iconBg: const Color(0xFFF3E8FF),
|
||||
title: s.aiAssistant,
|
||||
subtitle: s.aiAssistantSub,
|
||||
onTap: () => context.push(routeLabAi),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Preferences (language)
|
||||
_SectionHeader(title: s.preferences),
|
||||
_InfoCard(children: [
|
||||
_NavTile(
|
||||
icon: Icons.language_outlined,
|
||||
iconColor: AppColors.accent,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.appLanguage,
|
||||
subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s),
|
||||
onTap: () => _showLanguagePicker(context, ref, s),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Sign out
|
||||
_SignOutCard(ref: ref, s: s),
|
||||
const SizedBox(height: 32),
|
||||
const Center(
|
||||
child: Text('DLS — Dental Lab System',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Center(
|
||||
child: Text('Geliştirici: kovakyazilim.com',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
|
||||
if (tenant == null) return;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _EditTenantSheet(
|
||||
tenant: tenant,
|
||||
s: s,
|
||||
onSave: (name, currency) async {
|
||||
await ref.read(authProvider.notifier).updateTenantInfo(
|
||||
tenantId: tenant.id,
|
||||
companyName: name,
|
||||
defaultCurrency: currency,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLanguagePicker(BuildContext context, WidgetRef ref, AppStrings s) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _LanguagePickerSheet(s: s, ref: ref),
|
||||
);
|
||||
}
|
||||
|
||||
static String _tenantKindLabel(TenantKind? kind, AppStrings s) =>
|
||||
switch (kind) {
|
||||
TenantKind.clinic => s.tenantKindClinic,
|
||||
TenantKind.lab => s.tenantKindLab,
|
||||
null => '-',
|
||||
};
|
||||
|
||||
static String _currentLanguageLabel(String code, AppStrings s) => switch (code) {
|
||||
'en' => s.languageEnglish,
|
||||
'ru' => s.languageRussian,
|
||||
'ar' => s.languageArabic,
|
||||
'de' => s.languageGerman,
|
||||
_ => s.languageTurkish,
|
||||
};
|
||||
|
||||
static String _roleLabel(TenantRole? role, AppStrings s) => switch (role) {
|
||||
TenantRole.owner => s.roleOwner,
|
||||
TenantRole.admin => s.roleAdmin,
|
||||
TenantRole.technician => s.roleTechnician,
|
||||
TenantRole.delivery => s.roleDelivery,
|
||||
TenantRole.finance => s.roleFinance,
|
||||
TenantRole.doctor => s.roleDoctor,
|
||||
TenantRole.member => s.roleMember,
|
||||
null => '-',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Language picker sheet ─────────────────────────────────────────────────────
|
||||
|
||||
class _LanguagePickerSheet extends ConsumerWidget {
|
||||
const _LanguagePickerSheet({required this.s, required this.ref});
|
||||
final AppStrings s;
|
||||
final WidgetRef ref;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef _) {
|
||||
final currentLocale = ref.watch(localeProvider);
|
||||
|
||||
final options = [
|
||||
('tr', '🇹🇷', s.languageTurkish),
|
||||
('en', '🇬🇧', s.languageEnglish),
|
||||
('ru', '🇷🇺', s.languageRussian),
|
||||
('ar', '🇸🇦', s.languageArabic),
|
||||
('de', '🇩🇪', s.languageGerman),
|
||||
];
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
s.languageSelection,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (final (code, flag, label) in options)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
leading: Text(flag, style: const TextStyle(fontSize: 24)),
|
||||
title: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
trailing: currentLocale.languageCode == code
|
||||
? const Icon(Icons.check_circle_rounded,
|
||||
color: AppColors.accent)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(localeProvider.notifier).setLocale(Locale(code));
|
||||
ref.read(authProvider.notifier).updateLanguage(code);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit sheet ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _EditTenantSheet extends StatefulWidget {
|
||||
const _EditTenantSheet({
|
||||
required this.tenant,
|
||||
required this.s,
|
||||
required this.onSave,
|
||||
});
|
||||
final Tenant tenant;
|
||||
final AppStrings s;
|
||||
final Future<void> Function(String companyName, String currency) onSave;
|
||||
|
||||
@override
|
||||
State<_EditTenantSheet> createState() => _EditTenantSheetState();
|
||||
}
|
||||
|
||||
class _EditTenantSheetState extends State<_EditTenantSheet> {
|
||||
late final TextEditingController _nameController;
|
||||
late String _selectedCurrency;
|
||||
bool _saving = false;
|
||||
|
||||
static const _currencies = [
|
||||
('TRY', '₺', 'Türk Lirası'),
|
||||
('USD', '\$', 'US Dollar'),
|
||||
('EUR', '€', 'Euro'),
|
||||
('GBP', '£', 'British Pound'),
|
||||
('AED', 'د.إ', 'UAE Dirham'),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.tenant.companyName);
|
||||
_selectedCurrency = widget.tenant.defaultCurrency;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
setState(() => _saving = true);
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await widget.onSave(name, _selectedCurrency);
|
||||
navigator.pop();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = widget.s;
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(s.editLabInfo,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: s.labName,
|
||||
hintText: s.labNameHint,
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(s.currency,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedCurrency,
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border)),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border)),
|
||||
),
|
||||
items: [
|
||||
for (final (code, symbol, name) in _currencies)
|
||||
DropdownMenuItem(
|
||||
value: code,
|
||||
child: Text('$symbol $name ($code)',
|
||||
style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => _selectedCurrency = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_saving)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent))
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 48)),
|
||||
child: Text(s.save),
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reusable UI pieces ────────────────────────────────────────────────────────
|
||||
|
||||
class _UserCard extends StatelessWidget {
|
||||
const _UserCard({required this.profile});
|
||||
final dynamic profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayName = (profile?.displayName?.isNotEmpty == true)
|
||||
? profile!.displayName as String
|
||||
: 'Kullanıcı';
|
||||
final initial = (profile?.displayName?.isNotEmpty == true
|
||||
? (profile!.displayName as String)[0]
|
||||
: (profile?.email as String?)?[0] ?? '?')
|
||||
.toUpperCase();
|
||||
|
||||
return 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: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(14)),
|
||||
child: Center(
|
||||
child: Text(initial,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.inProgress)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(profile?.email as String? ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, this.action});
|
||||
final String title;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
letterSpacing: 0.3),
|
||||
),
|
||||
),
|
||||
if (action != null) action!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoCard extends StatelessWidget {
|
||||
const _InfoCard({required this.children});
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
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(
|
||||
children: [
|
||||
for (int i = 0; i < children.length; i++) ...[
|
||||
children[i],
|
||||
if (i < children.length - 1)
|
||||
const Divider(
|
||||
height: 1, indent: 16, endIndent: 16, color: AppColors.border),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoTile extends StatelessWidget {
|
||||
const _InfoTile(
|
||||
{required this.icon, required this.label, required this.value});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textMuted)),
|
||||
const SizedBox(height: 2),
|
||||
Text(value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoTileBadge extends StatelessWidget {
|
||||
const _InfoTileBadge({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.badgeColor,
|
||||
required this.badgeBg,
|
||||
});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color badgeColor;
|
||||
final Color badgeBg;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textMuted)),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(value,
|
||||
style: TextStyle(
|
||||
color: badgeColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavTile extends StatelessWidget {
|
||||
const _NavTile({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.iconBg,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final Color iconBg;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg, borderRadius: BorderRadius.circular(9)),
|
||||
child: Icon(icon, color: iconColor, size: 18),
|
||||
),
|
||||
title: Text(title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
subtitle: subtitle != null
|
||||
? Text(subtitle!,
|
||||
style: const TextStyle(color: AppColors.textSecondary))
|
||||
: null,
|
||||
trailing:
|
||||
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignOutCard extends StatelessWidget {
|
||||
const _SignOutCard({required this.ref, required this.s});
|
||||
final WidgetRef ref;
|
||||
final AppStrings s;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.cancelledBg),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: ListTile(
|
||||
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),
|
||||
),
|
||||
title: Text(s.signOut,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cancelled, fontWeight: FontWeight.w600)),
|
||||
onTap: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(s.signOutTitle),
|
||||
content: Text(s.signOutConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(s.cancel)),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cancelled),
|
||||
child: Text(s.signOut),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref.read(authProvider.notifier).signOut();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user