761 lines
21 KiB
Dart
761 lines
21 KiB
Dart
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();
|
|
}
|
|
}
|
|
}
|