1154 lines
51 KiB
Dart
1154 lines
51 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 (picked != null && context.mounted) {
|
|
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) {
|
|
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: () => 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: () {
|
|
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: 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 (parentContext.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) {
|
|
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: () => 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: () => Navigator.pop(ctx, true),
|
|
style: FilledButton.styleFrom(backgroundColor: kDanger),
|
|
child: const Text('Batalkan'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
);
|
|
},
|
|
);
|
|
if (confirm == true && mounted) {
|
|
final err = await state.cancelSale(currentSale.id);
|
|
if (sheetContext.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,
|
|
});
|
|
}
|