420 lines
14 KiB
Vue
420 lines
14 KiB
Vue
<template>
|
|
<div class="my-6">
|
|
<hr class="border-B mb-5" />
|
|
|
|
<!-- Filter Section -->
|
|
<div class="flex flex-row my-3 overflow-x-auto gap-1 md:gap-5 lg:gap-8">
|
|
<div class="mb-3 w-full">
|
|
<label class="text-D/80" for="pilihTanggal">Filter Tanggal:</label>
|
|
<input type="date" v-model="tanggalDipilih" id="pilihTanggal"
|
|
class="mt-1 block w-full rounded-md shadow-sm sm:text-sm bg-A text-D border-B focus:border-C focus:ring focus:outline-none focus:ring-D focus:ring-opacity-50 p-2" />
|
|
</div>
|
|
<div class="mb-3 w-full">
|
|
<label class="text-D/80" for="pilihSales">Filter Sales:</label>
|
|
<InputSelect :options="opsiSales" v-model="salesDipilih" id="pilihSales" />
|
|
</div>
|
|
<div class="mb-3 w-full">
|
|
<label class="text-D/80" for="pilihPelanggan">Nama Pembeli:</label>
|
|
<InputField placeholder="Nama pelanggan" v-model="namaPembeli" id="pilihPelanggan" />
|
|
</div>
|
|
<div class="mb-3 w-full">
|
|
<label class="text-D/80" for="pilihProduk">Filter Produk:</label>
|
|
<InputSelect :options="opsiProduk" v-model="produkDipilih" id="pilihProduk" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Export Section -->
|
|
<div class="flex flex-row items-center justify-between mt-5 gap-3">
|
|
<!-- Summary Cards -->
|
|
<div class="flex gap-4" v-if="data?.rekap_harian">
|
|
<div class="bg-A p-3 rounded-md border border-C">
|
|
<div class="text-xs text-D/60">Total Item</div>
|
|
<div class="font-semibold text-D">{{ data.rekap_harian.total_item_terjual }}</div>
|
|
</div>
|
|
<div class="bg-A p-3 rounded-md border border-C">
|
|
<div class="text-xs text-D/60">Total Berat</div>
|
|
<div class="font-semibold text-D">{{ data.rekap_harian.total_berat_terjual }}</div>
|
|
</div>
|
|
<div class="bg-A p-3 rounded-md border border-C">
|
|
<div class="text-xs text-D/60">Total Pendapatan</div>
|
|
<div class="font-semibold text-D">{{ data.rekap_harian.total_pendapatan }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Export Dropdown -->
|
|
<div class="relative w-40" ref="exportDropdownRef">
|
|
<button @click="isExportOpen = !isExportOpen" type="button"
|
|
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
|
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
|
|
<i class="fas fa-chevron-down"></i>
|
|
</button>
|
|
<div v-if="isExportOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C right-0">
|
|
<ul class="py-1">
|
|
<li v-for="option in exportOptions" :key="option.value" @click="selectExport(option)"
|
|
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
|
|
{{ option.label }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table Section -->
|
|
<div class="mt-5 overflow-x-auto">
|
|
<table class="w-full border-collapse border border-C rounded-md">
|
|
<thead>
|
|
<tr class="bg-C text-D rounded-t-md">
|
|
<th class="border-x border-C px-3 py-3">
|
|
<button @click="handleSort('nama_nampan')"
|
|
class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors">
|
|
<span>Nama Nampan</span>
|
|
<i :class="getSortIcon('nama_nampan')" class="ml-2"></i>
|
|
</button>
|
|
</th>
|
|
<th class="border-x border-C px-3 py-3">
|
|
<button @click="handleSort('jumlah_item_terjual')"
|
|
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
|
|
<span>Item Terjual</span>
|
|
<i :class="getSortIcon('jumlah_item_terjual')" class="ml-2"></i>
|
|
</button>
|
|
</th>
|
|
<th class="border-x border-C px-3 py-3">
|
|
<button @click="handleSort('berat_terjual')"
|
|
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
|
|
<span>Total Berat</span>
|
|
<i :class="getSortIcon('berat_terjual')" class="ml-2"></i>
|
|
</button>
|
|
</th>
|
|
<th class="border-x border-C px-3 py-3">
|
|
<button @click="handleSort('pendapatan')"
|
|
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
|
|
<span>Total Pendapatan</span>
|
|
<i :class="getSortIcon('pendapatan')" class="ml-2"></i>
|
|
</button>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="loading">
|
|
<td colspan="4" class="p-4">
|
|
<div class="flex items-center justify-center w-full h-30">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
|
|
<span class="ml-2 text-gray-600">Memuat data...</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr v-else-if="!sortedNampan.length">
|
|
<td colspan="4" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
|
|
</tr>
|
|
<template v-else v-for="item in sortedNampan" :key="item.nama_nampan">
|
|
<tr class="text-center border-y border-C hover:bg-A">
|
|
<td class="border-x border-C px-3 py-2 text-left">{{ item.nama_nampan }}</td>
|
|
<td class="border-x border-C px-3 py-2">{{ item.jumlah_item_terjual }}</td>
|
|
<td class="border-x border-C px-3 py-2">{{ item.berat_terjual }}</td>
|
|
<td class="border-x border-C px-3 py-2">
|
|
<div class="flex justify-center">
|
|
<div :ref="el => { if (el) pendapatanElements.push(el) }"
|
|
:style="pendapatanStyle"
|
|
:class="item.pendapatan == '-' ? 'text-center' : 'text-right'">
|
|
{{ item.pendapatan }}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- Pagination -->
|
|
<div v-if="pagination.total > 0 && pagination.last_page > 1" class="flex items-center justify-end gap-2 mt-4">
|
|
<button @click="goToPage(pagination.current_page - 1)"
|
|
:disabled="pagination.current_page === 1 || loading"
|
|
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
|
|
Sebelumnya
|
|
</button>
|
|
<span class="text-sm text-D">
|
|
Halaman {{ pagination.current_page }} dari {{ pagination.last_page }}
|
|
</span>
|
|
<button @click="goToPage(pagination.current_page + 1)"
|
|
:disabled="(pagination.current_page === pagination.last_page) || loading"
|
|
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
|
|
Berikutnya
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue';
|
|
import InputSelect from './InputSelect.vue';
|
|
import InputField from './InputField.vue';
|
|
import axios from 'axios';
|
|
|
|
// --- State ---
|
|
const isExportOpen = ref(false);
|
|
const exportDropdownRef = ref(null);
|
|
|
|
const exportOptions = ref([
|
|
{ value: 'pdf', label: 'Pdf' },
|
|
{ value: 'xls', label: 'Excel' },
|
|
{ value: 'csv', label: 'Csv' }
|
|
]);
|
|
|
|
const exportFormat = ref(null);
|
|
const tanggalDipilih = ref('');
|
|
const data = ref(null);
|
|
const loading = ref(false);
|
|
|
|
// Sorting state
|
|
const sortBy = ref(null);
|
|
const sortOrder = ref('asc'); // 'asc' or 'desc'
|
|
|
|
const pagination = ref({
|
|
current_page: 1,
|
|
last_page: 1,
|
|
total: 0,
|
|
});
|
|
|
|
const pendapatanWidth = ref(0);
|
|
const pendapatanElements = ref([]);
|
|
|
|
const salesDipilih = ref(null);
|
|
const opsiSales = ref([
|
|
{ label: 'Semua Sales', value: null, selected: true },
|
|
]);
|
|
|
|
const produkDipilih = ref(null);
|
|
const opsiProduk = ref([
|
|
{ label: 'Semua Produk', value: null, selected: true },
|
|
]);
|
|
|
|
const namaPembeli = ref(null);
|
|
|
|
// --- Computed ---
|
|
const nampan = computed(() => data.value?.nampan || []);
|
|
|
|
const sortedNampan = computed(() => {
|
|
if (!sortBy.value || !nampan.value.length) {
|
|
return nampan.value;
|
|
}
|
|
|
|
const sorted = [...nampan.value].sort((a, b) => {
|
|
let aValue = a[sortBy.value];
|
|
let bValue = b[sortBy.value];
|
|
|
|
// Handle different data types
|
|
if (sortBy.value === 'nama_nampan') {
|
|
// String comparison
|
|
aValue = aValue?.toString().toLowerCase() || '';
|
|
bValue = bValue?.toString().toLowerCase() || '';
|
|
} else if (sortBy.value === 'jumlah_item_terjual') {
|
|
// Numeric comparison
|
|
aValue = parseInt(aValue) || 0;
|
|
bValue = parseInt(bValue) || 0;
|
|
} else if (sortBy.value === 'berat_terjual') {
|
|
// Handle weight values (remove unit if exists)
|
|
aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0;
|
|
bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0;
|
|
} else if (sortBy.value === 'pendapatan') {
|
|
// Handle currency values (remove currency symbols and commas)
|
|
if (aValue === '-') aValue = 0;
|
|
if (bValue === '-') bValue = 0;
|
|
aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0;
|
|
bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0;
|
|
}
|
|
|
|
if (sortOrder.value === 'asc') {
|
|
if (typeof aValue === 'string') {
|
|
return aValue.localeCompare(bValue);
|
|
}
|
|
return aValue - bValue;
|
|
} else {
|
|
if (typeof aValue === 'string') {
|
|
return bValue.localeCompare(aValue);
|
|
}
|
|
return bValue - aValue;
|
|
}
|
|
});
|
|
|
|
return sorted;
|
|
});
|
|
|
|
const selectedExportLabel = computed(() => {
|
|
return exportOptions.value.find(opt => opt.value === exportFormat.value)?.label || 'Export Laporan';
|
|
});
|
|
|
|
const pendapatanStyle = computed(() => ({
|
|
minWidth: `${pendapatanWidth.value}px`,
|
|
padding: '0.5rem 0.75rem'
|
|
}));
|
|
|
|
// --- Watchers ---
|
|
watch(nampan, async (newValue) => {
|
|
if (newValue && newValue.length > 0) {
|
|
await nextTick();
|
|
pendapatanElements.value = [];
|
|
let maxWidth = 0;
|
|
|
|
await nextTick();
|
|
pendapatanElements.value.forEach(el => {
|
|
if (el && el.scrollWidth > maxWidth) {
|
|
maxWidth = el.scrollWidth;
|
|
}
|
|
});
|
|
pendapatanWidth.value = maxWidth;
|
|
}
|
|
}, { deep: true });
|
|
|
|
// --- Methods ---
|
|
const handleSort = (column) => {
|
|
if (sortBy.value === column) {
|
|
// If same column, toggle sort order
|
|
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
// If different column, set new column and default to ascending
|
|
sortBy.value = column;
|
|
sortOrder.value = 'asc';
|
|
}
|
|
};
|
|
|
|
const getSortIcon = (column) => {
|
|
if (sortBy.value !== column) {
|
|
return 'fas fa-sort text-D/40'; // Default sort icon
|
|
}
|
|
|
|
if (sortOrder.value === 'asc') {
|
|
return 'fas fa-sort-up text-D'; // Ascending
|
|
} else {
|
|
return 'fas fa-sort-down text-D'; // Descending
|
|
}
|
|
};
|
|
|
|
const fetchSales = async () => {
|
|
try {
|
|
const response = await axios.get('/api/sales', {
|
|
headers: {
|
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
|
},
|
|
});
|
|
const salesData = response.data;
|
|
opsiSales.value = [
|
|
{ label: 'Semua Sales', value: null },
|
|
...salesData.map(sales => ({
|
|
label: sales.nama,
|
|
value: sales.id,
|
|
}))
|
|
];
|
|
} catch (error) {
|
|
console.error('Gagal mengambil data sales:', error);
|
|
}
|
|
};
|
|
|
|
const fetchProduk = async () => {
|
|
try {
|
|
const response = await axios.get('/api/produk', {
|
|
headers: {
|
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
|
},
|
|
});
|
|
const produkData = response.data;
|
|
opsiProduk.value = [
|
|
{ label: 'Semua Produk', value: null },
|
|
...produkData.map(produk => ({
|
|
label: produk.nama,
|
|
value: produk.id,
|
|
}))
|
|
];
|
|
} catch (error) {
|
|
console.error('Gagal mengambil data produk:', error);
|
|
}
|
|
};
|
|
|
|
const fetchData = async (page = 1) => {
|
|
if (!tanggalDipilih.value) return;
|
|
|
|
loading.value = true;
|
|
pendapatanElements.value = [];
|
|
|
|
let queryParams = `tanggal=${tanggalDipilih.value}&page=${page}`;
|
|
if (salesDipilih.value) queryParams += `&sales_id=${salesDipilih.value}`;
|
|
if (produkDipilih.value) queryParams += `&produk_id=${produkDipilih.value}`;
|
|
if (namaPembeli.value) queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`;
|
|
|
|
try {
|
|
const response = await axios.get(`/api/detail-per-nampan?${queryParams}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
|
}
|
|
});
|
|
|
|
data.value = response.data;
|
|
|
|
// Handle pagination data if provided by backend
|
|
if (response.data.pagination) {
|
|
pagination.value = {
|
|
current_page: response.data.pagination.current_page,
|
|
last_page: response.data.pagination.last_page,
|
|
total: response.data.pagination.total,
|
|
};
|
|
} else {
|
|
// Reset pagination if no pagination data
|
|
pagination.value = {
|
|
current_page: 1,
|
|
last_page: 1,
|
|
total: response.data.nampan ? response.data.nampan.length : 0,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error('Gagal mengambil data laporan nampan:', error);
|
|
data.value = null;
|
|
pagination.value = {
|
|
current_page: 1,
|
|
last_page: 1,
|
|
total: 0,
|
|
};
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const goToPage = (page) => {
|
|
if (page >= 1 && page <= pagination.value.last_page) {
|
|
pagination.value.current_page = page;
|
|
fetchData(page);
|
|
}
|
|
};
|
|
|
|
const selectExport = (option) => {
|
|
exportFormat.value = option.value;
|
|
isExportOpen.value = false;
|
|
alert(`Fitur Belum dikerjakan. Laporan akan diekspor dalam format ${option.label}`);
|
|
};
|
|
|
|
const closeDropdownsOnClickOutside = (event) => {
|
|
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
|
|
isExportOpen.value = false;
|
|
}
|
|
};
|
|
|
|
// --- Lifecycle Hooks ---
|
|
onMounted(() => {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
tanggalDipilih.value = today;
|
|
|
|
fetchSales();
|
|
fetchProduk();
|
|
|
|
document.addEventListener('click', closeDropdownsOnClickOutside);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', closeDropdownsOnClickOutside);
|
|
});
|
|
|
|
// Watch for filter changes
|
|
watch([tanggalDipilih, salesDipilih, produkDipilih, namaPembeli], () => {
|
|
pagination.value.current_page = 1; // Reset to first page when filters change
|
|
fetchData(1);
|
|
}, { immediate: true });
|
|
</script> |