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

@@ -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,

View File

@@ -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',
),
]);
}

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',

View File

@@ -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

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> {
),
],
),
);
},
);
}

View File

@@ -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',
);
}

View File

@@ -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;
}

View File

@@ -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();