946 lines
28 KiB
Dart
946 lines
28 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;
|
|
|
|
// Stream notifikasi transaksi baru (dipakai UI untuk banner+suara+getar).
|
|
// Diisi setiap createSale sukses DAN setiap realtime INSERT dari HP lain.
|
|
final StreamController<Sale> newSaleNotifications =
|
|
StreamController<Sale>.broadcast();
|
|
final Set<String> _notifiedSaleIds = {};
|
|
|
|
// Jumlah transaksi baru yang belum dilihat admin (badge di tab Riwayat).
|
|
int unseenSalesCount = 0;
|
|
|
|
void markSalesSeen() {
|
|
if (unseenSalesCount != 0) {
|
|
unseenSalesCount = 0;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> retryProducts() => refresh();
|
|
|
|
// 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);
|
|
|
|
static const String _currentStoreId = '00000000-0000-0000-0000-000000000000';
|
|
|
|
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;
|
|
bool dataReady = false;
|
|
bool productsLoading = false;
|
|
String? productsError;
|
|
|
|
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;
|
|
|
|
/// True jika session auth mati total (refresh token pun kedaluwarsa).
|
|
/// UI (kasir) memakai ini untuk menampilkan info "sesi habis" +
|
|
/// tombol login ulang, bukan list kosong yang membingungkan.
|
|
bool _sessionExpired = false;
|
|
bool get sessionExpired => _sessionExpired;
|
|
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();
|
|
_checkNewSales();
|
|
}
|
|
|
|
bool _salesFirstLoad = true;
|
|
|
|
/// Deteksi transaksi baru dari polling/refresh dan kirim ke stream
|
|
/// notifikasi. Load pertama dilewati agar tidak spam saat start.
|
|
void _checkNewSales() {
|
|
// Hanya admin yang perlu notifikasi — kasir tidak (mereka yang
|
|
// transaksi, jadi sudah tahu).
|
|
if (userRole != 'admin') {
|
|
for (final s in sales) {
|
|
_notifiedSaleIds.add(s.id);
|
|
}
|
|
_salesFirstLoad = false;
|
|
return;
|
|
}
|
|
final now = DateTime.now();
|
|
bool newSeen = false;
|
|
for (final s in sales) {
|
|
if (!_notifiedSaleIds.contains(s.id)) {
|
|
_notifiedSaleIds.add(s.id);
|
|
// Lewati transaksi lama (>5 menit) agar tidak spam notif saat start.
|
|
// Lewati juga transaksi sendiri — admin tahu kalau dia yang jualan.
|
|
if (!_salesFirstLoad &&
|
|
s.userId != userId &&
|
|
now.difference(s.date).inMinutes.abs() <= 5) {
|
|
newSeen = true;
|
|
unseenSalesCount++;
|
|
if (!newSaleNotifications.isClosed) {
|
|
newSaleNotifications.add(s);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (newSeen) notifyListeners();
|
|
_salesFirstLoad = false;
|
|
}
|
|
|
|
/// Refresh produk + riwayat dengan retry terbatas untuk kegagalan sementara.
|
|
/// Nyalakan indikator loading hanya saat data pertama belum siap, supaya
|
|
/// Kasir bisa menampilkan status memuat/gagal/kosong dengan jelas.
|
|
/// Nonaktifkan indikator apa pun yang menyebabkan masalah refresh berulang.
|
|
/// Kirim sinyal hanya pada pemuatan awal; polling latar tidak mengganggu.
|
|
Future<void> refresh({bool notifyLoading = true, int retries = 1}) async {
|
|
final authOk = await _ensureSessionAndAuth();
|
|
if (!authOk) {
|
|
// Auth mati: jangan lanjut query (pasti 401). Cukup notify agar
|
|
// AuthGate redirect ke LoginScreen.
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
final showProductsLoading = notifyLoading && !dataReady;
|
|
if (showProductsLoading) {
|
|
productsLoading = true;
|
|
notifyListeners();
|
|
}
|
|
try {
|
|
await _loadProducts();
|
|
await _loadSales();
|
|
dataReady = true;
|
|
// Server terbukti reachable → coba kirim antrean transaksi offline.
|
|
await _offlineQueue.retryNow();
|
|
} catch (e) {
|
|
productsError ??= e.toString();
|
|
if (retries > 0) {
|
|
// Coba sekali lagi untuk kegagalan sementara (misal koneksi putus).
|
|
productsError = null;
|
|
await Future<void>.delayed(const Duration(seconds: 1));
|
|
await refresh(notifyLoading: notifyLoading, retries: retries - 1);
|
|
return;
|
|
}
|
|
debugPrint('Gagal refresh: $e');
|
|
} finally {
|
|
if (showProductsLoading) {
|
|
productsLoading = false;
|
|
}
|
|
if (notifyLoading) {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
}
|
|
|
|
final OfflineQueueService _offlineQueue = OfflineQueueService();
|
|
|
|
/// ID transaksi yang masih dalam antrean offline (belum terkirim ke server).
|
|
Set<String> get pendingSaleIds => _offlineQueue.pendingSaleIds;
|
|
|
|
/// True jika transaksi dengan [saleId] masih menunggu sinkronisasi.
|
|
bool isSalePending(String? saleId) => _offlineQueue.isPending(saleId);
|
|
|
|
/// Jumlah transaksi offline yang belum tersinkron (badge di Riwayat).
|
|
int get pendingOfflineCount => _offlineQueue.pendingSaleIds.length;
|
|
|
|
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 {
|
|
final authOk = await _ensureSessionAndAuth();
|
|
if (!authOk) {
|
|
// Session mati total (refresh token ikut kadaluwarsa). Jangan lanjut:
|
|
// _loadProducts() akan timeout 30s + 401, bakal menjebak loading walau
|
|
// produk sendiri masih ada. Tandai sessionExpired, jalankan polling
|
|
// saja (real-time masih bisa update), dan biarkan UI tampil
|
|
// "login ulang".
|
|
_sessionExpired = true;
|
|
startPolling();
|
|
error = 'Sesi auth belum tersedia, silakan login ulang.';
|
|
} else {
|
|
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> _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;
|
|
_sessionExpired = false;
|
|
dataReady = false;
|
|
productsLoading = true;
|
|
productsError = null;
|
|
notifyListeners();
|
|
await _loadProfile();
|
|
await refresh();
|
|
return null;
|
|
} on AuthException catch (e) {
|
|
productsLoading = false;
|
|
notifyListeners();
|
|
return e.message;
|
|
} catch (e) {
|
|
productsLoading = false;
|
|
notifyListeners();
|
|
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;
|
|
products = [];
|
|
dataReady = false;
|
|
productsLoading = false;
|
|
productsError = null;
|
|
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;
|
|
_sessionExpired = false;
|
|
_loadProfile();
|
|
} else {
|
|
// Sesi mati total (logout / refresh token ikut kedaluwarsa):
|
|
// tandai agar UI bisa menampilkan pesan "sesi habis", bukan diam.
|
|
userId = null;
|
|
userRole = null;
|
|
_profile = null;
|
|
_sessionExpired = true;
|
|
}
|
|
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 {
|
|
try {
|
|
final rows = await _withTimeout(
|
|
_supabase.from('products').select().eq('store_id', _currentStoreId).order('name'),
|
|
'Load products',
|
|
);
|
|
final loaded = rows.map((e) => Product.fromJson(e)).toList();
|
|
// Hasil kosong dari server JANGAN menimpa data lama — itu tanda
|
|
// query gagal diam-diam (misal JWT expired), bukan toko kehabisan produk.
|
|
if (loaded.isNotEmpty) {
|
|
products = loaded;
|
|
} else if (products.isEmpty) {
|
|
_seed();
|
|
}
|
|
} catch (e) {
|
|
// Jangan crash refresh: produk lama tetap ditampilkan, error tercatat di log.
|
|
debugPrint('Gagal load products: $e');
|
|
}
|
|
}
|
|
|
|
/// Cek langkah demi langkah sampai session valid atau mati total.
|
|
/// Stop segera kalau auth mati, jangan tunggu timeout 30s per request.
|
|
Future<bool> _ensureSessionAndAuth() async {
|
|
final session = _supabase.auth.currentSession;
|
|
if (session != null && !session.isExpired) {
|
|
return true;
|
|
}
|
|
// Session kadaluwarsa: coba refresh token dulu.
|
|
try {
|
|
final res = await _supabase.auth.refreshSession();
|
|
if (res.session != null && !res.session!.isExpired) {
|
|
return true;
|
|
}
|
|
// Refresh jalan tapi session tetap tak valid → auth benar-benar mati.
|
|
_sessionExpired = true;
|
|
} on AuthException {
|
|
// Token refresh ditolak server (invalid/kedaluwarsa) → auth mati.
|
|
_sessionExpired = true;
|
|
} catch (_) {
|
|
// Error jaringan/timeout: JANGAN tandai sessionExpired (bisa network
|
|
// blip). Data gagal load sementara, session belum dianggap mati.
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Future<void> _loadSales() async {
|
|
final rows = await _withTimeout(
|
|
_supabase
|
|
.from('sales')
|
|
.select()
|
|
.eq('store_id', _currentStoreId)
|
|
.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()..['store_id'] = _currentStoreId).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,
|
|
{String? imagePath}) 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,
|
|
imagePath: imagePath,
|
|
);
|
|
await _withTimeout(
|
|
_supabase.from('products').insert(product.toJson()..['store_id'] = _currentStoreId),
|
|
'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,
|
|
{String? imagePath}) 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,
|
|
'store_id': _currentStoreId,
|
|
'image_path': imagePath,
|
|
}).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).eq('store_id', _currentStoreId),
|
|
'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: paymentStatus == 'paid' ? (paid - total).clamp(0, double.infinity) : 0,
|
|
totalCost: totalCost,
|
|
paymentMethod: paymentMethod,
|
|
paymentStatus: paymentStatus,
|
|
dueAmount: dueAmount,
|
|
customerPhone: customerPhone,
|
|
splitPayment: splitPayment,
|
|
);
|
|
|
|
try {
|
|
await _withTimeout(
|
|
_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);
|
|
await _withTimeout(
|
|
_supabase
|
|
.from('products')
|
|
.update({'stock': current.stock - line.qty})
|
|
.eq('id', line.product.id)
|
|
.eq('store_id', _currentStoreId),
|
|
'Update stock',
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
await _loadProducts();
|
|
await _loadSales();
|
|
|
|
cart.clear();
|
|
_discount = 0;
|
|
notifyListeners();
|
|
|
|
// Tandai ID transaksi lokal agar tidak di-notify lagi via polling
|
|
// (admin yang jualan sendiri tidak perlu notif transaksinya sendiri).
|
|
_notifiedSaleIds.add(sale.id);
|
|
|
|
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).eq('store_id', _currentStoreId),
|
|
'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)
|
|
.eq('store_id', _currentStoreId),
|
|
'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,
|
|
'change': (newPaid - sale.total).clamp(0, double.infinity),
|
|
}).eq('id', saleId).eq('store_id', _currentStoreId),
|
|
'Record payment',
|
|
);
|
|
await _loadSales();
|
|
notifyListeners();
|
|
return null;
|
|
} on PostgrestException catch (e) {
|
|
return e.message;
|
|
} catch (e) {
|
|
return e.toString();
|
|
}
|
|
}
|
|
}
|