569 lines
17 KiB
Dart
569 lines
17 KiB
Dart
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:firebase_auth/firebase_auth.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
class RiwayatController extends GetxController {
|
|
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
|
final FirebaseAuth _auth = FirebaseAuth.instance;
|
|
List<Map<String, dynamic>> _wilayahData = [];
|
|
|
|
var monthlySummary = <Map<String, dynamic>>[].obs;
|
|
|
|
var isLoading = false.obs;
|
|
var dailySummary = <Map<String, dynamic>>[].obs;
|
|
var dailyDetail = <Map<String, dynamic>>[].obs;
|
|
|
|
/// 🔹 Ambil data ringkasan resi per hari
|
|
Future<void> fetchResiData({DateTime? start, DateTime? end}) async {
|
|
try {
|
|
isLoading.value = true;
|
|
final user = _auth.currentUser;
|
|
if (user == null) return;
|
|
final userId = user.uid;
|
|
|
|
final DateTime now = DateTime.now();
|
|
final DateTime startOfMonth = start ?? DateTime(now.year, now.month, 1);
|
|
final DateTime endOfMonth =
|
|
end ?? DateTime(now.year, now.month + 1, 0, 23, 59, 59);
|
|
|
|
final snapshot = await _firestore
|
|
.collection('resis')
|
|
.where('store_id', isEqualTo: userId)
|
|
.where('created_at', isGreaterThanOrEqualTo: startOfMonth)
|
|
.where('created_at', isLessThanOrEqualTo: endOfMonth)
|
|
.get();
|
|
|
|
final data = snapshot.docs.map((doc) {
|
|
final resi = doc.data();
|
|
final createdAt = (resi['created_at'] as Timestamp).toDate();
|
|
final tanggalKey = DateFormat('yyyy-MM-dd').format(createdAt);
|
|
|
|
return {
|
|
'id': doc.id,
|
|
'tanggalKey': tanggalKey,
|
|
'tanggalAsli': createdAt,
|
|
'pembayaran': resi['pembayaran'] ?? '',
|
|
'total': int.tryParse(resi['total'].toString()) ?? 0,
|
|
};
|
|
}).toList();
|
|
|
|
final Map<String, List<Map<String, dynamic>>> grouped = {};
|
|
for (var item in data) {
|
|
grouped.putIfAbsent(item['tanggalKey'], () => []);
|
|
grouped[item['tanggalKey']]!.add(item);
|
|
}
|
|
|
|
final List<Map<String, dynamic>> summary = [];
|
|
grouped.forEach((key, list) {
|
|
int total = 0, codCount = 0, nonCodCount = 0, income = 0;
|
|
|
|
for (var resi in list) {
|
|
total += 1;
|
|
final totalValue = num.tryParse(resi['total'].toString()) ?? 0;
|
|
income += totalValue.toInt();
|
|
|
|
if (resi['pembayaran'].toString().toUpperCase() == 'COD') {
|
|
codCount++;
|
|
} else {
|
|
nonCodCount++;
|
|
}
|
|
}
|
|
|
|
final date = DateTime.parse(key);
|
|
summary.add({
|
|
'day': DateFormat('EEEE', 'id_ID').format(date),
|
|
'date': DateFormat('d MMMM yyyy', 'id_ID').format(date),
|
|
'total': total,
|
|
'cod': codCount,
|
|
'nonCod': nonCodCount,
|
|
'income': income,
|
|
'rawDate': date,
|
|
});
|
|
});
|
|
|
|
summary.sort((a, b) => b['rawDate'].compareTo(a['rawDate']));
|
|
dailySummary.assignAll(summary);
|
|
} catch (e) {
|
|
print('Error fetching resi data: $e');
|
|
dailySummary.clear();
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
|
|
/// 🔹 Ambil data resi + detail toko berdasarkan tanggal
|
|
Future<void> fetchResiByDate(DateTime selectedDate) async {
|
|
try {
|
|
isLoading.value = true;
|
|
final user = _auth.currentUser;
|
|
if (user == null) return;
|
|
final userId = user.uid;
|
|
|
|
final startOfDay =
|
|
DateTime(selectedDate.year, selectedDate.month, selectedDate.day);
|
|
final endOfDay =
|
|
DateTime(selectedDate.year, selectedDate.month, selectedDate.day, 23, 59, 59);
|
|
|
|
final snapshot = await _firestore
|
|
.collection('resis')
|
|
.where('store_id', isEqualTo: userId)
|
|
.where('created_at', isGreaterThanOrEqualTo: startOfDay)
|
|
.where('created_at', isLessThanOrEqualTo: endOfDay)
|
|
.get();
|
|
|
|
// 🔹 Ambil data resi & data toko
|
|
List<Map<String, dynamic>> results = [];
|
|
|
|
for (var doc in snapshot.docs) {
|
|
final data = doc.data();
|
|
final storeId = data['store_id'];
|
|
|
|
// ambil data toko
|
|
final storeSnapshot =
|
|
await _firestore.collection('stores').doc(storeId).get();
|
|
final storeData = storeSnapshot.data() ?? {};
|
|
|
|
results.add({
|
|
'id': doc.id,
|
|
'no_resi': data['no_resi'] ?? '-',
|
|
'penerima': data['penerima'] ?? '-',
|
|
'alamat': data['alamat'] ?? '-',
|
|
'kota': data['kota'] ?? '-',
|
|
'provinsi': data['provinsi'] ?? '-',
|
|
'barang': data['barang'] ?? '-',
|
|
'pembayaran': data['pembayaran'] ?? '-',
|
|
'total': data['total'] ?? 0,
|
|
'no_wa': data['no_wa'] ?? '-',
|
|
'created_at': (data['created_at'] as Timestamp).toDate(),
|
|
'store': {
|
|
'namaToko': storeData['namaToko'] ?? '-',
|
|
'alamat': storeData['alamat'] ?? '-',
|
|
'noHp': storeData['noHp'] ?? '-',
|
|
'noRegistrasi': storeData['noRegistrasi'] ?? '-',
|
|
'keterangan': storeData['keterangan'] ?? '-',
|
|
}
|
|
});
|
|
}
|
|
|
|
dailyDetail.assignAll(results);
|
|
} catch (e) {
|
|
print('Error fetching resi detail: $e');
|
|
dailyDetail.clear();
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
|
|
|
|
/// 🔹 Ambil total pendapatan COD & Non COD per tanggal
|
|
Future<Map<String, dynamic>> getPendapatanHarian(DateTime date) async {
|
|
try {
|
|
final user = _auth.currentUser;
|
|
if (user == null) return {};
|
|
|
|
final startOfDay = DateTime(date.year, date.month, date.day);
|
|
final endOfDay = DateTime(date.year, date.month, date.day, 23, 59, 59);
|
|
|
|
final snapshot = await _firestore
|
|
.collection('resis')
|
|
.where('store_id', isEqualTo: user.uid)
|
|
.where('created_at', isGreaterThanOrEqualTo: startOfDay)
|
|
.where('created_at', isLessThanOrEqualTo: endOfDay)
|
|
.get();
|
|
|
|
double codIncome = 0;
|
|
double nonCodIncome = 0;
|
|
|
|
for (var doc in snapshot.docs) {
|
|
final data = doc.data();
|
|
final pembayaran = (data['pembayaran'] ?? '').toString().toUpperCase();
|
|
final total = double.tryParse(data['total'].toString()) ?? 0;
|
|
|
|
if (pembayaran == 'COD') {
|
|
codIncome += total;
|
|
} else {
|
|
nonCodIncome += total;
|
|
}
|
|
}
|
|
|
|
return {
|
|
'cod': codIncome,
|
|
'nonCod': nonCodIncome,
|
|
};
|
|
} catch (e) {
|
|
print('Error getPendapatanHarian: $e');
|
|
return {};
|
|
}
|
|
}
|
|
|
|
Map<String, String?> _extractWilayah(String alamat) {
|
|
if (alamat.isEmpty || _wilayahData.isEmpty) {
|
|
return {'kota': null, 'provinsi': null};
|
|
}
|
|
|
|
String alamatLower = alamat.toLowerCase();
|
|
String? provinsi;
|
|
String? kota;
|
|
|
|
// 🔹 Ambil bagian belakang alamat (lebih akurat)
|
|
List<String> words = alamatLower.split(RegExp(r'\s+'));
|
|
String lastPart = words.length > 5
|
|
? words.sublist(words.length - 5).join(' ')
|
|
: alamatLower;
|
|
|
|
// ==============================
|
|
// 🔹 STEP 1: DETEKSI PROVINSI DULU
|
|
// ==============================
|
|
for (var data in _wilayahData) {
|
|
final prov = data['provinsi']?.toString() ?? '';
|
|
final provLower = prov.toLowerCase();
|
|
|
|
if (provLower.isEmpty) continue;
|
|
|
|
if (lastPart.contains(provLower) ||
|
|
_similarity(lastPart, provLower) > 0.7 ||
|
|
(provLower.contains('jawa timur') && lastPart.contains('jatim')) ||
|
|
(provLower.contains('jawa tengah') && lastPart.contains('jateng')) ||
|
|
(provLower.contains('jawa barat') && lastPart.contains('jabar')) ||
|
|
(provLower.contains('yogyakarta') && lastPart.contains('diy'))) {
|
|
provinsi = prov;
|
|
break; // 🔥 STOP kalau sudah ketemu
|
|
}
|
|
}
|
|
|
|
// ❌ kalau provinsi tidak ketemu → langsung return
|
|
if (provinsi == null) {
|
|
return {'kota': null, 'provinsi': null};
|
|
}
|
|
|
|
// ==============================
|
|
// 🔹 STEP 2: CARI KOTA DALAM PROVINSI TERSEBUT
|
|
// ==============================
|
|
final provData = _wilayahData.firstWhere(
|
|
(e) => e['provinsi'] == provinsi,
|
|
orElse: () => {},
|
|
);
|
|
|
|
final List kotaList = List.from(provData['kota'] ?? []);
|
|
|
|
double bestScore = 0;
|
|
|
|
for (var k in kotaList) {
|
|
final kotaNama = k.toString();
|
|
final kotaLower = kotaNama.toLowerCase();
|
|
|
|
final kotaBersih =
|
|
kotaLower.replaceAll(RegExp(r'^(kab\.?|kota)\s*'), '').trim();
|
|
|
|
double score;
|
|
|
|
if (alamatLower.contains(kotaBersih)) {
|
|
score = 0.95;
|
|
} else {
|
|
score = _similarity(lastPart, kotaBersih);
|
|
}
|
|
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
kota = kotaNama;
|
|
}
|
|
}
|
|
|
|
return {
|
|
'kota': kota,
|
|
'provinsi': provinsi,
|
|
};
|
|
}
|
|
|
|
double _similarity(String a, String b) {
|
|
if (a.isEmpty || b.isEmpty) return 0.0;
|
|
|
|
final aWords = a.split(' ');
|
|
final bWords = b.split(' ');
|
|
int matches = 0;
|
|
|
|
for (var aw in aWords) {
|
|
for (var bw in bWords) {
|
|
if (aw.isNotEmpty && bw.isNotEmpty && aw == bw) matches++;
|
|
}
|
|
}
|
|
|
|
return matches / ((aWords.length + bWords.length) / 2);
|
|
}
|
|
|
|
/// 🔹 Update data resi berdasarkan ID
|
|
Future<void> updateResi(String id, Map<String, dynamic> updatedData) async {
|
|
try {
|
|
isLoading.value = true;
|
|
|
|
// Pastikan dokumen dengan ID tersebut ada
|
|
final docRef = _firestore.collection('resis').doc(id);
|
|
final snapshot = await docRef.get();
|
|
|
|
if (!snapshot.exists) {
|
|
Get.snackbar(
|
|
"Gagal",
|
|
"Data resi tidak ditemukan.",
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
backgroundColor: const Color(0xFFFFCDD2),
|
|
colorText: const Color(0xFFB71C1C),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 🔹 Update data di Firestore
|
|
await docRef.update(updatedData);
|
|
|
|
// 🔹 Refresh data setelah update
|
|
if (snapshot.data()?['created_at'] != null) {
|
|
final tanggal = (snapshot.data()!['created_at'] as Timestamp).toDate();
|
|
await fetchResiByDate(tanggal);
|
|
}
|
|
|
|
Get.snackbar(
|
|
"Berhasil",
|
|
"Data resi berhasil diperbarui.",
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
backgroundColor: const Color(0xFFC8E6C9),
|
|
colorText: const Color(0xFF1B5E20),
|
|
);
|
|
} catch (e) {
|
|
print("Error update resi: $e");
|
|
Get.snackbar(
|
|
"Error",
|
|
"Terjadi kesalahan saat memperbarui data.",
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
backgroundColor: const Color(0xFFFFCDD2),
|
|
colorText: const Color(0xFFB71C1C),
|
|
);
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
// Future<void> updateResi(String id, Map<String, dynamic> updatedData) async {
|
|
// try {
|
|
// isLoading.value = true;
|
|
|
|
// final docRef = _firestore.collection('resis').doc(id);
|
|
// final snapshot = await docRef.get();
|
|
|
|
// if (!snapshot.exists) {
|
|
// Get.snackbar(
|
|
// "Gagal",
|
|
// "Data resi tidak ditemukan.",
|
|
// snackPosition: SnackPosition.BOTTOM,
|
|
// backgroundColor: const Color(0xFFFFCDD2),
|
|
// colorText: const Color(0xFFB71C1C),
|
|
// );
|
|
// return;
|
|
// }
|
|
|
|
// final oldData = snapshot.data()!;
|
|
|
|
// // ==============================
|
|
// // 🔥 VALIDASI FIELD KOSONG
|
|
// // ==============================
|
|
// bool isEmpty(String? val) => val == null || val.trim().isEmpty;
|
|
|
|
// List<String> missing = [];
|
|
|
|
// if (isEmpty(updatedData['penerima'])) missing.add('Penerima');
|
|
// if (isEmpty(updatedData['alamat'])) missing.add('Alamat');
|
|
// if (isEmpty(updatedData['no_wa'])) missing.add('No. WA');
|
|
// if (isEmpty(updatedData['barang'])) missing.add('Barang');
|
|
// if (isEmpty(updatedData['total'])) missing.add('Total');
|
|
// if (isEmpty(updatedData['pembayaran'])) missing.add('Pembayaran');
|
|
|
|
// if (missing.isNotEmpty) {
|
|
// Get.snackbar(
|
|
// 'Gagal Update',
|
|
// 'Field berikut kosong:\n${missing.join(', ')}',
|
|
// backgroundColor: const Color(0xFFFF9800),
|
|
// colorText: Colors.white,
|
|
// snackPosition: SnackPosition.BOTTOM,
|
|
// );
|
|
// return;
|
|
// }
|
|
|
|
// // ==============================
|
|
// // 🔥 VALIDASI FORMAT
|
|
// // ==============================
|
|
|
|
// // No WA minimal 10 digit angka
|
|
// String noWa =
|
|
// updatedData['no_wa'].toString().replaceAll(RegExp(r'\D'), '');
|
|
// if (noWa.length < 10) {
|
|
// Get.snackbar(
|
|
// 'Gagal Update',
|
|
// 'No. WA tidak valid!',
|
|
// backgroundColor: const Color(0xFFFF9800),
|
|
// colorText: Colors.white,
|
|
// snackPosition: SnackPosition.BOTTOM,
|
|
// );
|
|
// return;
|
|
// }
|
|
|
|
// // Total harus angka
|
|
// String total =
|
|
// updatedData['total'].toString().replaceAll(RegExp(r'\D'), '');
|
|
// if (total.isEmpty || int.tryParse(total) == null) {
|
|
// Get.snackbar(
|
|
// 'Gagal Update',
|
|
// 'Total harus berupa angka!',
|
|
// backgroundColor: const Color(0xFFFF9800),
|
|
// colorText: Colors.white,
|
|
// snackPosition: SnackPosition.BOTTOM,
|
|
// );
|
|
// return;
|
|
// }
|
|
|
|
// // ==============================
|
|
// // 🔥 CEK APAKAH ALAMAT DIUBAH
|
|
// // ==============================
|
|
// final oldAlamat = oldData['alamat'] ?? '';
|
|
// final newAlamat = updatedData['alamat'] ?? '';
|
|
// // ==============================
|
|
// // 🔥 SELALU DETEKSI ULANG WILAYAH
|
|
// // ==============================
|
|
// final alamat = updatedData['alamat'].toString().trim();
|
|
|
|
// final wilayah = _extractWilayah(alamat);
|
|
|
|
// // ❗ Kalau gagal detect → TOLAK
|
|
// if (wilayah['provinsi'] == null || wilayah['kota'] == null) {
|
|
// Get.snackbar(
|
|
// 'Gagal Update',
|
|
// 'Alamat tidak valid, provinsi/kota tidak terdeteksi!',
|
|
// backgroundColor: const Color(0xFFFF9800),
|
|
// colorText: Colors.white,
|
|
// snackPosition: SnackPosition.BOTTOM,
|
|
// );
|
|
// return;
|
|
// }
|
|
|
|
// // 🔥 SET ULANG
|
|
// updatedData['alamat'] = alamat;
|
|
// updatedData['kota'] = wilayah['kota'];
|
|
// updatedData['provinsi'] = wilayah['provinsi'];
|
|
|
|
// // ==============================
|
|
// // 🔥 CLEAN DATA
|
|
// // ==============================
|
|
// updatedData.updateAll((key, value) => value.toString().trim());
|
|
|
|
// debugPrint("FINAL UPDATE DATA: $updatedData");
|
|
|
|
// // ==============================
|
|
// // 🔹 UPDATE FIRESTORE
|
|
// // ==============================
|
|
// await docRef.update(updatedData);
|
|
|
|
// // 🔹 Refresh data
|
|
// if (snapshot.data()?['created_at'] != null) {
|
|
// final tanggal =
|
|
// (snapshot.data()!['created_at'] as Timestamp).toDate();
|
|
// await fetchResiByDate(tanggal);
|
|
// }
|
|
|
|
// Get.snackbar(
|
|
// "Berhasil",
|
|
// "Data resi berhasil diperbarui.",
|
|
// snackPosition: SnackPosition.BOTTOM,
|
|
// backgroundColor: const Color(0xFFC8E6C9),
|
|
// colorText: const Color(0xFF1B5E20),
|
|
// );
|
|
// } catch (e) {
|
|
// debugPrint("Error update resi: $e");
|
|
|
|
// Get.snackbar(
|
|
// "Error",
|
|
// "Terjadi kesalahan saat memperbarui data.",
|
|
// snackPosition: SnackPosition.BOTTOM,
|
|
// backgroundColor: const Color(0xFFFFCDD2),
|
|
// colorText: const Color(0xFFB71C1C),
|
|
// );
|
|
// } finally {
|
|
// isLoading.value = false;
|
|
// }
|
|
// }
|
|
Future<void> deleteRiwayat(String id) async {
|
|
try {
|
|
isLoading(true);
|
|
|
|
// Hapus dari Firestore
|
|
await _firestore.collection('riwayat_bulanan').doc(id).delete();
|
|
|
|
// Hapus juga dari list lokal agar tampilan otomatis ter-update
|
|
monthlySummary.removeWhere((item) => item['id'] == id);
|
|
|
|
Get.snackbar(
|
|
"Berhasil",
|
|
"Data berhasil dihapus",
|
|
backgroundColor: const Color(0xFFFFCDD2),
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
} catch (e) {
|
|
Get.snackbar(
|
|
"Error",
|
|
"Gagal menghapus data: $e",
|
|
backgroundColor: const Color(0xFFFFE0B2),
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
} finally {
|
|
isLoading(false);
|
|
}
|
|
}
|
|
|
|
/// 🔹 Hapus data resi berdasarkan ID dokumen
|
|
Future<void> deleteResi(String id) async {
|
|
try {
|
|
isLoading(true);
|
|
|
|
// Cek apakah dokumen ada
|
|
final docRef = _firestore.collection('resis').doc(id);
|
|
final snapshot = await docRef.get();
|
|
|
|
if (!snapshot.exists) {
|
|
Get.snackbar(
|
|
"Gagal",
|
|
"Data tidak ditemukan atau sudah dihapus.",
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
backgroundColor: const Color(0xFFFFCDD2),
|
|
colorText: const Color(0xFFB71C1C),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 🔹 Hapus dari Firestore
|
|
await docRef.delete();
|
|
|
|
// 🔹 Hapus dari list lokal agar tampilan otomatis update
|
|
dailyDetail.removeWhere((item) => item['id'] == id);
|
|
dailySummary.removeWhere((item) => item['id'] == id);
|
|
|
|
Get.snackbar(
|
|
"Berhasil",
|
|
"Data berhasil dihapus.",
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
backgroundColor: const Color(0xFFFFEBEE),
|
|
colorText: const Color(0xFFB71C1C),
|
|
);
|
|
} catch (e) {
|
|
Get.snackbar(
|
|
"Error",
|
|
"Gagal menghapus data: $e",
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
backgroundColor: const Color(0xFFFFE0B2),
|
|
colorText: const Color(0xFFBF360C),
|
|
);
|
|
} finally {
|
|
isLoading(false);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
}
|