Files
lab-app/lib/features/lab/discounts/discounts_screen.dart
T
2026-06-12 00:04:53 +03:00

941 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../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 Faturalanabilir Adet (İsteğe Bağlı)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary)),
const SizedBox(height: 4),
const Text(
'İş bazında diş/vaka adedi bu eşiğe ulaşı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);
}
},
);
}
}