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

@@ -72,6 +72,125 @@ class _KasirScreenState extends State<KasirScreen> {
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
final nameCtrl = TextEditingController();
final priceCtrl = TextEditingController();
final qtyCtrl = TextEditingController(text: '1');
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (ctx) {
bool submitting = false;
return StatefulBuilder(
builder: (context, setDialog) {
return AlertDialog(
title: const Text('Tambah Pesanan Manual'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: 'Nama Produk',
prefixIcon: Icon(Icons.shopping_bag_outlined),
),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 12),
TextField(
controller: priceCtrl,
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(
labelText: 'Harga',
prefixText: 'Rp ',
prefixIcon: Icon(Icons.payments_outlined),
),
),
const SizedBox(height: 12),
TextField(
controller: qtyCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Qty',
prefixIcon: Icon(Icons.numbers),
),
),
],
),
),
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 name = nameCtrl.text.trim();
final price = (RupiahInputFormatter.getRawValue(priceCtrl.text) ?? 0).toDouble();
final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1;
if (name.isEmpty || price <= 0 || qty <= 0) return;
Navigator.pop(ctx, {
'name': name,
'price': price,
'qty': qty,
});
},
style: FilledButton.styleFrom(backgroundColor: kAccent, foregroundColor: const Color(0xFF06210F)),
child: submitting
? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF06210F)))
: const Text('Tambah'),
),
),
],
),
],
);
}
);
},
);
if (result != null) {
final manualProduct = Product.manual(
result['name'] as String,
(result['price'] as double),
);
for (int i = 0; i < (result['qty'] as int); i++) {
state.addToCart(manualProduct);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Pesanan manual ditambahkan: ${result['name']}'),
backgroundColor: kSuccess,
),
);
}
}
},
icon: const Icon(Icons.edit_note_rounded),
label: const Text('Tambah Manual'),
),
),
),
if (state.resellerEnabled) Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
child: Row(
@@ -182,10 +301,10 @@ class _KasirScreenState extends State<KasirScreen> {
showDialog(
context: sheetContext,
builder: (dialogContext) {
final isPayingRef = [false];
DateTime? payingStart;
return StatefulBuilder(
builder: (context, setDialog) {
final isPayingRef = [false];
DateTime? payingStart;
final messenger = ScaffoldMessenger.of(context);
void updatePaid(String v) {
@@ -233,7 +352,7 @@ class _KasirScreenState extends State<KasirScreen> {
Navigator.pop(sheetContext);
if (mounted) {
HapticFeedback.mediumImpact();
_showReceipt(dialogContext, sale);
_showReceipt(dialogContext, sale, isBayarNanti: isBayarNanti);
}
} catch (e) {
if (!dialogContext.mounted) return;
@@ -662,157 +781,162 @@ class _KasirScreenState extends State<KasirScreen> {
);
}
void _showReceipt(BuildContext context, Sale sale) {
void _showReceipt(BuildContext context, Sale sale, {bool isBayarNanti = false}) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
contentPadding: const EdgeInsets.all(20),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircleAvatar(
radius: 26,
backgroundColor: kSuccess,
child: Icon(Icons.check, color: Colors.white, size: 30),
),
const SizedBox(height: 10),
const Text('Pembayaran Berhasil',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(
'${formatTanggal(sale.date)} ${formatWaktu(sale.date)}',
style: const TextStyle(color: kMuted, fontSize: 12),
),
const SizedBox(height: 4),
Text('#${sale.id.toUpperCase()}',
style: const TextStyle(color: kMuted, fontSize: 11)),
const Divider(height: 24),
...sale.items.map((it) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text('${it.name} x${it.qty}',
style: const TextStyle(fontSize: 14)),
),
Text(formatRupiah(it.subtotal),
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: it.isReseller
? kAccent.withValues(alpha: 0.15)
: kMuted.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
it.isReseller ? 'Reseller' : 'Umum',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: it.isReseller ? kAccent : kMuted,
),
),
),
],
),
)),
const SizedBox(height: 4),
Text(
'Kasir: ${context.read<AppState>().getProfileName(sale.userId)}',
style: const TextStyle(color: kMuted, fontSize: 12),
),
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)),
],
builder: (ctx) {
final state = ctx.read<AppState>();
bool printing = false;
return StatefulBuilder(
builder: (context, setReceipt) {
return AlertDialog(
contentPadding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
icon: Icon(isBayarNanti ? Icons.receipt_long : Icons.check_circle, color: kSuccess, size: 28),
title: Text(isBayarNanti ? 'Pesanan Berhasil' : 'Pembayaran Berhasil'),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${formatTanggal(sale.date)} ${formatWaktu(sale.date)} • #${sale.id.toUpperCase()}',
style: const TextStyle(color: kMuted, fontSize: 12),
),
),
const Divider(height: 24),
_ReceiptRow('Total', formatRupiah(sale.total), true),
_ReceiptRow('Metode', _paymentMethodLabel(sale.paymentMethod), false),
if (sale.paymentStatus == 'unpaid') ...[
_ReceiptRow('Status', 'Belum Lunas', false),
_ReceiptRow('Sisa', formatRupiah(sale.dueAmount), false),
] else if (sale.paymentMethod == 'tunai') ...[
_ReceiptRow('Bayar', formatRupiah(sale.paid), false),
_ReceiptRow('Kembali', formatRupiah(sale.change), 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);
}),
const SizedBox(height: 16),
const Text('Terima kasih!',
style: TextStyle(color: kMuted)),
],
),
),
actions: [
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Tutup'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
HapticFeedback.lightImpact();
final messenger = ScaffoldMessenger.of(context);
final prefs = await SharedPreferences.getInstance();
final storeName =
prefs.getString('store_name') ?? 'Toko Grosir';
final err = await printerService.printSale(
sale,
storeName,
kasirName: context.read<AppState>().getProfileName(sale.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,
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...sale.items.map((it) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(
it.productId.startsWith('manual-')
? '[MANUAL] ${it.name} x${it.qty}'
: '${it.name} x${it.qty}',
style: TextStyle(
fontSize: 13,
color: it.productId.startsWith('manual-') ? kAccent : null,
),
),
),
Text(formatRupiah(it.subtotal),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600)),
],
),
)),
const SizedBox(height: 8),
Text(
'Kasir: ${state.getProfileName(sale.userId)}',
style: const TextStyle(color: kMuted, fontSize: 12),
),
if (sale.discount > 0)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
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: 20),
_ReceiptRow('Total', formatRupiah(sale.total), true),
_ReceiptRow(
'Metode', _paymentMethodLabel(sale.paymentMethod), false),
if (sale.paymentStatus == 'unpaid') ...[
_ReceiptRow('Status', 'Belum Lunas', false),
_ReceiptRow('Sisa', formatRupiah(sale.dueAmount), false),
] else if (sale.paymentMethod == 'tunai') ...[
_ReceiptRow('Bayar', formatRupiah(sale.paid), false),
_ReceiptRow('Kembali', formatRupiah(sale.change), 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);
}),
const SizedBox(height: 12),
const Text('Terima kasih!',
style: TextStyle(color: kMuted)),
],
),
),
);
}
},
icon: const Icon(Icons.print_outlined),
label: const Text('Cetak Struk'),
),
],
),
),
),
],
actions: [
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: printing
? null
: () async {
setReceipt(() => printing = true);
HapticFeedback.lightImpact();
final messenger = ScaffoldMessenger.of(context);
final prefs = await SharedPreferences.getInstance();
final storeName =
prefs.getString('store_name') ?? 'Toko Grosir';
final err = await printerService.printSale(
sale,
storeName,
kasirName: state.getProfileName(sale.userId),
whatsapp: prefs.getString('store_whatsapp'),
notes: prefs.getString('store_notes'),
);
if (!context.mounted) return;
setReceipt(() => printing = false);
messenger.showSnackBar(
SnackBar(
content: Text(err == null
? 'Struk dikirim ke printer'
: 'Gagal cetak: $err'),
backgroundColor: err == null ? kSuccess : kDanger,
),
);
},
icon: printing
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF06210F),
),
)
: const Icon(Icons.print_outlined, size: 18),
label: Text(printing ? 'Mencetak...' : 'Cetak Struk'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Tutup'),
),
),
],
);
}
);
},
);
@@ -1032,11 +1156,36 @@ class _CartSheetContent extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(line.product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600)),
Row(
children: [
Expanded(
child: Text(line.product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600)),
),
if (line.product.id.startsWith('manual-'))
Container(
margin: const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: kAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: kAccent.withValues(alpha: 0.4)),
),
child: Text(
'MANUAL',
style: TextStyle(
color: kAccent,
fontSize: 9,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
),
),
],
),
const SizedBox(height: 2),
Text(
'${formatRupiah(line.price)} /pcs',