Kasir/resources/js/components/DetailPerProduk.vue
2025-09-10 13:32:58 +07:00

454 lines
16 KiB
Vue

<template>
<div class="my-6">
<hr class="border-B mb-5" />
<div class="flex flex-row my-3 overflow-x-auto gap-1 md:gap-5 lg:gap-8">
<div class="mb-3 w-full min-w-fit">
<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 min-w-fit">
<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 min-w-fit">
<label class="text-D/80" for="pilihPelanggan">Nama Pembeli:</label>
<InputField placeholder="Cari nama pelanggan" v-model="namaPembeli" id="pilihPelanggan" />
</div>
<div class="mb-3 w-full min-w-fit">
<label class="text-D/80" for="pilihNampan">Filter Nampan:</label>
<InputSelect :options="opsiNampan" v-model="nampanDipilih" id="pilihNampan" />
</div>
</div>
<div class="flex flex-row items-center justify-between mt-5 gap-3">
<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>
<div v-else>
<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>
</div>
<div class="relative w-40" ref="exportDropdownRef">
<button v-if="loadingExport" type="button"
class="flex items-center w-full px-3 py-2 text-sm text-left bg-C/80 border rounded-md border-C/80" disabled>
<i class="fas fa-spinner fa-spin mr-2"></i> Memproses...
</button>
<button v-else @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>
<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_produk')"
class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors">
<span>Nama Produk</span>
<i :class="getSortIcon('nama_produk')" 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="!sortedProduk.length">
<td colspan="4" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
</tr>
<template v-else v-for="item in sortedProduk" :key="item.nama_produk">
<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_produk }}</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>
<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: 'xlsx', label: 'Excel' },
{ value: 'csv', label: 'Csv' }
]);
const exportFormat = ref(null);
const tanggalDipilih = ref('');
const data = ref(null);
const loading = ref(false);
const loadingExport = 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 nampanDipilih = ref(null);
const opsiNampan = ref([
{ label: 'Semua Nampan', value: null, selected: true },
]);
const namaPembeli = ref(null);
// --- Computed ---
const produk = computed(() => data.value?.produk || []);
const sortedProduk = computed(() => {
if (!sortBy.value || !produk.value.length) {
return produk.value;
}
const sorted = [...produk.value].sort((a, b) => {
let aValue = a[sortBy.value];
let bValue = b[sortBy.value];
// Handle different data types
if (sortBy.value === 'nama_produk') {
// 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(produk, 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) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
} else {
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 fetchNampan = async () => {
try {
const response = await axios.get('/api/nampan', {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
const nampanData = response.data;
opsiNampan.value = [
{ label: 'Semua Nampan', value: null },
{ label: 'Brankas', value: 0 },
...nampanData.map(nampan => ({
label: nampan.nama,
value: nampan.id,
}))
];
} catch (error) {
console.error('Gagal mengambil data nampan:', 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 != null) queryParams += `&sales_id=${salesDipilih.value}`;
if (nampanDipilih.value != null) queryParams += `&nampan_id=${nampanDipilih.value}`;
if (namaPembeli.value != null || namaPembeli.value != '') queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`;
try {
const response = await axios.get(`/api/laporan/detail-per-produk?${queryParams}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
}
});
data.value = response.data;
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 {
pagination.value = {
current_page: 1,
last_page: 1,
total: response.data.produk ? response.data.produk.length : 0,
};
}
} catch (error) {
console.error('Gagal mengambil data laporan produk:', 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 = async (option) => {
exportFormat.value = option.value;
isExportOpen.value = false;
loadingExport.value = true
try {
const response = await axios.get('/api/laporan/export/detail-perproduk', {
params: {
tanggal: tanggalDipilih.value,
sales_id: salesDipilih.value,
nampan_id: nampanDipilih.value,
nama_pembeli: namaPembeli.value,
format: exportFormat.value,
},
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
const fileName = `laporan_per_produk_${tanggalDipilih.value}_${new Date().toISOString().split('T')[0]}.${exportFormat.value}`;
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (e) {
console.error("Gagal mengekspor laporan per produk:", e);
} finally {
loadingExport.value = false
}
};
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();
fetchNampan(); // Changed from fetchProduk to fetchNampan
document.addEventListener('click', closeDropdownsOnClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', closeDropdownsOnClickOutside);
});
// Watch for filter changes
watch([tanggalDipilih, salesDipilih, nampanDipilih, namaPembeli], () => { // Changed from produkDipilih to nampanDipilih
pagination.value.current_page = 1;
fetchData(1);
}, { immediate: true });
</script>