POS Grosir Dutra Store: update kasir/produk/riwayat/pengaturan + offline queue
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class Product {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -19,6 +21,17 @@ class Product {
|
||||
this.hargaPokok = 0,
|
||||
});
|
||||
|
||||
factory Product.manual(String name, double price) {
|
||||
return Product(
|
||||
id: 'manual-${const Uuid().v4()}',
|
||||
name: name,
|
||||
price: price,
|
||||
stock: 99999,
|
||||
minStock: 0,
|
||||
hargaPokok: 0,
|
||||
);
|
||||
}
|
||||
|
||||
factory Product.fromJson(Map<String, dynamic> json) {
|
||||
return Product(
|
||||
id: json['id'] as String,
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'kasir_screen.dart';
|
||||
import 'produk_screen.dart';
|
||||
import 'riwayat_screen.dart';
|
||||
import 'pengaturan_screen.dart';
|
||||
import 'printer_screen.dart';
|
||||
import '../state/app_state.dart';
|
||||
import '../utils/colors.dart';
|
||||
|
||||
@@ -30,7 +29,7 @@ class _HomePageState extends State<HomePage> {
|
||||
} else {
|
||||
screens.addAll([
|
||||
const RiwayatScreen(),
|
||||
const PrinterScreen(),
|
||||
const PengaturanScreen(),
|
||||
]);
|
||||
}
|
||||
return screens;
|
||||
@@ -65,8 +64,8 @@ class _HomePageState extends State<HomePage> {
|
||||
label: 'Riwayat',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.print_outlined),
|
||||
label: 'Printer',
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
label: 'Pengaturan',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../state/app_state.dart';
|
||||
import '../utils/colors.dart';
|
||||
@@ -21,23 +24,61 @@ class _PengaturanScreenState extends State<PengaturanScreen> {
|
||||
final TextEditingController _notesCtrl = TextEditingController();
|
||||
bool _resellerEnabled = true;
|
||||
bool _loading = true;
|
||||
Timer? _settingsSyncTimer;
|
||||
bool _loadingSettings = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
_settingsSyncTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
if (!_loadingSettings && mounted) {
|
||||
_loadSettings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_storeNameCtrl.text = prefs.getString('store_name') ?? 'Toko Grosir';
|
||||
_currencyCtrl.text = prefs.getString('currency') ?? 'IDR';
|
||||
_whatsappCtrl.text = prefs.getString('store_whatsapp') ?? '';
|
||||
_notesCtrl.text = prefs.getString('store_notes') ?? '';
|
||||
_resellerEnabled = prefs.getBool('reseller_enabled') ?? true;
|
||||
_loading = false;
|
||||
});
|
||||
if (_loadingSettings) return;
|
||||
_loadingSettings = true;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
String storeName = prefs.getString('store_name') ?? 'Toko Grosir';
|
||||
String currency = prefs.getString('currency') ?? 'IDR';
|
||||
String whatsapp = prefs.getString('store_whatsapp') ?? '';
|
||||
String notes = prefs.getString('store_notes') ?? '';
|
||||
bool resellerEnabled = prefs.getBool('reseller_enabled') ?? true;
|
||||
|
||||
try {
|
||||
final data = await Supabase.instance.client
|
||||
.from('store_settings')
|
||||
.select()
|
||||
.eq('store_id', '00000000-0000-0000-0000-000000000000')
|
||||
.maybeSingle();
|
||||
if (data != null) {
|
||||
storeName = (data['store_name'] as String?) ?? storeName;
|
||||
currency = (data['currency'] as String?) ?? currency;
|
||||
whatsapp = data['whatsapp'] as String? ?? whatsapp;
|
||||
notes = data['notes'] as String? ?? notes;
|
||||
resellerEnabled = data['reseller_enabled'] as bool? ?? resellerEnabled;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Gagal load store settings dari Supabase: $e');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_storeNameCtrl.text = storeName;
|
||||
_currencyCtrl.text = currency;
|
||||
_whatsappCtrl.text = whatsapp;
|
||||
_notesCtrl.text = notes;
|
||||
_resellerEnabled = resellerEnabled;
|
||||
_loading = false;
|
||||
});
|
||||
} finally {
|
||||
_loadingSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
@@ -50,6 +91,21 @@ class _PengaturanScreenState extends State<PengaturanScreen> {
|
||||
if (!mounted) return;
|
||||
final state = context.read<AppState>();
|
||||
state.setResellerEnabled(_resellerEnabled);
|
||||
|
||||
try {
|
||||
await Supabase.instance.client.from('store_settings').upsert({
|
||||
'id': 'store',
|
||||
'store_id': '00000000-0000-0000-0000-000000000000',
|
||||
'store_name': _storeNameCtrl.text.trim(),
|
||||
'currency': _currencyCtrl.text.trim(),
|
||||
'whatsapp': _whatsappCtrl.text.trim(),
|
||||
'notes': _notesCtrl.text.trim(),
|
||||
'reseller_enabled': _resellerEnabled,
|
||||
'updated_at': DateTime.now().toUtc().toIso8601String(),
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Gagal simpan store settings ke Supabase: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -58,38 +114,49 @@ class _PengaturanScreenState extends State<PengaturanScreen> {
|
||||
return const Center(child: CircularProgressIndicator(color: kAccent));
|
||||
}
|
||||
|
||||
final state = context.watch<AppState>();
|
||||
final isAdmin = state.isAdmin;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<AppState>().recordManualRefresh();
|
||||
await context.read<AppState>().refresh();
|
||||
_settingsSyncTimer?.cancel();
|
||||
_settingsSyncTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
if (!_loadingSettings && mounted) {
|
||||
_loadSettings();
|
||||
}
|
||||
});
|
||||
},
|
||||
color: kAccent,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
_SettingsListTile(
|
||||
icon: Icons.store_outlined,
|
||||
title: 'Pengaturan Toko',
|
||||
subtitle: _whatsappCtrl.text.isEmpty
|
||||
? '${_storeNameCtrl.text} • ${_currencyCtrl.text}'
|
||||
: '${_storeNameCtrl.text} • ${_currencyCtrl.text} • ${_whatsappCtrl.text}',
|
||||
onTap: () => _showStoreSettingsDialog(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SettingsListTile(
|
||||
icon: Icons.people_outline,
|
||||
title: 'Akun Kasir',
|
||||
subtitle: 'Tambah, edit, atau hapus akun kasir',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const KelolaKasirScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (isAdmin) ...[
|
||||
_SettingsListTile(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
title: 'Pengaturan Struk',
|
||||
subtitle: _whatsappCtrl.text.isEmpty
|
||||
? '${_storeNameCtrl.text} • ${_currencyCtrl.text}'
|
||||
: '${_storeNameCtrl.text} • ${_currencyCtrl.text} • ${_whatsappCtrl.text}',
|
||||
onTap: () => _showStoreSettingsDialog(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SettingsListTile(
|
||||
icon: Icons.people_outline,
|
||||
title: 'Akun Kasir',
|
||||
subtitle: 'Tambah, edit, atau hapus akun kasir',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const KelolaKasirScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
_SettingsListTile(
|
||||
icon: Icons.print_outlined,
|
||||
title: 'Printer Bluetooth',
|
||||
@@ -103,6 +170,35 @@ class _PengaturanScreenState extends State<PengaturanScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!isAdmin)
|
||||
_SettingsListTile(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
title: 'Info Struk',
|
||||
subtitle: _whatsappCtrl.text.isEmpty
|
||||
? '${_storeNameCtrl.text} • ${_currencyCtrl.text}'
|
||||
: '${_storeNameCtrl.text} • ${_currencyCtrl.text} • ${_whatsappCtrl.text}',
|
||||
onTap: null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: FutureBuilder<PackageInfo>(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final info = snapshot.data!;
|
||||
return Text(
|
||||
'Versi ${info.version} (${info.buildNumber})',
|
||||
style: const TextStyle(color: kMuted, fontSize: 12),
|
||||
);
|
||||
}
|
||||
return const Text(
|
||||
'Versi ...',
|
||||
style: TextStyle(color: kMuted, fontSize: 12),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -180,20 +276,27 @@ class _PengaturanScreenState extends State<PengaturanScreen> {
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
_storeNameCtrl.text = storeNameCtrl.text.trim();
|
||||
_currencyCtrl.text = currencyCtrl.text.trim();
|
||||
_whatsappCtrl.text = whatsappCtrl.text.trim();
|
||||
_notesCtrl.text = notesCtrl.text.trim();
|
||||
_resellerEnabled = resellerEnabled;
|
||||
await _saveSettings();
|
||||
if (ctx.mounted) {
|
||||
Navigator.pop(ctx, true);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.save_outlined, size: 18),
|
||||
label: const Text('Simpan'),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () async {
|
||||
_storeNameCtrl.text = storeNameCtrl.text.trim();
|
||||
_currencyCtrl.text = currencyCtrl.text.trim();
|
||||
_whatsappCtrl.text = whatsappCtrl.text.trim();
|
||||
_notesCtrl.text = notesCtrl.text.trim();
|
||||
_resellerEnabled = resellerEnabled;
|
||||
await _saveSettings();
|
||||
if (ctx.mounted) {
|
||||
Navigator.pop(ctx, true);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.save_outlined, size: 18),
|
||||
label: const Text('Simpan'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -212,6 +315,7 @@ class _PengaturanScreenState extends State<PengaturanScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settingsSyncTimer?.cancel();
|
||||
_storeNameCtrl.dispose();
|
||||
_currencyCtrl.dispose();
|
||||
_whatsappCtrl.dispose();
|
||||
@@ -224,12 +328,12 @@ class _SettingsListTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onTap;
|
||||
const _SettingsListTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
|
||||
@@ -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> {
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,11 +97,13 @@ class OfflineQueueService {
|
||||
final items = (transaction['items'] as List?) ?? [];
|
||||
|
||||
final saleId = sale['id'] as String;
|
||||
final storeId = sale['store_id'] as String?;
|
||||
final existing = await _withTimeout(
|
||||
_supabase
|
||||
.from('sales')
|
||||
.select('id, total_cost')
|
||||
.eq('id', saleId)
|
||||
.eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000')
|
||||
.maybeSingle(),
|
||||
'Check existing sale',
|
||||
);
|
||||
@@ -118,7 +120,8 @@ class OfflineQueueService {
|
||||
_supabase
|
||||
.from('sales')
|
||||
.update({'total_cost': sale['total_cost']})
|
||||
.eq('id', saleId),
|
||||
.eq('id', saleId)
|
||||
.eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000'),
|
||||
'Update sale total_cost',
|
||||
);
|
||||
}
|
||||
@@ -132,6 +135,7 @@ class OfflineQueueService {
|
||||
.from('products')
|
||||
.select()
|
||||
.eq('id', productId)
|
||||
.eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000')
|
||||
.maybeSingle(),
|
||||
'Load product for stock update',
|
||||
);
|
||||
@@ -142,7 +146,8 @@ class OfflineQueueService {
|
||||
_supabase
|
||||
.from('products')
|
||||
.update({'stock': newStock})
|
||||
.eq('id', productId),
|
||||
.eq('id', productId)
|
||||
.eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000'),
|
||||
'Update product stock',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,7 +177,6 @@ class PrinterService {
|
||||
]));
|
||||
}
|
||||
}
|
||||
bytes.addAll(generator.hr());
|
||||
if (kasirName != null && kasirName.isNotEmpty) {
|
||||
bytes.addAll(generator.text('Kasir: $kasirName',
|
||||
styles: const PosStyles(align: PosAlign.center)));
|
||||
@@ -195,12 +194,10 @@ class PrinterService {
|
||||
styles: const PosStyles(align: PosAlign.center)));
|
||||
}
|
||||
}
|
||||
bytes.addAll(generator.hr());
|
||||
bytes.addAll(generator.text(
|
||||
'Terima kasih!',
|
||||
styles: const PosStyles(align: PosAlign.center),
|
||||
));
|
||||
bytes.addAll(generator.feed(2));
|
||||
bytes.addAll(generator.cut());
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
static const Duration _timeout = Duration(seconds: 30);
|
||||
|
||||
static const String _currentStoreId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
Future<T> _withTimeout<T>(Future<T> future, [String? operation]) async {
|
||||
try {
|
||||
return await future.timeout(
|
||||
@@ -451,7 +453,7 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
Future<void> _loadProducts() async {
|
||||
final rows = await _withTimeout(
|
||||
_supabase.from('products').select().order('name'),
|
||||
_supabase.from('products').select().eq('store_id', _currentStoreId).order('name'),
|
||||
'Load products',
|
||||
);
|
||||
products = rows.map((e) => Product.fromJson(e)).toList();
|
||||
@@ -463,6 +465,7 @@ class AppState extends ChangeNotifier {
|
||||
_supabase
|
||||
.from('sales')
|
||||
.select()
|
||||
.eq('store_id', _currentStoreId)
|
||||
.order('created_at', ascending: false),
|
||||
'Load sales',
|
||||
);
|
||||
@@ -480,7 +483,7 @@ class AppState extends ChangeNotifier {
|
||||
Product(id: uid(), name: 'Indomie Dus', price: 95000, priceReseller: 90000, stock: 25),
|
||||
];
|
||||
await _withTimeout(
|
||||
_supabase.from('products').insert(seeds.map((e) => e.toJson()).toList()),
|
||||
_supabase.from('products').insert(seeds.map((e) => e.toJson()..['store_id'] = _currentStoreId).toList()),
|
||||
'Seed products',
|
||||
);
|
||||
products = seeds;
|
||||
@@ -517,7 +520,7 @@ class AppState extends ChangeNotifier {
|
||||
hargaPokok: hargaPokok,
|
||||
);
|
||||
await _withTimeout(
|
||||
_supabase.from('products').insert(product.toJson()),
|
||||
_supabase.from('products').insert(product.toJson()..['store_id'] = _currentStoreId),
|
||||
'Add product',
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -547,6 +550,7 @@ class AppState extends ChangeNotifier {
|
||||
'stock': stock,
|
||||
'min_stock': minStock,
|
||||
'harga_pokok': hargaPokok,
|
||||
'store_id': _currentStoreId,
|
||||
}).eq('id', id),
|
||||
'Update product',
|
||||
);
|
||||
@@ -564,7 +568,7 @@ class AppState extends ChangeNotifier {
|
||||
try {
|
||||
cart.removeWhere((c) => c.product.id == id);
|
||||
await _withTimeout(
|
||||
_supabase.from('products').delete().eq('id', id),
|
||||
_supabase.from('products').delete().eq('id', id).eq('store_id', _currentStoreId),
|
||||
'Delete product',
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -641,7 +645,7 @@ class AppState extends ChangeNotifier {
|
||||
discount: _discount,
|
||||
total: total,
|
||||
paid: paid,
|
||||
change: paid - total,
|
||||
change: paymentStatus == 'paid' ? (paid - total).clamp(0, double.infinity) : 0,
|
||||
totalCost: totalCost,
|
||||
paymentMethod: paymentMethod,
|
||||
paymentStatus: paymentStatus,
|
||||
@@ -652,11 +656,12 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
try {
|
||||
await _withTimeout(
|
||||
_supabase.from('sales').insert(sale.toJson()),
|
||||
_supabase.from('sales').insert(sale.toJson()..['store_id'] = _currentStoreId),
|
||||
'Create sale',
|
||||
);
|
||||
|
||||
for (final line in cart) {
|
||||
if (line.product.id.startsWith('manual-')) continue;
|
||||
try {
|
||||
final current =
|
||||
products.firstWhere((p) => p.id == line.product.id);
|
||||
@@ -664,7 +669,8 @@ class AppState extends ChangeNotifier {
|
||||
_supabase
|
||||
.from('products')
|
||||
.update({'stock': current.stock - line.qty})
|
||||
.eq('id', line.product.id),
|
||||
.eq('id', line.product.id)
|
||||
.eq('store_id', _currentStoreId),
|
||||
'Update stock',
|
||||
);
|
||||
} catch (_) {}
|
||||
@@ -703,7 +709,7 @@ class AppState extends ChangeNotifier {
|
||||
final sale = sales.firstWhere((s) => s.id == saleId);
|
||||
|
||||
await _withTimeout(
|
||||
_supabase.from('sales').update({'is_cancelled': true}).eq('id', saleId),
|
||||
_supabase.from('sales').update({'is_cancelled': true}).eq('id', saleId).eq('store_id', _currentStoreId),
|
||||
'Cancel sale',
|
||||
);
|
||||
|
||||
@@ -717,7 +723,8 @@ class AppState extends ChangeNotifier {
|
||||
_supabase
|
||||
.from('products')
|
||||
.update({'stock': product.stock + item.qty})
|
||||
.eq('id', product.id),
|
||||
.eq('id', product.id)
|
||||
.eq('store_id', _currentStoreId),
|
||||
'Restore stock',
|
||||
);
|
||||
}
|
||||
@@ -745,7 +752,8 @@ class AppState extends ChangeNotifier {
|
||||
'paid': newPaid,
|
||||
'due_amount': newDue,
|
||||
'payment_status': newStatus,
|
||||
}).eq('id', saleId),
|
||||
'change': (newPaid - sale.total).clamp(0, double.infinity),
|
||||
}).eq('id', saleId).eq('store_id', _currentStoreId),
|
||||
'Record payment',
|
||||
);
|
||||
await _loadSales();
|
||||
|
||||
Reference in New Issue
Block a user