POS Grosir Dutra Store: update kasir/produk/riwayat/pengaturan + offline queue

This commit is contained in:
onie
2026-10-17 06:38:50 +07:00
parent bec02d196c
commit 0121eca8bb
15 changed files with 1202 additions and 524 deletions

View File

@@ -512,66 +512,71 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
BuildContext parentContext, BuildContext sheetContext, Sale sale, ScrollController controller) {
final state = parentContext.read<AppState>();
final isAdmin = state.isAdmin;
final isCancelled = sale.isCancelled;
final isKasir = state.isKasir;
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),
),
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)),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('#${sale.id.toUpperCase()}',
style: const TextStyle(fontSize: 10, color: kMuted),
overflow: TextOverflow.ellipsis),
Text(
'Kasir: ${state.getProfileName(sale.userId)}',
style: const TextStyle(color: kMuted, fontSize: 11),
),
],
),
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),
),
if (isCancelled)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: kDanger.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
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),
),
],
),
),
child: const Text(
'DIBATALKAN',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: kDanger),
),
),
],
),
),
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(sale.date)} ${formatWaktu(sale.date)}',
'${formatTanggal(currentSale.date)} ${formatWaktu(currentSale.date)}',
style: const TextStyle(color: kMuted, fontSize: 12),
),
const Divider(height: 24),
@@ -582,14 +587,21 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...sale.items.map((it) => Padding(
...currentSale.items.map((it) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text('${it.name} x${it.qty}',
style: const TextStyle(fontSize: 14)),
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(
@@ -597,40 +609,41 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
],
),
)),
if (sale.discount > 0)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Diskon', style: TextStyle(color: kMuted)),
Text('-${formatRupiah(sale.discount)}',
style: const TextStyle(color: kMuted)),
],
),
),
const Divider(height: 24),
_receiptRow('Total', formatRupiah(sale.total), true),
_receiptRow('Bayar', formatRupiah(sale.paid), false),
if (sale.paymentStatus == 'unpaid' || sale.paymentStatus == 'partial')
_receiptRow('Sisa', formatRupiah(sale.dueAmount), false),
_receiptRow('Kembali', formatRupiah(sale.change), false),
_receiptRow('Metode', _paymentMethodLabel(sale.paymentMethod), false),
_receiptRow('Status', sale.paymentStatus == 'paid' ? 'Lunas' : 'Belum Lunas', false),
if (sale.splitPayment != null && sale.splitPayment!.isNotEmpty)
...sale.splitPayment!.entries.map((e) {
final label = e.key == 'tunai'
? 'Tunai'
: e.key == 'transfer'
? 'Bank'
: 'QRIS';
return _receiptRow('Split $label', formatRupiah(e.value), false);
}),
if (sale.totalCost > 0) ...[
const Divider(height: 24),
_receiptRow('Harga Pokok', formatRupiah(sale.totalCost), false),
_receiptRow('Laba Kotor', formatRupiah(sale.total - sale.totalCost), true),
],
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,
@@ -644,9 +657,9 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
prefs.getString('store_name') ?? 'Toko Grosir';
final err =
await printerService.printSale(
sale,
currentSale,
storeName,
kasirName: state.getProfileName(sale.userId),
kasirName: state.getProfileName(currentSale.userId),
whatsapp: prefs.getString('store_whatsapp'),
notes: prefs.getString('store_notes'),
);
@@ -665,7 +678,7 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
label: const Text('Cetak Struk'),
),
),
if (isAdmin && !isCancelled && sale.paymentStatus != 'paid') ...[
if ((isAdmin || isKasir) && !isCancelled && currentSale.paymentStatus != 'paid') ...[
const SizedBox(height: 4),
SizedBox(
width: double.infinity,
@@ -674,60 +687,168 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
final payCtrl = TextEditingController();
final result = await showDialog<double>(
context: parentContext,
builder: (ctx) => AlertDialog(
title: Text(sale.paymentStatus == 'unpaid' ? 'Terima Pembayaran' : 'Bayar Sisa'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Total: ${formatRupiah(sale.total)}'),
Text('Sudah bayar: ${formatRupiah(sale.paid)}'),
Text('Sisa: ${formatRupiah(sale.dueAmount)}'),
const SizedBox(height: 12),
TextField(
controller: payCtrl,
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(
labelText: 'Nominal Pembayaran',
prefixText: 'Rp ',
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: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Batal')),
FilledButton(
onPressed: () {
final val = (RupiahInputFormatter.getRawValue(payCtrl.text) ?? 0).toDouble();
if (val <= 0) return;
Navigator.pop(ctx, val);
},
style: FilledButton.styleFrom(backgroundColor: kSuccess),
child: const Text('Simpan'),
),
],
),
);
if (result != null && result > 0) {
final err = await state.recordPayment(sale.id, result);
if (mounted) {
final messenger = ScaffoldMessenger.of(parentContext);
messenger.showSnackBar(
SnackBar(
content: Text(err == null ? 'Pembayaran berhasil dicatat' : 'Gagal: $err'),
backgroundColor: err == null ? kSuccess : kDanger,
),
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(sale.paymentStatus == 'unpaid' ? 'Terima Pembayaran' : 'Bayar Sisa'),
style: FilledButton.styleFrom(
backgroundColor: kSuccess,
foregroundColor: const Color(0xFF06210F),
),
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),
),
),
),
],
@@ -740,40 +861,51 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
final messenger = ScaffoldMessenger.of(parentContext);
final confirm = await showDialog<bool>(
context: parentContext,
builder: (ctx) => AlertDialog(
icon: Icon(Icons.cancel_outlined, color: kDanger, size: 28),
title: const Text('Batalkan Transaksi?'),
content: const Text(
'Stok akan dikembalikan secara otomatis. Tindakan ini tidak dapat dibatalkan.',
),
actions: [
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx, false),
style: OutlinedButton.styleFrom(
foregroundColor: kMuted,
side: const BorderSide(color: kBorder),
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.',
),
child: const Text('Batal'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: kDanger,
),
child: const Text('Batalkan'),
),
),
],
),
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(sale.id);
if (confirm == true && mounted) {
final err = await state.cancelSale(currentSale.id);
if (mounted) {
Navigator.pop(sheetContext);
messenger.showSnackBar(
@@ -802,6 +934,8 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
),
],
),
);
},
);
}