POS Grosir Dutra Store: update kasir/produk/riwayat/pengaturan + offline queue
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user