chore: initial commit Dutra Store POS app
This commit is contained in:
760
lib/state/app_state.dart
Normal file
760
lib/state/app_state.dart
Normal file
@@ -0,0 +1,760 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../models/product.dart';
|
||||
import '../models/sale.dart';
|
||||
import '../models/user_profile.dart';
|
||||
import '../services/offline_queue_service.dart';
|
||||
import '../utils/format.dart';
|
||||
import '../config.dart';
|
||||
|
||||
class CartLine {
|
||||
final Product product;
|
||||
int qty;
|
||||
bool isReseller;
|
||||
|
||||
CartLine({
|
||||
required this.product,
|
||||
this.qty = 1,
|
||||
this.isReseller = false,
|
||||
});
|
||||
|
||||
double get price => isReseller ? product.priceReseller : product.price;
|
||||
}
|
||||
|
||||
class AppState extends ChangeNotifier {
|
||||
final SupabaseClient _supabase = Supabase.instance.client;
|
||||
|
||||
// Client dengan service_role key untuk operasi admin (buat/edit/hapus akun kasir).
|
||||
// PERINGATAN: key ini bypass RLS — hanya untuk app POS di device terpercaya.
|
||||
final SupabaseClient _admin = SupabaseClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
static const Duration _timeout = Duration(seconds: 30);
|
||||
|
||||
Future<T> _withTimeout<T>(Future<T> future, [String? operation]) async {
|
||||
try {
|
||||
return await future.timeout(
|
||||
_timeout,
|
||||
onTimeout: () => throw TimeoutException('${operation ?? 'Operasi'} timeout setelah $_timeout'),
|
||||
);
|
||||
} on TimeoutException catch (e) {
|
||||
debugPrint('Supabase timeout: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
List<Product> products = [];
|
||||
List<Sale> sales = [];
|
||||
List<CartLine> cart = [];
|
||||
List<Map<String, dynamic>> kasirAccounts = [];
|
||||
bool loading = true;
|
||||
String? error;
|
||||
|
||||
static const int _pageSize = 30;
|
||||
int _visibleProductCount = _pageSize;
|
||||
int _visibleSalesCount = _pageSize;
|
||||
|
||||
List<Product> get displayedProducts =>
|
||||
products.take(_visibleProductCount).toList();
|
||||
List<Sale> get displayedSales =>
|
||||
sales.take(_visibleSalesCount).toList();
|
||||
|
||||
int get visibleProductCount => _visibleProductCount;
|
||||
int get visibleSalesCount => _visibleSalesCount;
|
||||
int get pageSize => _pageSize;
|
||||
|
||||
bool get hasMoreProducts => _visibleProductCount < products.length;
|
||||
bool get hasMoreSales => _visibleSalesCount < sales.length;
|
||||
|
||||
void resetPagination() {
|
||||
_visibleProductCount = _pageSize;
|
||||
_visibleSalesCount = _pageSize;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void loadMoreProducts() {
|
||||
if (!hasMoreProducts) return;
|
||||
_visibleProductCount = (_visibleProductCount + _pageSize).clamp(0, products.length);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void loadMoreSales() {
|
||||
if (!hasMoreSales) return;
|
||||
_visibleSalesCount = (_visibleSalesCount + _pageSize).clamp(0, sales.length);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String customerType = 'umum';
|
||||
bool resellerEnabled = true;
|
||||
String? userId;
|
||||
String? userRole;
|
||||
UserProfile? _profile;
|
||||
final Map<String, String> _profileNames = {};
|
||||
|
||||
double _discount = 0;
|
||||
|
||||
bool get isAdmin => userRole == 'admin';
|
||||
bool get isKasir => userRole == 'kasir';
|
||||
bool get isLoggedIn => userId != null;
|
||||
|
||||
String getProfileName(String? uid) {
|
||||
if (uid == null) return 'Tidak diketahui';
|
||||
if (uid == userId) return _profile?.fullName ?? 'Admin';
|
||||
return _profileNames[uid] ?? 'Kasir';
|
||||
}
|
||||
|
||||
AppState() {
|
||||
_init();
|
||||
}
|
||||
|
||||
Timer? _pollTimer;
|
||||
static const Duration _pollInterval = Duration(minutes: 1);
|
||||
DateTime? _lastManualRefresh;
|
||||
|
||||
void recordManualRefresh() {
|
||||
_lastManualRefresh = DateTime.now();
|
||||
}
|
||||
|
||||
void startPolling() {
|
||||
stopPolling();
|
||||
_pollTimer = Timer.periodic(_pollInterval, (_) => _poll());
|
||||
}
|
||||
|
||||
void stopPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
if (_lastManualRefresh != null) {
|
||||
final elapsed = DateTime.now().difference(_lastManualRefresh!);
|
||||
if (elapsed < const Duration(seconds: 15)) return;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
await _loadProducts();
|
||||
await _loadSales();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final OfflineQueueService _offlineQueue = OfflineQueueService();
|
||||
|
||||
double get subtotal =>
|
||||
cart.fold(0, (sum, line) => sum + line.price * line.qty);
|
||||
|
||||
double get discount => _discount;
|
||||
|
||||
double get total => (subtotal - _discount).clamp(0, double.infinity);
|
||||
|
||||
bool get canCheckout => cart.isNotEmpty && total > 0;
|
||||
|
||||
/* ---------- Init & Realtime ---------- */
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
await _initAuth();
|
||||
await _loadProducts();
|
||||
await _loadSales();
|
||||
await loadKasirAccounts();
|
||||
await _loadProfileNames();
|
||||
await _loadSettings();
|
||||
_subscribe();
|
||||
await _seedAdminIfEmpty();
|
||||
await _offlineQueue.init();
|
||||
startPolling();
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
}
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
resellerEnabled = prefs.getBool('reseller_enabled') ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setResellerEnabled(bool value) async {
|
||||
resellerEnabled = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('reseller_enabled', value);
|
||||
if (!value) {
|
||||
setCustomerType('umum');
|
||||
for (final line in cart) {
|
||||
if (line.isReseller) toggleLineMode(line.product.id);
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _initAuth() async {
|
||||
final session = _supabase.auth.currentSession;
|
||||
if (session != null) {
|
||||
userId = session.user.id;
|
||||
await _loadProfile();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
if (userId == null) return;
|
||||
try {
|
||||
final data = await _supabase
|
||||
.from('profiles')
|
||||
.select()
|
||||
.eq('id', userId!)
|
||||
.maybeSingle();
|
||||
if (data != null) {
|
||||
_profile = UserProfile.fromJson(data);
|
||||
userRole = _profile!.role;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _seedAdminIfEmpty() async {
|
||||
try {
|
||||
final count = await _withTimeout(
|
||||
_admin.from('profiles').select().count(),
|
||||
'Check admin profiles',
|
||||
);
|
||||
if (count.count == 0) {
|
||||
await _withTimeout(
|
||||
_admin.auth.admin.createUser(
|
||||
AdminUserAttributes(email: 'admin@posgrosir.local', password: 'admin123'),
|
||||
),
|
||||
'Create default admin',
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<String?> login(String email, String password) async {
|
||||
try {
|
||||
final res = await _withTimeout(
|
||||
_supabase.auth.signInWithPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
),
|
||||
'Login',
|
||||
);
|
||||
userId = res.user?.id;
|
||||
await _loadProfile();
|
||||
notifyListeners();
|
||||
return null;
|
||||
} on AuthException catch (e) {
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Reserved for future self-service signup flow.
|
||||
// Currently unused per PRD §5.3: all devices must be pre-provisioned by admin.
|
||||
Future<String?> signup(String email, String password) async {
|
||||
try {
|
||||
final res = await _withTimeout(
|
||||
_supabase.auth.signUp(
|
||||
email: email,
|
||||
password: password,
|
||||
),
|
||||
'Signup',
|
||||
);
|
||||
userId = res.user?.id;
|
||||
await _loadProfile();
|
||||
notifyListeners();
|
||||
return null;
|
||||
} on AuthException catch (e) {
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _withTimeout(_supabase.auth.signOut(), 'Logout');
|
||||
userId = null;
|
||||
userRole = null;
|
||||
_profile = null;
|
||||
cart.clear();
|
||||
_discount = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String?> createKasir(String email, String password, String? name) async {
|
||||
try {
|
||||
final res = await _withTimeout(
|
||||
_admin.auth.admin.createUser(
|
||||
AdminUserAttributes(email: email, password: password, emailConfirm: true),
|
||||
),
|
||||
'Create kasir user',
|
||||
);
|
||||
final newUserId = res.user?.id;
|
||||
if (newUserId != null) {
|
||||
await _withTimeout(
|
||||
_admin.from('profiles').upsert({
|
||||
'id': newUserId,
|
||||
'role': 'kasir',
|
||||
'full_name': name,
|
||||
}),
|
||||
'Create kasir profile',
|
||||
);
|
||||
await loadKasirAccounts();
|
||||
}
|
||||
return null;
|
||||
} on AuthException catch (e) {
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteKasir(String id) async {
|
||||
try {
|
||||
await _withTimeout(
|
||||
_admin.from('profiles').delete().eq('id', id),
|
||||
'Delete kasir profile',
|
||||
);
|
||||
await _withTimeout(
|
||||
_admin.auth.admin.deleteUser(id),
|
||||
'Delete kasir user',
|
||||
);
|
||||
await loadKasirAccounts();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<String?> updateKasir(String id, String email, String? name, String? password) async {
|
||||
try {
|
||||
final updateData = <String, dynamic>{
|
||||
'full_name': name,
|
||||
};
|
||||
|
||||
if (password != null && password.isNotEmpty) {
|
||||
await _withTimeout(
|
||||
_admin.auth.admin.updateUserById(
|
||||
id,
|
||||
attributes: AdminUserAttributes(password: password),
|
||||
),
|
||||
'Update kasir password',
|
||||
);
|
||||
}
|
||||
|
||||
await _withTimeout(
|
||||
_admin.from('profiles').update(updateData).eq('id', id),
|
||||
'Update kasir profile',
|
||||
);
|
||||
await loadKasirAccounts();
|
||||
return null;
|
||||
} on AuthException catch (e) {
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadKasirAccounts() async {
|
||||
try {
|
||||
final rows = await _withTimeout(
|
||||
_admin
|
||||
.from('profiles')
|
||||
.select()
|
||||
.eq('role', 'kasir'),
|
||||
'Load kasir accounts',
|
||||
);
|
||||
|
||||
Map<String, String> emails = {};
|
||||
try {
|
||||
final usersRes = await _withTimeout(
|
||||
_admin.auth.admin.listUsers(),
|
||||
'List auth users',
|
||||
);
|
||||
for (final u in usersRes) {
|
||||
if (u.email != null && u.email!.isNotEmpty) {
|
||||
emails[u.id] = u.email!;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
kasirAccounts = rows.map((r) {
|
||||
final map = Map<String, dynamic>.from(r);
|
||||
if ((map['email'] == null || map['email'].toString().isEmpty) &&
|
||||
emails.containsKey(map['id'])) {
|
||||
map['email'] = emails[map['id']];
|
||||
}
|
||||
if ((map['full_name'] == null || map['full_name'].toString().isEmpty) &&
|
||||
map['email'] != null &&
|
||||
map['email'].toString().isNotEmpty) {
|
||||
map['full_name'] = map['email'].toString().split('@').first;
|
||||
}
|
||||
return map;
|
||||
}).toList();
|
||||
notifyListeners();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _loadProfileNames() async {
|
||||
try {
|
||||
final rows = await _withTimeout(
|
||||
_admin.from('profiles').select('id,full_name,email'),
|
||||
'Load profile names',
|
||||
);
|
||||
_profileNames.clear();
|
||||
for (final r in rows) {
|
||||
final map = Map<String, dynamic>.from(r);
|
||||
final name = (map['full_name']?.toString().trim().isEmpty == true
|
||||
? map['email']?.toString().split('@').first
|
||||
: map['full_name']?.toString().trim()) ??
|
||||
'';
|
||||
if (name.isNotEmpty) {
|
||||
_profileNames[map['id'] as String] = name;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
_supabase.auth.onAuthStateChange.listen((event) {
|
||||
if (event.session != null) {
|
||||
userId = event.session!.user.id;
|
||||
_loadProfile();
|
||||
} else {
|
||||
userId = null;
|
||||
userRole = null;
|
||||
_profile = null;
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
_supabase.from('products').stream(primaryKey: ['id']).listen(
|
||||
(rows) {
|
||||
products = rows.map((e) => Product.fromJson(e)).toList();
|
||||
notifyListeners();
|
||||
},
|
||||
onError: (e) {
|
||||
debugPrint('REALTIME products stream error: $e');
|
||||
},
|
||||
);
|
||||
|
||||
_supabase.from('sales').stream(primaryKey: ['id']).listen(
|
||||
(rows) {
|
||||
sales = rows.map((e) => Sale.fromJson(e)).toList();
|
||||
sales.sort((a, b) => b.date.compareTo(a.date));
|
||||
notifyListeners();
|
||||
},
|
||||
onError: (e) {
|
||||
debugPrint('REALTIME sales stream error: $e');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadProducts() async {
|
||||
final rows = await _withTimeout(
|
||||
_supabase.from('products').select().order('name'),
|
||||
'Load products',
|
||||
);
|
||||
products = rows.map((e) => Product.fromJson(e)).toList();
|
||||
if (products.isEmpty) _seed();
|
||||
}
|
||||
|
||||
Future<void> _loadSales() async {
|
||||
final rows = await _withTimeout(
|
||||
_supabase
|
||||
.from('sales')
|
||||
.select()
|
||||
.order('created_at', ascending: false),
|
||||
'Load sales',
|
||||
);
|
||||
sales = rows.map((e) => Sale.fromJson(e)).toList();
|
||||
sales.sort((a, b) => b.date.compareTo(a.date));
|
||||
}
|
||||
|
||||
Future<void> _seed() async {
|
||||
final seeds = [
|
||||
Product(id: uid(), name: 'Beras 5kg', price: 58000, priceReseller: 55000, stock: 50),
|
||||
Product(id: uid(), name: 'Minyak 2L', price: 32000, priceReseller: 30000, stock: 40),
|
||||
Product(id: uid(), name: 'Gula 1kg', price: 14000, priceReseller: 13000, stock: 60),
|
||||
Product(id: uid(), name: 'Teh Botol', price: 3600, priceReseller: 3200, stock: 120),
|
||||
Product(id: uid(), name: 'Sabun Cuci', price: 8500, priceReseller: 7800, stock: 35),
|
||||
Product(id: uid(), name: 'Indomie Dus', price: 95000, priceReseller: 90000, stock: 25),
|
||||
];
|
||||
await _withTimeout(
|
||||
_supabase.from('products').insert(seeds.map((e) => e.toJson()).toList()),
|
||||
'Seed products',
|
||||
);
|
||||
products = seeds;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/* ---------- Tipe Pembeli ---------- */
|
||||
void setCustomerType(String type) {
|
||||
customerType = type;
|
||||
final reseller = type == 'reseller';
|
||||
for (final c in cart) {
|
||||
c.isReseller = reseller;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/* ---------- Produk ---------- */
|
||||
Future<String?> addProduct(String name, String barcode, double price,
|
||||
double priceReseller, int stock, int minStock, double hargaPokok) async {
|
||||
try {
|
||||
final trimmed = barcode.trim();
|
||||
if (trimmed.isNotEmpty &&
|
||||
products.any((p) => p.barcode.trim().toLowerCase() == trimmed.toLowerCase())) {
|
||||
return 'Barcode sudah digunakan oleh produk lain';
|
||||
}
|
||||
final product = Product(
|
||||
id: uid(),
|
||||
name: name,
|
||||
barcode: trimmed,
|
||||
price: price,
|
||||
priceReseller: priceReseller,
|
||||
stock: stock,
|
||||
minStock: minStock,
|
||||
hargaPokok: hargaPokok,
|
||||
);
|
||||
await _withTimeout(
|
||||
_supabase.from('products').insert(product.toJson()),
|
||||
'Add product',
|
||||
);
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
try {
|
||||
await _loadProducts();
|
||||
} catch (_) {}
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String?> updateProduct(String id, String name, String barcode,
|
||||
double price, double priceReseller, int stock, int minStock, double hargaPokok) async {
|
||||
try {
|
||||
final trimmed = barcode.trim();
|
||||
if (trimmed.isNotEmpty &&
|
||||
products.any((p) => p.id != id && p.barcode.trim().toLowerCase() == trimmed.toLowerCase())) {
|
||||
return 'Barcode sudah digunakan oleh produk lain';
|
||||
}
|
||||
await _withTimeout(
|
||||
_supabase.from('products').update({
|
||||
'name': name,
|
||||
'barcode': trimmed,
|
||||
'price': price,
|
||||
'price_reseller': priceReseller,
|
||||
'stock': stock,
|
||||
'min_stock': minStock,
|
||||
'harga_pokok': hargaPokok,
|
||||
}).eq('id', id),
|
||||
'Update product',
|
||||
);
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
try {
|
||||
await _loadProducts();
|
||||
} catch (_) {}
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String?> deleteProduct(String id) async {
|
||||
try {
|
||||
cart.removeWhere((c) => c.product.id == id);
|
||||
await _withTimeout(
|
||||
_supabase.from('products').delete().eq('id', id),
|
||||
'Delete product',
|
||||
);
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
try {
|
||||
await _loadProducts();
|
||||
} catch (_) {}
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- Cart ---------- */
|
||||
void addToCart(Product product) {
|
||||
if (product.stock <= 0) return;
|
||||
final line = cart.firstWhere(
|
||||
(c) => c.product.id == product.id,
|
||||
orElse: () => CartLine(product: product),
|
||||
);
|
||||
if (cart.contains(line)) {
|
||||
if (line.qty < product.stock) line.qty++;
|
||||
} else {
|
||||
line.isReseller = customerType == 'reseller';
|
||||
cart.add(line);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void changeQty(String productId, int delta) {
|
||||
final line = cart.firstWhere((c) => c.product.id == productId);
|
||||
line.qty += delta;
|
||||
if (line.qty <= 0) {
|
||||
cart.remove(line);
|
||||
} else if (line.qty > line.product.stock) {
|
||||
line.qty = line.product.stock;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleLineMode(String productId) {
|
||||
final line = cart.firstWhere((c) => c.product.id == productId);
|
||||
line.isReseller = !line.isReseller;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setDiscount(double value) {
|
||||
_discount = value < 0 ? 0 : value.clamp(0, subtotal);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearCart() {
|
||||
cart.clear();
|
||||
_discount = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/* ---------- Checkout ---------- */
|
||||
Future<Sale> checkout(double paid, {String paymentMethod = 'tunai', Map<String, double>? splitPayment, String paymentStatus = 'paid', double dueAmount = 0.0, String? customerPhone}) async {
|
||||
final totalCost = cart.fold(0.0, (sum, c) => sum + (c.product.hargaPokok * c.qty));
|
||||
final sale = Sale(
|
||||
id: uid(),
|
||||
date: DateTime.now(),
|
||||
userId: userId,
|
||||
items: cart
|
||||
.map((c) => SaleItem(
|
||||
productId: c.product.id,
|
||||
name: c.product.name,
|
||||
price: c.price,
|
||||
qty: c.qty,
|
||||
isReseller: c.isReseller,
|
||||
))
|
||||
.toList(),
|
||||
subtotal: subtotal,
|
||||
discount: _discount,
|
||||
total: total,
|
||||
paid: paid,
|
||||
change: paid - total,
|
||||
totalCost: totalCost,
|
||||
paymentMethod: paymentMethod,
|
||||
paymentStatus: paymentStatus,
|
||||
dueAmount: dueAmount,
|
||||
customerPhone: customerPhone,
|
||||
splitPayment: splitPayment,
|
||||
);
|
||||
|
||||
try {
|
||||
await _withTimeout(
|
||||
_supabase.from('sales').insert(sale.toJson()),
|
||||
'Create sale',
|
||||
);
|
||||
|
||||
for (final line in cart) {
|
||||
try {
|
||||
final current =
|
||||
products.firstWhere((p) => p.id == line.product.id);
|
||||
await _withTimeout(
|
||||
_supabase
|
||||
.from('products')
|
||||
.update({'stock': current.stock - line.qty})
|
||||
.eq('id', line.product.id),
|
||||
'Update stock',
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
await _loadProducts();
|
||||
await _loadSales();
|
||||
|
||||
cart.clear();
|
||||
_discount = 0;
|
||||
notifyListeners();
|
||||
return sale;
|
||||
} catch (e) {
|
||||
await _offlineQueue.addToQueue({
|
||||
'sale': sale.toJson(),
|
||||
'items': cart.map((c) {
|
||||
final lineTotalCost = c.product.hargaPokok * c.qty;
|
||||
return {
|
||||
'product_id': c.product.id,
|
||||
'qty': c.qty,
|
||||
'price': c.price,
|
||||
'total_cost': lineTotalCost,
|
||||
};
|
||||
}).toList(),
|
||||
});
|
||||
cart.clear();
|
||||
_discount = 0;
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Retur / Pembatalan ---------- */
|
||||
Future<String?> cancelSale(String saleId) async {
|
||||
try {
|
||||
final sale = sales.firstWhere((s) => s.id == saleId);
|
||||
|
||||
await _withTimeout(
|
||||
_supabase.from('sales').update({'is_cancelled': true}).eq('id', saleId),
|
||||
'Cancel sale',
|
||||
);
|
||||
|
||||
for (final item in sale.items) {
|
||||
final product = products.firstWhere(
|
||||
(p) => p.id == item.productId,
|
||||
orElse: () => Product(id: '', name: '', price: 0, stock: 0, hargaPokok: 0),
|
||||
);
|
||||
if (product.id.isNotEmpty) {
|
||||
await _withTimeout(
|
||||
_supabase
|
||||
.from('products')
|
||||
.update({'stock': product.stock + item.qty})
|
||||
.eq('id', product.id),
|
||||
'Restore stock',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _loadProducts();
|
||||
await _loadSales();
|
||||
notifyListeners();
|
||||
return null;
|
||||
} on PostgrestException catch (e) {
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> recordPayment(String saleId, double amount) async {
|
||||
try {
|
||||
final sale = sales.firstWhere((s) => s.id == saleId);
|
||||
final newPaid = sale.paid + amount;
|
||||
final newDue = (sale.dueAmount - amount).clamp(0, double.infinity);
|
||||
final newStatus = newDue <= 0 ? 'paid' : 'partial';
|
||||
await _withTimeout(
|
||||
_supabase.from('sales').update({
|
||||
'paid': newPaid,
|
||||
'due_amount': newDue,
|
||||
'payment_status': newStatus,
|
||||
}).eq('id', saleId),
|
||||
'Record payment',
|
||||
);
|
||||
await _loadSales();
|
||||
notifyListeners();
|
||||
return null;
|
||||
} on PostgrestException catch (e) {
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user