397 lines
13 KiB
Vue
397 lines
13 KiB
Vue
<template>
|
|
<div class="my-6">
|
|
<!-- Divider -->
|
|
<hr class="border-B mb-5" />
|
|
|
|
<!-- Filter Section -->
|
|
<div class="flex flex-col md:flex-row justify-between my-3 gap-3 md:gap-5">
|
|
<!-- Date Range Filter -->
|
|
<div class="w-full md:w-1/3">
|
|
<DatePicker v-model="dateRange" label="Filter Tanggal" placeholder="Pilih rentang tanggal" :max-days="31"
|
|
@change="handleDateChange" />
|
|
</div>
|
|
|
|
<div class="flex flex-col sm:flex-row w-full md:w-1/3">
|
|
<div class="flex-1 min-w-0">
|
|
<input placeholder="Cari kode transaksi atau nama pembeli" v-model="searchQuery"
|
|
class="mt-1 block w-full rounded-l-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>
|
|
<button @click="handleSearch"
|
|
class="mt-1 px-4 py-2 bg-C hover:bg-C/80 text-D rounded-r-md text-sm font-medium transition-colors">
|
|
Cari
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table Section -->
|
|
<div class="mt-6 overflow-x-auto">
|
|
<div class="bg-white rounded-md border border-C overflow-hidden">
|
|
<table class="w-full">
|
|
<thead>
|
|
<tr class="bg-C text-D">
|
|
<th class="border-x border-C px-3 py-3 text-left">
|
|
<button @click="handleSort('created_at')"
|
|
class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors">
|
|
<span>Tanggal & Waktu</span>
|
|
<i :class="getSortIcon('created_at')" class="ml-2"></i>
|
|
</button>
|
|
</th>
|
|
<th class="border-x border-C px-3 py-3 text-left">
|
|
<button @click="handleSort('kode_transaksi')"
|
|
class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors">
|
|
<span>Kode Transaksi</span>
|
|
<i :class="getSortIcon('kode_transaksi')" class="ml-2"></i>
|
|
</button>
|
|
</th>
|
|
<th class="border-x border-C px-3 py-3 text-left">
|
|
<button @click="handleSort('nama_pembeli')"
|
|
class="flex items-center justify-between w-full hover:text-D/80 transition-colors">
|
|
<span>Nama Pembeli</span>
|
|
<i :class="getSortIcon('nama_pembeli')" class="ml-2"></i>
|
|
</button>
|
|
</th>
|
|
<th class="border-x border-C px-3 py-3 text-left">
|
|
<button @click="handleSort('total_harga')"
|
|
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
|
|
<span>Total</span>
|
|
<i :class="getSortIcon('total_harga')" class="ml-2"></i>
|
|
</button>
|
|
</th>
|
|
<th class="border-x border-C px-3 py-3 text-center">
|
|
<span>Jml</span>
|
|
</th>
|
|
<th class="border-r border-C px-3 py-3 text-center">
|
|
<span>Aksi</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody class="divide-y divide-C/20">
|
|
<!-- Loading Row -->
|
|
<tr v-if="loading">
|
|
<td :colspan="tableColumns" class="p-8 text-center">
|
|
<div class="flex items-center justify-center">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
|
|
<span class="ml-2 text-D/70">Memuat riwayat transaksi...</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Empty State Row -->
|
|
<tr v-else-if="filteredTransaksi.length === 0 && !loading">
|
|
<td :colspan="tableColumns" class="p-12 text-center">
|
|
<div class="text-D/50 space-y-2">
|
|
<svg class="w-16 h-16 mx-auto text-D/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
|
</svg>
|
|
<div class="space-y-1">
|
|
<p class="text-sm font-medium">Tidak ada transaksi ditemukan.</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Data Rows -->
|
|
<template v-else v-for="trx in sortedTransaksi" :key="trx.id">
|
|
<tr class="hover:bg-A/50 transition-colors">
|
|
<!-- Tanggal & Waktu -->
|
|
<td class="border-x border-C px-3 py-3">
|
|
<div class="flex flex-row text-sm gap-2">
|
|
<div class="text-D">{{ formatDate(trx.created_at) }},</div>
|
|
<div class="text-D/60"> {{ formatTime(trx.created_at) }}</div>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Kode Transaksi -->
|
|
<td class="text-sm border-x border-C px-3 py-3">
|
|
{{ trx.kode_transaksi }}
|
|
</td>
|
|
|
|
<!-- Nama pembeli -->
|
|
<td class="text-sm border-x border-C px-3 py-3">
|
|
{{ trx.nama_pembeli || '-' }}
|
|
</td>
|
|
|
|
<!-- Total -->
|
|
<td class="text-sm border-x border-C px-3 py-3 text-center">
|
|
Rp{{ (trx.total_harga || 0).toLocaleString('id-ID') }}
|
|
</td>
|
|
|
|
<!-- Jumlah Item -->
|
|
<td class="text-sm border-x border-C px-3 py-3 text-center">
|
|
{{ trx.total_items || 0 }}
|
|
</td>
|
|
|
|
<!-- Aksi -->
|
|
<td class="border-r border-C px-3 py-3 text-center">
|
|
<button @click="lihatDetail(trx)"
|
|
class="inline-flex items-center px-3 py-1.5 bg-C hover:bg-C/80 text-D rounded-md text-xs font-medium transition-colors"
|
|
:disabled="isDetailLoading">
|
|
<i v-if="isDetailLoading && selectedTransaksi.id === trx.id"
|
|
class="fas fa-spinner fa-spin mr-1"></i>
|
|
<span>Lihat Detail</span>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div v-if="pagination && pagination.total > 0 && pagination.last_page > 1"
|
|
class="flex items-center justify-between gap-4 mt-6 px-1">
|
|
<div class="text-sm text-D/70">
|
|
Menampilkan {{ pagination.from }} - {{ pagination.to }} dari {{ pagination.total }} transaksi
|
|
<span v-if="filteredTransaksi.length !== pagination.per_page" class="ml-2 text-blue-600">
|
|
({{ filteredTransaksi.length }} sesuai filter)
|
|
</span>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<button @click="goToPage(pagination.current_page - 1)" :disabled="pagination.current_page === 1 || loading"
|
|
class="px-3 py-2 text-sm font-medium border rounded-md bg-A border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/50 transition-colors">
|
|
<i class="fas fa-chevron-left mr-1"></i>
|
|
Sebelumnya
|
|
</button>
|
|
|
|
<span class="text-sm text-D/70 px-3">
|
|
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-3 py-2 text-sm font-medium border rounded-md bg-A border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/50 transition-colors">
|
|
Berikutnya
|
|
<i class="fas fa-chevron-right ml-1"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Detail Transaksi -->
|
|
<StrukView :is-open="isDetailOpen" :transaksi="selectedTransaksi" @close="closeDetail" />
|
|
|
|
<!-- Loading Overlay for Detail -->
|
|
<div v-if="isDetailLoading" class="fixed inset-0 bg-black/60 flex items-center justify-center z-[9999] p-4">
|
|
<div class="bg-white rounded-lg p-6 flex items-center gap-3 shadow-xl max-w-md w-full">
|
|
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-D"></div>
|
|
<span class="text-D/80">Memuat detail transaksi...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import axios from 'axios'
|
|
import DatePicker from '@/components/DatePicker.vue'
|
|
import StrukView from '@/components/StrukView.vue'
|
|
|
|
// Props & Emits
|
|
const props = defineProps({
|
|
initialData: {
|
|
type: Object,
|
|
default: () => ({ data: [], pagination: null })
|
|
}
|
|
})
|
|
|
|
// Reactive State
|
|
const transaksi = ref(props.initialData.data || [])
|
|
const pagination = ref(props.initialData.pagination || null)
|
|
const loading = ref(false)
|
|
const isDetailLoading = ref(false)
|
|
|
|
// Filter State
|
|
const dateRange = ref({
|
|
start: new Date().toISOString().split('T')[0],
|
|
end: new Date().toISOString().split('T')[0]
|
|
})
|
|
const statusDipilih = ref('')
|
|
const pembayaranDipilih = ref('')
|
|
const searchQuery = ref('')
|
|
|
|
// Sort State
|
|
const sortField = ref('created_at')
|
|
const sortDirection = ref('desc')
|
|
|
|
// Modal State
|
|
const isDetailOpen = ref(false)
|
|
const selectedTransaksi = ref({})
|
|
|
|
// Computed
|
|
const filteredTransaksi = computed(() => {
|
|
let filtered = [...transaksi.value]
|
|
|
|
if (dateRange.value.start && dateRange.value.end) {
|
|
const startDate = new Date(dateRange.value.start)
|
|
const endDate = new Date(dateRange.value.end)
|
|
endDate.setHours(23, 59, 59, 999)
|
|
|
|
filtered = filtered.filter(trx => {
|
|
const trxDate = new Date(trx.created_at)
|
|
return trxDate >= startDate && trxDate <= endDate
|
|
})
|
|
}
|
|
|
|
// Status filter
|
|
if (statusDipilih.value) {
|
|
filtered = filtered.filter(trx => trx.status === statusDipilih.value)
|
|
}
|
|
|
|
// Payment method filter
|
|
if (pembayaranDipilih.value) {
|
|
filtered = filtered.filter(trx => trx.metode_pembayaran === pembayaranDipilih.value)
|
|
}
|
|
|
|
// Removed searchQuery filter to prevent client-side filtering
|
|
return filtered
|
|
})
|
|
|
|
const sortedTransaksi = computed(() => {
|
|
return [...filteredTransaksi.value].sort((a, b) => {
|
|
let aVal = a[sortField.value] || ''
|
|
let bVal = b[sortField.value] || ''
|
|
|
|
// Handle numeric fields
|
|
if (['total_harga', 'total_items'].includes(sortField.value)) {
|
|
aVal = parseFloat(aVal) || 0
|
|
bVal = parseFloat(bVal) || 0
|
|
}
|
|
|
|
if (aVal < bVal) return sortDirection.value === 'asc' ? -1 : 1
|
|
if (aVal > bVal) return sortDirection.value === 'asc' ? 1 : -1
|
|
return 0
|
|
})
|
|
})
|
|
|
|
const tableColumns = computed(() => 6)
|
|
|
|
// Methods
|
|
const fetchTransaksi = async (page = 1) => {
|
|
try {
|
|
loading.value = true
|
|
|
|
const params = new URLSearchParams({
|
|
page,
|
|
limit: 10,
|
|
start_date: dateRange.value.start,
|
|
end_date: dateRange.value.end,
|
|
status: statusDipilih.value,
|
|
search: searchQuery.value,
|
|
})
|
|
|
|
const response = await axios.get(`/api/transaksi?${params}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${localStorage.getItem("token")}`
|
|
}
|
|
})
|
|
|
|
transaksi.value = response.data.data || []
|
|
pagination.value = response.data.pagination || null
|
|
|
|
console.log("data", transaksi.value)
|
|
} catch (error) {
|
|
console.error('Error fetching transaksi:', error)
|
|
transaksi.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const handleDateChange = (newRange) => {
|
|
dateRange.value = newRange
|
|
pagination.value = null // Reset pagination
|
|
fetchTransaksi(1)
|
|
}
|
|
|
|
const handleSearch = () => {
|
|
pagination.value = null
|
|
fetchTransaksi(1)
|
|
}
|
|
|
|
const handleSort = (field) => {
|
|
if (sortField.value === field) {
|
|
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
|
} else {
|
|
sortField.value = field
|
|
sortDirection.value = 'asc'
|
|
}
|
|
|
|
fetchTransaksi(1)
|
|
}
|
|
|
|
const getSortIcon = (field) => {
|
|
if (sortField.value !== field) return 'fas fa-sort text-D/40'
|
|
|
|
if (sortDirection.value === 'asc') return 'fas fa-sort-up text-D'
|
|
return 'fas fa-sort-down text-D'
|
|
}
|
|
|
|
const goToPage = (page) => {
|
|
if (page >= 1 && page <= (pagination.value?.last_page || 1)) {
|
|
fetchTransaksi(page)
|
|
}
|
|
}
|
|
|
|
const lihatDetail = async (trx) => {
|
|
try {
|
|
isDetailLoading.value = true
|
|
selectedTransaksi.value = trx // Show loading state first
|
|
|
|
const response = await axios.get(`/api/transaksi/${trx.id}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${localStorage.getItem("token")}`
|
|
}
|
|
})
|
|
|
|
selectedTransaksi.value = {
|
|
...response.data,
|
|
total_items: response.data.itemTransaksi?.length || 0
|
|
}
|
|
isDetailOpen.value = true
|
|
} catch (error) {
|
|
console.error('Error fetching detail:', error)
|
|
alert('Gagal memuat detail transaksi: ' + (error.response?.data?.message || error.message))
|
|
} finally {
|
|
isDetailLoading.value = false
|
|
}
|
|
}
|
|
|
|
const closeDetail = () => {
|
|
isDetailOpen.value = false
|
|
selectedTransaksi.value = {}
|
|
}
|
|
|
|
// Helper Functions
|
|
const formatDate = (dateString) => {
|
|
return new Date(dateString).toLocaleDateString('id-ID', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric'
|
|
})
|
|
}
|
|
|
|
const formatTime = (dateString) => {
|
|
return new Date(dateString).toLocaleTimeString('id-ID', {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
}
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
fetchTransaksi()
|
|
})
|
|
|
|
// Watchers
|
|
import { watch } from 'vue'
|
|
|
|
watch([statusDipilih, pembayaranDipilih], () => {
|
|
pagination.value = null
|
|
fetchTransaksi(1)
|
|
}, { deep: true })
|
|
</script> |