Files
app-grosir/lib/screens/riwayat_screen.dart

1164 lines
52 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/sale.dart';
import '../services/export_service.dart';
import '../services/printer_service.dart';
import '../state/app_state.dart';
import '../utils/colors.dart';
import '../utils/format.dart';
import '../utils/rupiah_input.dart';
class RiwayatScreen extends StatefulWidget {
const RiwayatScreen({super.key});
@override
State<RiwayatScreen> createState() => _RiwayatScreenState();
}
class _RiwayatScreenState extends State<RiwayatScreen> {
DateTimeRange? _dateRange;
String _paymentFilter = 'all';
final PageController _reportPageController = PageController(viewportFraction: 0.85);
int _reportPage = 0;
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
if (state.loading) {
return const Center(child: CircularProgressIndicator(color: kAccent));
}
final now = DateTime.now();
final isAdmin = state.isAdmin;
List<Sale> filteredSales;
if (isAdmin) {
if (_dateRange != null) {
final start = DateTime(_dateRange!.start.year, _dateRange!.start.month,
_dateRange!.start.day);
final end = DateTime(_dateRange!.end.year, _dateRange!.end.month,
_dateRange!.end.day + 1);
filteredSales = state.sales
.where((s) => s.date
.isAfter(start.subtract(const Duration(seconds: 1))) &&
s.date.isBefore(end))
.toList();
} else {
filteredSales = state.sales
.where((s) =>
s.date.year == now.year &&
s.date.month == now.month)
.toList();
}
} else {
filteredSales = state.sales
.where((s) =>
s.date.year == now.year &&
s.date.month == now.month &&
s.date.day == now.day)
.toList();
}
if (_paymentFilter == 'unpaid') {
filteredSales = filteredSales.where((s) => s.paymentStatus != 'paid' && !s.isCancelled).toList();
} else if (_paymentFilter == 'paid') {
filteredSales = filteredSales.where((s) => s.paymentStatus == 'paid' && !s.isCancelled).toList();
}
final displayedSales = filteredSales.take(state.visibleSalesCount).toList();
final currentMonth = DateTime(now.year, now.month);
final previousMonth = DateTime(now.year, now.month - 1);
final currentMonthSales = state.sales.where((s) {
final saleMonth = DateTime(s.date.year, s.date.month);
return saleMonth == currentMonth && !s.isCancelled;
}).toList();
final previousMonthSales = state.sales.where((s) {
final saleMonth = DateTime(s.date.year, s.date.month);
return saleMonth == previousMonth && !s.isCancelled;
}).toList();
final currentRevenue =
currentMonthSales.fold(0.0, (sum, s) => sum + s.total);
final previousRevenue =
previousMonthSales.fold(0.0, (sum, s) => sum + s.total);
final revenueChange = previousRevenue > 0
? ((currentRevenue - previousRevenue) / previousRevenue) * 100
: (currentRevenue > 0 ? 100 : 0);
final currentProfit =
currentMonthSales.fold(0.0, (sum, s) => sum + (s.total - s.totalCost));
final previousProfit =
previousMonthSales.fold(0.0, (sum, s) => sum + (s.total - s.totalCost));
final profitChange = previousProfit > 0
? ((currentProfit - previousProfit) / previousProfit) * 100
: (currentProfit > 0 ? 100 : 0);
final currentTransactions = currentMonthSales.length;
final previousTransactions = previousMonthSales.length;
final transactionChange = previousTransactions > 0
? ((currentTransactions - previousTransactions) / previousTransactions) * 100
: (currentTransactions > 0 ? 100 : 0);
final today = DateTime(now.year, now.month, now.day);
final yesterday = DateTime(now.year, now.month, now.day - 1);
final todaySales = state.sales.where((s) {
final saleDay = DateTime(s.date.year, s.date.month, s.date.day);
return saleDay == today && !s.isCancelled;
}).toList();
final yesterdaySales = state.sales.where((s) {
final saleDay = DateTime(s.date.year, s.date.month, s.date.day);
return saleDay == yesterday && !s.isCancelled;
}).toList();
final todayRevenue = todaySales.fold(0.0, (sum, s) => sum + s.total);
final yesterdayRevenue = yesterdaySales.fold(0.0, (sum, s) => sum + s.total);
final revenueDayChange = yesterdayRevenue > 0
? ((todayRevenue - yesterdayRevenue) / yesterdayRevenue) * 100
: (todayRevenue > 0 ? 100 : 0);
final reports = <_Report>[
if (isAdmin) ...[
_Report(
'Pendapatan Bulan Ini',
formatRupiah(currentRevenue),
Icons.trending_up,
kSuccess,
change: revenueChange.toDouble(),
compareValue: formatRupiah(previousRevenue),
compareLabel: 'Bulan Lalu',
),
_Report(
'Laba Kotor Bulan Ini',
formatRupiah(currentProfit),
Icons.attach_money,
kAccent,
change: profitChange.toDouble(),
compareValue: formatRupiah(previousProfit),
compareLabel: 'Bulan Lalu',
),
_Report(
'Transaksi Bulan Ini',
'$currentTransactions',
Icons.receipt_long,
kAccent,
change: transactionChange.toDouble(),
compareValue: '$previousTransactions',
compareLabel: 'Bulan Lalu',
),
] else ...[
_Report(
'Transaksi Hari Ini',
'$currentTransactions',
Icons.receipt_long,
kAccent,
change: transactionChange.toDouble(),
compareValue: '$previousTransactions',
compareLabel: 'Kemarin',
),
_Report(
'Pendapatan Hari Ini',
formatRupiah(todayRevenue),
Icons.today_outlined,
kSuccess,
change: revenueDayChange.toDouble(),
compareValue: formatRupiah(yesterdayRevenue),
compareLabel: 'Kemarin',
),
],
];
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
if (isAdmin)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_dateRange == null
? 'Semua data'
: '${formatTanggal(_dateRange!.start)} - ${formatTanggal(_dateRange!.end)}',
style: const TextStyle(color: kMuted, fontSize: 13),
),
),
],
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
TextButton.icon(
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
try {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2024),
lastDate: DateTime.now(),
initialDateRange: _dateRange,
);
if (mounted && picked != null) {
setState(() => _dateRange = picked);
context.read<AppState>().resetPagination();
}
} catch (e) {
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text('Gagal memilih tanggal: $e'),
backgroundColor: kDanger,
),
);
}
}
},
icon: const Icon(Icons.date_range_outlined, size: 18),
label: const Text('Pilih Rentang Tanggal'),
),
if (_dateRange != null)
TextButton.icon(
onPressed: () {
try {
setState(() => _dateRange = null);
context.read<AppState>().resetPagination();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal reset filter: $e'),
backgroundColor: kDanger,
),
);
}
}
},
icon: const Icon(Icons.close, size: 18),
label: const Text('Reset'),
),
IconButton(
icon: const Icon(Icons.picture_as_pdf_outlined,
size: 18),
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
try {
if (filteredSales.isEmpty) {
if (mounted) {
messenger.showSnackBar(
const SnackBar(
content:
Text('Tidak ada data untuk export'),
backgroundColor: kDanger,
),
);
}
return;
}
final exportService = ExportService();
final path = await exportService.exportToPdf(
filteredSales
.map((s) => {
'date': s.date,
'id': s.id.toUpperCase(),
'total': formatRupiah(s.total),
'paid': formatRupiah(s.paid),
'change': formatRupiah(s.change),
'items': s.items.length,
'discount': s.discount,
})
.toList(),
'Dutra Store',
_dateRange,
);
if (path != null && mounted) {
await exportService.shareFile(path);
}
} catch (e) {
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text('Gagal export PDF: $e'),
backgroundColor: kDanger,
),
);
}
}
},
tooltip: 'Export PDF',
),
],
),
),
],
),
),
Column(
children: [
SizedBox(
height: 90,
child: PageView.builder(
controller: _reportPageController,
onPageChanged: (i) => setState(() => _reportPage = i),
itemCount: reports.length,
itemBuilder: (context, i) {
final r = reports[i];
return Padding(
padding: EdgeInsets.only(
right: i < reports.length - 1 ? 8 : 0,
),
child: _ReportCard(
report: r,
isActive: i == _reportPage,
),
);
},
),
),
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(reports.length, (i) {
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
width: _reportPage == i ? 12 : 5,
height: 5,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: _reportPage == i
? reports[i].color
: kBorder,
borderRadius: BorderRadius.circular(999),
),
);
}),
),
],
),
const SizedBox(height: 6),
if (isAdmin) _buildUnpaidSummary(state.sales, () => setState(() => _paymentFilter = 'unpaid')),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_PaymentFilterChip(
label: 'Semua',
selected: _paymentFilter == 'all',
onTap: () => setState(() => _paymentFilter = 'all'),
),
_PaymentFilterChip(
label: 'Belum Lunas',
selected: _paymentFilter == 'unpaid',
onTap: () => setState(() => _paymentFilter = 'unpaid'),
isWarning: true,
),
_PaymentFilterChip(
label: 'Sudah Lunas',
selected: _paymentFilter == 'paid',
onTap: () => setState(() => _paymentFilter = 'paid'),
isSuccess: true,
),
],
),
const SizedBox(height: 8),
Expanded(
child: filteredSales.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.receipt_long_outlined,
size: 48, color: kMuted),
SizedBox(height: 8),
Text('Belum ada transaksi',
style: TextStyle(color: kMuted)),
],
),
)
: RefreshIndicator(
onRefresh: () async {
context.read<AppState>().recordManualRefresh();
await context.read<AppState>().refresh();
},
color: kAccent,
child: ListView.separated(
itemCount: displayedSales.length +
(filteredSales.length > state.visibleSalesCount
? 1
: 0),
separatorBuilder: (_, __) =>
const SizedBox(height: 10),
itemBuilder: (context, i) {
if (i >= displayedSales.length) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: FilledButton.icon(
onPressed: () => state.loadMoreSales(),
icon: const Icon(Icons.expand_more, size: 18),
label: Text('Muat lebih banyak (${filteredSales.length - state.visibleSalesCount} tersisa)'),
),
);
}
final sale = displayedSales[i];
final isCancelled = sale.isCancelled;
return Card(
color: isCancelled ? kDanger.withValues(alpha: 0.08) : null,
child: InkWell(
onTap: () => _showSaleDetail(context, sale),
borderRadius: BorderRadius.circular(kRadius),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: isCancelled
? kDanger.withValues(alpha: 0.12)
: kSuccess.withValues(alpha: 0.12),
child: Icon(
isCancelled
? Icons.cancel_outlined
: Icons.check_circle_outline,
color: isCancelled ? kDanger : kSuccess,
size: 20,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
formatRupiah(sale.total),
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
decoration: isCancelled
? TextDecoration.lineThrough
: null,
color: isCancelled ? kMuted : null,
),
),
const SizedBox(height: 2),
Text(
'${formatTanggal(sale.date)} ${formatWaktu(sale.date)} • ${sale.items.length} item',
style: const TextStyle(fontSize: 11, color: kMuted),
),
if (isCancelled) ...[
const SizedBox(height: 2),
Text(
'Dibatalkan',
style: const TextStyle(
fontSize: 10,
color: kDanger,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
const SizedBox(width: 6),
Text(
'#${sale.id.toUpperCase()}',
style: const TextStyle(fontSize: 10, color: kMuted),
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
},
),
),
),
],
),
);
}
void _showSaleDetail(BuildContext context, Sale sale) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.5,
maxChildSize: 0.92,
builder: (_, controller) => _buildSaleDetailBottomSheet(
context,
sheetContext,
sale,
controller,
),
),
);
}
Widget _buildSaleDetailBottomSheet(
BuildContext parentContext, BuildContext sheetContext, Sale sale, ScrollController controller) {
final state = parentContext.read<AppState>();
final isAdmin = state.isAdmin;
final isKasir = state.isKasir;
return StatefulBuilder(
builder: (context, setSheet) {
Sale currentSale = sale;
final isCancelled = currentSale.isCancelled;
return Container(
decoration: const BoxDecoration(
color: kSurface,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
border: Border(top: BorderSide(color: kBorder)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: kBorder,
borderRadius: BorderRadius.circular(4),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('#${currentSale.id.toUpperCase()}',
style: const TextStyle(fontSize: 10, color: kMuted),
overflow: TextOverflow.ellipsis),
Text(
'Kasir: ${state.getProfileName(currentSale.userId)}',
style: const TextStyle(color: kMuted, fontSize: 11),
),
],
),
),
if (isCancelled)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: kDanger.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'DIBATALKAN',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: kDanger),
),
),
],
),
),
const SizedBox(height: 2),
Text(
'${formatTanggal(currentSale.date)} ${formatWaktu(currentSale.date)}',
style: const TextStyle(color: kMuted, fontSize: 12),
),
const Divider(height: 24),
Expanded(
child: SingleChildScrollView(
controller: controller,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...currentSale.items.map((it) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
it.productId.startsWith('manual-')
? '[MANUAL] ${it.name} x${it.qty}'
: '${it.name} x${it.qty}',
style: TextStyle(
fontSize: 14,
color: it.productId.startsWith('manual-') ? kAccent : null,
),
),
),
Text(formatRupiah(it.subtotal),
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600)),
],
),
)),
if (currentSale.discount > 0)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Diskon', style: TextStyle(color: kMuted)),
Text('-${formatRupiah(currentSale.discount)}',
style: const TextStyle(color: kMuted)),
],
),
),
const Divider(height: 24),
_receiptRow('Total', formatRupiah(currentSale.total), true),
_receiptRow('Bayar', formatRupiah(currentSale.paid), false),
if (currentSale.paymentStatus == 'unpaid' || currentSale.paymentStatus == 'partial')
_receiptRow('Sisa', formatRupiah(currentSale.dueAmount), false),
if (currentSale.paymentStatus == 'paid')
_receiptRow('Kembali', formatRupiah(currentSale.change.clamp(0, double.infinity)), false),
_receiptRow('Metode', _paymentMethodLabel(currentSale.paymentMethod), false),
_receiptRow('Status', currentSale.paymentStatus == 'paid' ? 'Lunas' : 'Belum Lunas', false),
if (currentSale.splitPayment != null && currentSale.splitPayment!.isNotEmpty)
...currentSale.splitPayment!.entries.map((e) {
final label = e.key == 'tunai'
? 'Tunai'
: e.key == 'transfer'
? 'Bank'
: 'QRIS';
return _receiptRow('Split $label', formatRupiah(e.value), false);
}),
if (!state.isKasir && currentSale.totalCost > 0) ...[
const Divider(height: 24),
_receiptRow('Harga Pokok', formatRupiah(currentSale.totalCost), false),
_receiptRow('Laba Kotor', formatRupiah(currentSale.total - currentSale.totalCost), true),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: isCancelled
? null
: () async {
final messenger = ScaffoldMessenger.of(context);
final prefs = await SharedPreferences.getInstance();
final storeName =
prefs.getString('store_name') ?? 'Toko Grosir';
final err =
await printerService.printSale(
currentSale,
storeName,
kasirName: state.getProfileName(currentSale.userId),
whatsapp: prefs.getString('store_whatsapp'),
notes: prefs.getString('store_notes'),
);
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text(err == null
? 'Struk dikirim ke printer'
: 'Gagal cetak: $err'),
backgroundColor: err == null ? kSuccess : kDanger,
),
);
}
},
icon: const Icon(Icons.print_outlined),
label: const Text('Cetak Struk'),
),
),
if ((isAdmin || isKasir) && !isCancelled && currentSale.paymentStatus != 'paid') ...[
const SizedBox(height: 4),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
final payCtrl = TextEditingController();
final result = await showDialog<double>(
context: parentContext,
builder: (ctx) {
bool submitting = false;
return StatefulBuilder(
builder: (context, setDialog) {
final isUnpaid = currentSale.paymentStatus == 'unpaid';
final remain = currentSale.dueAmount - currentSale.paid;
final remainColor = remain <= 0 ? kSuccess : kDanger;
return AlertDialog(
title: Text(isUnpaid ? 'Terima Pembayaran' : 'Bayar Sisa'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Total', style: TextStyle(color: kMuted, fontSize: 12)),
Text(formatRupiah(currentSale.total),
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Sudah Bayar', style: TextStyle(color: kMuted, fontSize: 12)),
Text(formatRupiah(currentSale.paid),
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
],
),
),
],
),
),
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: remainColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(isUnpaid ? 'Sisa Tagihan' : 'Sisa',
style: TextStyle(color: remainColor, fontSize: 13, fontWeight: FontWeight.w600)),
Text(formatRupiah(remain > 0 ? remain : 0),
style: TextStyle(color: remainColor, fontSize: 16, fontWeight: FontWeight.w800)),
],
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isUnpaid ? kMuted.withValues(alpha: 0.12) : kAccent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(isUnpaid ? 'Transaksi belum lunas' : 'Pembayaran sebagian',
style: TextStyle(color: isUnpaid ? kMuted : kAccent, fontSize: 12, fontWeight: FontWeight.w600)),
),
const SizedBox(height: 14),
TextField(
controller: payCtrl,
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: InputDecoration(
labelText: 'Nominal Pembayaran',
prefixText: 'Rp ',
helperText: isUnpaid ? 'Masukkan nominal sesuai yang dibayar customer' : 'Masukkan jumlah pelunasan',
helperStyle: TextStyle(color: kMuted, fontSize: 12),
),
),
],
),
),
actions: [
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: submitting ? null : () => Navigator.pop(ctx),
style: OutlinedButton.styleFrom(
foregroundColor: kMuted,
side: const BorderSide(color: kBorder),
),
child: const Text('Batal'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: submitting
? null
: () {
final val = (RupiahInputFormatter.getRawValue(payCtrl.text) ?? 0).toDouble();
if (val <= 0) return;
Navigator.pop(ctx, val);
},
style: FilledButton.styleFrom(backgroundColor: kSuccess, foregroundColor: const Color(0xFF06210F)),
child: submitting
? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF06210F)))
: const Text('Simpan'),
),
),
],
),
],
);
}
);
},
);
if (result != null && result > 0) {
final err = await state.recordPayment(currentSale.id, result);
Sale? updated;
try {
updated = state.sales.firstWhere((s) => s.id == currentSale.id);
} catch (_) {}
final isNowPaid = updated?.paymentStatus == 'paid';
final remainAfter = isNowPaid
? 0.0
: (updated?.dueAmount ?? (currentSale.dueAmount - result)).clamp(0, double.infinity);
if (sheetContext.mounted) {
Navigator.pop(sheetContext);
}
if (mounted) {
final messenger = ScaffoldMessenger.of(parentContext);
final msg = err == null
? isNowPaid
? 'Pembayaran lunas dicatat'
: 'Pembayaran ${formatRupiah(result.toDouble())} dicatat. Sisa: ${formatRupiah(remainAfter.toDouble())}'
: 'Gagal: $err';
messenger.showSnackBar(
SnackBar(
content: Text(msg),
backgroundColor: err == null ? kSuccess : kDanger,
),
);
}
}
},
icon: const Icon(Icons.payments_outlined, color: Colors.white),
label: Text(currentSale.paymentStatus == 'unpaid' ? 'Terima Pembayaran' : 'Bayar Sisa'),
style: FilledButton.styleFrom(
backgroundColor: kSuccess,
foregroundColor: const Color(0xFF06210F),
),
),
),
],
if (isAdmin && !isCancelled) ...[
const SizedBox(height: 4),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
final messenger = ScaffoldMessenger.of(parentContext);
final confirm = await showDialog<bool>(
context: parentContext,
builder: (ctx) {
bool cancelling = false;
return StatefulBuilder(
builder: (context, setDialog) {
return AlertDialog(
icon: Icon(Icons.warning_amber_rounded, color: kDanger, size: 28),
title: const Text('Batalkan Transaksi?'),
content: const Text(
'Stok akan dikembalikan secara otomatis. Tindakan ini tidak dapat dibatalkan.',
),
actions: [
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: cancelling ? null : () => Navigator.pop(ctx, false),
style: OutlinedButton.styleFrom(
foregroundColor: kMuted,
side: const BorderSide(color: kBorder),
),
child: const Text('Batal'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: cancelling
? null
: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: kDanger),
child: cancelling
? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Batalkan'),
),
),
],
),
],
);
}
);
},
);
if (confirm == true && mounted) {
final err = await state.cancelSale(currentSale.id);
if (mounted) {
Navigator.pop(sheetContext);
messenger.showSnackBar(
SnackBar(
content: Text(err == null
? 'Transaksi dibatalkan, stok dipulihkan'
: 'Gagal membatalkan: $err'),
backgroundColor: err == null ? kSuccess : kDanger,
),
);
}
}
},
icon: const Icon(Icons.cancel_outlined, color: kDanger),
label: const Text('Batalkan Transaksi'),
style: OutlinedButton.styleFrom(
foregroundColor: kDanger,
side: const BorderSide(color: kDanger),
),
),
),
],
],
),
),
),
],
),
);
},
);
}
Widget _receiptRow(String label, String value, bool bold) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label,
style: TextStyle(
color: bold ? kText : kMuted,
fontWeight: bold ? FontWeight.w700 : FontWeight.normal)),
Text(value,
style: TextStyle(
fontSize: bold ? 17 : 14,
fontWeight: bold ? FontWeight.w800 : FontWeight.w600,
color: bold ? kAccent : kText,
)),
],
),
);
}
}
class _ReportCard extends StatelessWidget {
final _Report report;
final bool isActive;
const _ReportCard({required this.report, required this.isActive});
@override
Widget build(BuildContext context) {
final r = report;
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOut,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(kRadius),
border: Border.all(
color: isActive ? r.color.withValues(alpha: 0.4) : kBorder,
),
),
child: isActive
? Row(
children: [
Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: r.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Icon(r.icon, color: r.color, size: 14),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
r.value,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: r.color,
),
),
Text(
r.label,
style: const TextStyle(fontSize: 10, color: kMuted),
),
if (r.change != null) ...[
const SizedBox(height: 1),
Text(
'${r.change! >= 0 ? '↑' : '↓'} ${r.change!.abs().toStringAsFixed(1)}% vs ${r.compareLabel}${r.compareValue != null ? " • ${r.compareValue}" : ""}',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: r.change! >= 0 ? kSuccess : kDanger,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: r.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Icon(r.icon, color: r.color, size: 11),
),
const SizedBox(height: 2),
Text(
r.value,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800),
),
Text(
r.label,
style: const TextStyle(fontSize: 8, color: kMuted),
),
],
),
);
}
}
Widget _buildUnpaidSummary(List<Sale> sales, VoidCallback onTap) {
final unpaid = sales.where((s) => s.paymentStatus != 'paid' && !s.isCancelled).toList();
if (unpaid.isEmpty) return const SizedBox.shrink();
final totalDue = unpaid.fold(0.0, (sum, s) => sum + s.dueAmount);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: kAccent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(kRadius),
border: Border.all(color: kAccent.withValues(alpha: 0.2)),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded, color: kAccent, size: 20),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('${unpaid.length} Transaksi Belum Lunas',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
Text('Total sisa: ${formatRupiah(totalDue)}',
style: TextStyle(fontSize: 11, color: kMuted)),
],
),
),
TextButton.icon(
onPressed: onTap,
icon: const Icon(Icons.visibility_outlined, size: 16),
label: const Text('Lihat'),
),
],
),
);
}
class _PaymentFilterChip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
final bool isWarning;
final bool isSuccess;
const _PaymentFilterChip({
required this.label,
required this.selected,
required this.onTap,
this.isWarning = false,
this.isSuccess = false,
});
@override
Widget build(BuildContext context) {
final color = isWarning
? kAccent
: isSuccess
? kSuccess
: kMuted;
return FilterChip(
label: Text(label, style: const TextStyle(fontSize: 12)),
selected: selected,
onSelected: (_) => onTap(),
selectedColor: color.withValues(alpha: 0.15),
checkmarkColor: color,
labelStyle: TextStyle(color: selected ? color : kMuted, fontSize: 12),
side: BorderSide(color: selected ? color : kBorder),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
);
}
}
String _paymentMethodLabel(String method) {
switch (method) {
case 'transfer':
return 'Bank';
case 'qris':
return 'QRIS';
case 'split':
return 'Split Payment';
default:
return 'Tunai';
}
}
class _Report {
final String label;
final String value;
final IconData icon;
final Color color;
final double? change;
final String? compareValue;
final String? compareLabel;
const _Report(
this.label,
this.value,
this.icon,
this.color, {
this.change,
this.compareValue,
this.compareLabel,
});
}