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,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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user