[Update] Filter interval laporan

This commit is contained in:
Baghaztra 2025-09-19 13:26:53 +07:00
parent 97192bb05a
commit f71fabdc90
10 changed files with 473 additions and 175 deletions

View File

@ -18,9 +18,10 @@ class LaporanHelper
public function calculateTotals(Collection $data): array
{
$totalPendapatan = $data->sum('pendapatan');
$totalItemTerjual = $data->sum('jumlah_item_terjual');
$totalBeratTerjual = $data->sum('berat_terjual');
// Asumsi $data punya raw numeric (int/float)
$totalPendapatan = $data->sum('pendapatan'); // Raw float
$totalItemTerjual = $data->sum('jumlah_item_terjual'); // Int
$totalBeratTerjual = $data->sum('berat_terjual'); // Float
return [
'total_item_terjual' => $totalItemTerjual,
@ -94,12 +95,12 @@ class LaporanHelper
});
}
public function buildProdukFilterInfo(Carbon $carbonDate, array $params): array
public function buildProdukFilterInfo(Carbon $startDate, Carbon $endDate, array $params): array
{
$filterInfo = [
'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'),
'periode' => "{$startDate->isoFormat('D MMMM Y')} - {$endDate->isoFormat('D MMMM Y')}",
'nama_sales' => null,
'nampan' => null,
'nampan' => null, // Default null
'nama_pembeli' => $params['nama_pembeli'] ?? null,
];
@ -109,21 +110,23 @@ class LaporanHelper
}
if (isset($params['nampan_id'])) {
if ($params['nampan_id'] == 0) {
if ($params['nampan_id'] === -1) {
$filterInfo['nampan'] = 'Brankas';
} else {
} elseif ($params['nampan_id'] > 0) {
$nampan = Nampan::find($params['nampan_id']);
$filterInfo['nampan'] = $nampan?->nama;
} else { // 0: Semua
$filterInfo['nampan'] = 'Semua Nampan';
}
}
return $filterInfo;
}
public function buildNampanFilterInfo(Carbon $carbonDate, array $params): array
public function buildNampanFilterInfo(Carbon $startDate, Carbon $endDate, array $params): array
{
$filterInfo = [
'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'),
'periode' => "{$startDate->isoFormat('D MMMM Y')} - {$endDate->isoFormat('D MMMM Y')}", // FIXED: Range
'nama_sales' => null,
'produk' => null,
'nama_pembeli' => $params['nama_pembeli'] ?? null,

View File

@ -88,17 +88,18 @@ class LaporanController extends Controller
public function exportDetailNampan(Request $request)
{
try {
return $this->laporanService->exportPerNampan($request->validate([
'tanggal' => 'required|string',
$validated = $request->validate([
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'format' => 'required|string|in:pdf,xlsx,csv',
'page' => 'required|integer|min:1',
'sales_id' => 'nullable|integer|exists:sales,id',
'produk_id' => 'nullable|integer|exists:produks,id',
'nama_pembeli' => 'nullable|string|max:255',
]));
]);
return $this->laporanService->exportPerNampan($validated);
} catch (\Exception $e) {
Log::error('Error in exprot per nampan method: ' . $e->getMessage());
Log::error('Error in export per nampan: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat export data'], 500);
}
}
@ -106,17 +107,18 @@ class LaporanController extends Controller
public function exportDetailProduk(Request $request)
{
try {
return $this->laporanService->exportPerProduk($request->validate([
'tanggal' => 'required|string',
$validated = $request->validate([
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'format' => 'required|string|in:pdf,xlsx,csv',
'page' => 'required|integer|min:1',
'sales_id' => 'nullable|integer|exists:sales,id',
'nampan_id' => 'nullable|integer|exists:nampans,id',
'nampan_id' => 'nullable|integer',
'nama_pembeli' => 'nullable|string|max:255',
]));
]);
return $this->laporanService->exportPerProduk($validated);
} catch (\Exception $e) {
Log::error('Error in exprot per nampan method: ' . $e->getMessage());
Log::error('Error in export per produk: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat export data'], 500);
}
}

View File

@ -20,7 +20,8 @@ class DetailLaporanRequest extends FormRequest
public function rules(): array
{
return [
'tanggal' => 'required|date_format:Y-m-d|before_or_equal:today',
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'sales_id' => 'nullable|integer|exists:sales,id',
'nampan_id' => 'nullable|integer',
'produk_id' => 'nullable|integer|exists:produks,id',
@ -36,9 +37,7 @@ class DetailLaporanRequest extends FormRequest
public function messages(): array
{
return [
'tanggal.required' => 'Tanggal harus diisi',
'tanggal.date_format' => 'Format tanggal harus Y-m-d',
'tanggal.before_or_equal' => 'Tanggal tidak boleh lebih dari hari ini',
'end_date.after_or_equal' => 'Tanggal akhir harus sama atau setelah tanggal mulai.',
'sales_id.exists' => 'Sales tidak ditemukan',
'produk_id.exists' => 'Produk tidak ditemukan',
'nama_pembeli.max' => 'Nama pembeli maksimal 255 karakter',

View File

@ -62,12 +62,19 @@ class LaporanService
*/
public function getDetailPerProduk(array $params)
{
$tanggal = Carbon::parse($params['tanggal']);
$startDate = Carbon::parse($params['start_date'])->startOfDay();
$endDate = Carbon::parse($params['end_date'])->endOfDay();
// TAMBAH: Validasi range max 30 hari (backup request)
if ($startDate->diffInDays($endDate) > 30) {
throw new \InvalidArgumentException('Interval tanggal maksimal 30 hari.');
}
$page = $params['page'] ?? 1;
$perPage = $params['per_page'] ?? self::DEFAULT_PER_PAGE;
// --- Step 1: Calculate overall totals for all filtered items ---
$totalsQuery = $this->buildBaseItemQuery($tanggal);
// --- Step 1: Totals ---
$totalsQuery = $this->buildBaseItemQueryForRange($startDate, $endDate); // FIXED: Call benar
$this->applyFilters($totalsQuery, $params);
$totalsResult = $totalsQuery->select(
@ -76,14 +83,14 @@ class LaporanService
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as total_pendapatan')
)->first();
$rekapHarian = [
$rekapInterval = [
'total_item_terjual' => (int) $totalsResult->total_item_terjual,
'total_berat_terjual' => $this->helper->formatWeight($totalsResult->total_berat_terjual),
'total_pendapatan' => $this->helper->formatCurrency($totalsResult->total_pendapatan),
];
// --- Step 2: Build the filtered sales data subquery ---
$salesSubQuery = $this->buildBaseItemQuery($tanggal)
// --- Step 2: Subquery ---
$salesSubQuery = $this->buildBaseItemQueryForRange($startDate, $endDate)
->select(
'produks.id as id_produk',
DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'),
@ -94,7 +101,7 @@ class LaporanService
$this->applyFilters($salesSubQuery, $params);
// --- Step 3: Fetch paginated products and LEFT JOIN the sales data subquery ---
// --- Step 3: Paginated products ---
$semuaProdukPaginated = Produk::select(
'produks.id',
'produks.nama as nama_produk',
@ -108,7 +115,7 @@ class LaporanService
->orderBy('produks.nama')
->paginate($perPage, ['*'], 'page', $page);
// --- Step 4: Map results for final presentation ---
// --- Step 4: Map & filter ---
$detailItem = $semuaProdukPaginated->map(function ($item) {
return [
'nama_produk' => $item->nama_produk,
@ -122,30 +129,44 @@ class LaporanService
$paginatedFiltered = new \Illuminate\Pagination\LengthAwarePaginator(
$detailItem->forPage($page, $perPage),
$detailItem->count(),
$detailItem->count(), // FIXED: Total dari filtered
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
// --- Step 5: Assemble final response ---
$filterInfo = $this->helper->buildProdukFilterInfo($tanggal, $params);
// --- Step 5: Response ---
$filterInfo = $this->helper->buildProdukFilterInfo($startDate, $endDate, $params);
return [
'filter' => $filterInfo,
'rekap_harian' => $rekapHarian,
// 'produk' => $detailItem,
'rekap_interval' => $rekapInterval,
'produk' => $paginatedFiltered->getCollection(),
'pagination' => $this->helper->buildPaginationInfo($semuaProdukPaginated),
'pagination' => $this->helper->buildPaginationInfo($paginatedFiltered), // FIXED: Dari filtered
];
}
private function buildBaseItemQueryForRange(Carbon $startDate, Carbon $endDate)
{
return ItemTransaksi::query()
->join('produks', 'item_transaksis.id_produk', '=', 'produks.id')
->join('transaksis', 'item_transaksis.id_transaksi', '=', 'transaksis.id')
->whereBetween('transaksis.created_at', [$startDate, $endDate]);
}
public function getDetailPerNampan(array $params)
{
$tanggal = Carbon::parse($params['tanggal']);
$startDate = Carbon::parse($params['start_date'])->startOfDay();
$endDate = Carbon::parse($params['end_date'])->endOfDay();
if ($startDate->diffInDays($endDate) > 30) {
throw new \InvalidArgumentException('Interval tanggal maksimal 30 hari.');
}
$page = $params['page'] ?? 1;
$perPage = $params['per_page'] ?? self::DEFAULT_PER_PAGE;
$nampanTerjualQuery = $this->buildBaseItemQuery($tanggal);
$nampanTerjualQuery = $this->buildBaseItemQueryForRange($startDate, $endDate); // FIXED: Range
$this->applyNampanFilters($nampanTerjualQuery, $params);
$nampanTerjual = $nampanTerjualQuery
@ -159,16 +180,22 @@ class LaporanService
->get()
->keyBy('nama_nampan');
$totals = $this->helper->calculateTotals($nampanTerjual);
// FIXED: calculateTotals sum raw (bukan formatted string)
$nampanTerjualRaw = $nampanTerjual->map(function ($item) {
return [
'jumlah_item_terjual' => (int) $item->jumlah_item_terjual,
'berat_terjual' => $item->berat_terjual,
'pendapatan' => $item->pendapatan,
];
});
$totals = $this->helper->calculateTotals($nampanTerjualRaw);
$semuaNampanPaginated = $this->helper->getAllNampanWithPagination($page, $perPage);
// $detailItem = $this->helper->mapNampanWithSalesData($semuaNampanPaginated, $nampanTerjual);
$filterInfo = $this->helper->buildNampanFilterInfo($tanggal, $params);
$detailItem = $this->helper->mapNampanWithSalesData($semuaNampanPaginated, $nampanTerjual)
->filter(function ($item) { // TAMBAH: Filter out kosong
return $item['jumlah_item_terjual'] !== $this->helper::DEFAULT_DISPLAY && $item['jumlah_item_terjual'] > 0;
->filter(function ($item) {
return $item['jumlah_item_terjual'] > 0; // FIXED: Int compare, no DEFAULT_DISPLAY check
});
// Rebuild paginator serupa seperti di atas
$paginatedFiltered = new \Illuminate\Pagination\LengthAwarePaginator(
$detailItem->forPage($page, $perPage),
$detailItem->count(),
@ -177,9 +204,11 @@ class LaporanService
['path' => request()->url(), 'query' => request()->query()]
);
$filterInfo = $this->helper->buildNampanFilterInfo($startDate, $endDate, $params); // FIXED: Range helper
return [
'filter' => $filterInfo,
'rekap_harian' => $totals,
'rekap_interval' => $totals, // FIXED: Rename
'nampan' => $paginatedFiltered->getCollection(),
'pagination' => $this->helper->buildPaginationInfo($paginatedFiltered),
];
@ -217,22 +246,22 @@ class LaporanService
public function exportPerProduk(array $params)
{
$tanggal = $params['tanggal'];
$format = $params['format'];
$allParams = $params;
unset($allParams['page'], $allParams['per_page']);
$data = $this->getDetailPerProdukForExport($allParams);
$fileName = "laporan_per_produk_{$tanggal}_" . Carbon::now()->format('Ymd') . ".{$format}";
$startDate = Carbon::parse($params['start_date'])->format('Ymd');
$endDate = Carbon::parse($params['end_date'])->format('Ymd');
$fileName = "laporan_per_produk_{$startDate}_to_{$endDate}.{$format}"; // FIXED: Range filename
if ($format === 'pdf') {
$pdf = PDF::loadView('exports.perproduk_pdf', [
'data' => $data,
'title' => 'Laporan Detail Per Produk'
]);
$pdf->setPaper('a4', 'potrait');
$pdf->setPaper('a4', 'portrait'); // FIXED: Typo 'potrait'
return $pdf->download($fileName);
}
@ -241,22 +270,22 @@ class LaporanService
public function exportPerNampan(array $params)
{
$tanggal = $params['tanggal'];
$format = $params['format'];
$allParams = $params;
unset($allParams['page'], $allParams['per_page']);
$data = $this->getDetailPerNampanForExport($allParams);
$fileName = "laporan_per_nampan_{$tanggal}_" . Carbon::now()->format('Ymd') . ".{$format}";
$startDate = Carbon::parse($params['start_date'])->format('Ymd');
$endDate = Carbon::parse($params['end_date'])->format('Ymd');
$fileName = "laporan_per_nampan_{$startDate}_to_{$endDate}.{$format}"; // FIXED: Range
if ($format === 'pdf') {
$pdf = PDF::loadView('exports.pernampan_pdf', [
'data' => $data,
'title' => 'Laporan Detail Per Nampan'
]);
$pdf->setPaper('a4', 'potrait');
$pdf->setPaper('a4', 'portrait');
return $pdf->download($fileName);
}
@ -265,9 +294,10 @@ class LaporanService
private function getDetailPerProdukForExport(array $params)
{
$tanggal = Carbon::parse($params['tanggal']);
$produkTerjualQuery = $this->buildBaseItemQuery($tanggal);
$startDate = Carbon::parse($params['start_date'])->startOfDay();
$endDate = Carbon::parse($params['end_date'])->endOfDay();
$produkTerjualQuery = $this->buildBaseItemQueryForRange($startDate, $endDate);
$this->applyFilters($produkTerjualQuery, $params);
$produkTerjual = $produkTerjualQuery
@ -282,7 +312,15 @@ class LaporanService
->get()
->keyBy('id_produk');
$totals = $this->helper->calculateTotals($produkTerjual);
// FIXED: calculateTotals sum raw
$produkTerjualRaw = $produkTerjual->map(function ($item) {
return [
'jumlah_item_terjual' => (int) $item->jumlah_item_terjual,
'berat_terjual' => $item->berat_terjual,
'pendapatan' => $item->pendapatan,
];
});
$totals = $this->helper->calculateTotals($produkTerjualRaw);
$semuaProduk = Produk::select('id', 'nama')->orderBy('nama')->get();
@ -296,31 +334,24 @@ class LaporanService
'pendapatan' => $this->helper->formatCurrency($dataTerjual->pendapatan),
];
}
return null; // Akan difilter
})->filter(); // FIXED: Filter null/kosong
return [
'nama_produk' => $item->nama,
'jumlah_item_terjual' => LaporanHelper::DEFAULT_DISPLAY,
'berat_terjual' => LaporanHelper::DEFAULT_DISPLAY,
'pendapatan' => LaporanHelper::DEFAULT_DISPLAY,
];
})->filter(function ($item) {
return $item['jumlah_item_terjual'] > 0;
});
$filterInfo = $this->helper->buildProdukFilterInfo($tanggal, $params);
$filterInfo = $this->helper->buildProdukFilterInfo($startDate, $endDate, $params);
return [
'filter' => $filterInfo,
'rekap_harian' => $totals,
'rekap_interval' => $totals, // FIXED: Rename
'produk' => $detailItem->values(),
];
}
private function getDetailPerNampanForExport(array $params)
{
$tanggal = Carbon::parse($params['tanggal']);
$startDate = Carbon::parse($params['start_date'])->startOfDay();
$endDate = Carbon::parse($params['end_date'])->endOfDay();
$nampanTerjualQuery = $this->buildBaseItemQuery($tanggal);
$nampanTerjualQuery = $this->buildBaseItemQueryForRange($startDate, $endDate);
$this->applyNampanFilters($nampanTerjualQuery, $params);
$nampanTerjual = $nampanTerjualQuery
@ -334,9 +365,18 @@ class LaporanService
->get()
->keyBy('posisi_asal');
$totals = $this->helper->calculateTotals($nampanTerjual);
// FIXED: Sum raw
$nampanTerjualRaw = $nampanTerjual->map(function ($item) {
return [
'jumlah_item_terjual' => (int) $item->jumlah_item_terjual,
'berat_terjual' => $item->berat_terjual,
'pendapatan' => $item->pendapatan,
];
});
$totals = $this->helper->calculateTotals($nampanTerjualRaw);
$semuaPosisi = DB::table('item_transaksis')
->whereBetween('created_at', [$startDate, $endDate]) // FIXED: Filter posisi di range
->select('posisi_asal')
->distinct()
->pluck('posisi_asal')
@ -353,22 +393,14 @@ class LaporanService
'pendapatan' => $this->helper->formatCurrency($dataTerjual->pendapatan),
];
}
return null; // Filter out
})->filter();
return [
'nama_nampan' => $posisi,
'jumlah_item_terjual' => LaporanHelper::DEFAULT_DISPLAY,
'berat_terjual' => LaporanHelper::DEFAULT_DISPLAY,
'pendapatan' => LaporanHelper::DEFAULT_DISPLAY,
];
})->filter(function ($item) {
return $item['jumlah_item_terjual'] > 0;
});
$filterInfo = $this->helper->buildNampanFilterInfo($tanggal, $params);
$filterInfo = $this->helper->buildNampanFilterInfo($startDate, $endDate, $params); // FIXED: Range
return [
'filter' => $filterInfo,
'rekap_harian' => $totals,
'rekap_interval' => $totals,
'nampan' => $detailItem->values(),
];
}
@ -391,15 +423,6 @@ class LaporanService
return $this->transaksiRepo->processLaporanBulanan($allSalesNames, $page, $limitPagination);
}
private function buildBaseItemQuery(Carbon $carbonDate)
{
// UBAH: Menghapus join ke tabel 'items' dan join 'produks' langsung dari 'item_transaksis'
return ItemTransaksi::query()
->join('produks', 'item_transaksis.id_produk', '=', 'produks.id')
->join('transaksis', 'item_transaksis.id_transaksi', '=', 'transaksis.id')
->whereDate('transaksis.created_at', $carbonDate);
}
private function applyFilters($query, array $params): void
{
if (!empty($params['sales_id'])) {
@ -411,10 +434,10 @@ class LaporanService
$nampanId = (int) $params['nampan_id'];
if ($nampanId === -1) {
$query->where('item_transaksis.posisi_asal', 'Brankas');
} else {
} elseif ($nampanId > 0) { // FIXED: >0 join, 0 skip (all)
$query->join('nampans', function ($join) use ($nampanId) {
$join->on('item_transaksis.posisi_asal', '=', 'nampans.nama')
->where('nampans.id', $nampanId);
->where('nampans.id', $nampanId);
});
}
}

View File

@ -33,8 +33,8 @@ class DatabaseSeeder extends Seeder
User::factory(2)->create();
Sales::factory(5)->create();
for ($i=0; $i <= 30; $i++) {
if ($i != 13) {
for ($i=0; $i < 30; $i++) {
if ($i != 12) {
Nampan::factory()->create([
'nama' => 'A' . ($i + 1)
]);

View File

@ -0,0 +1,206 @@
<template>
<div class="relative" ref="datePickerRef">
<!-- Input Display -->
<div class="flex gap-2 items-center">
<div class="flex-1">
<label v-if="label" class="text-D/80 block text-sm font-medium mb-1">{{ label }}</label>
<div
@click="toggleCalendar"
class="w-full px-3 py-2 bg-A text-D border border-B rounded-md cursor-pointer hover:border-C focus-within:border-C focus-within:ring focus-within:ring-D focus-within:ring-opacity-50 transition-colors"
>
<div class="flex items-center justify-between">
<span v-if="displayText" class="text-sm">{{ displayText }}</span>
<span v-else class="text-sm text-D/60">{{ placeholder }}</span>
<i class="fas fa-calendar-alt text-D/60"></i>
</div>
</div>
<div v-if="errorMessage" class="text-red-500 text-xs mt-1">{{ errorMessage }}</div>
</div>
</div>
<!-- Calendar Popup (inline, no teleport) -->
<div
v-if="showCalendar"
ref="popupRef"
class="absolute z-[9999] bg-A border border-C rounded-lg shadow-xl p-4 min-w-[300px] mt-2"
:class="popupPositionClass"
>
<!-- Manual Date Inputs -->
<div class="mb-4">
<div class="text-sm font-medium text-D mb-2">Pilih Manual</div>
<div class="flex gap-3 items-center">
<div class="flex-1">
<label class="text-xs text-D/80 block mb-1">Dari</label>
<input
type="date"
v-model="tempStartDate"
@input="validateDates"
:max="maxDate"
class="w-full text-xs px-2 py-2 bg-A text-D border border-B rounded focus:border-C focus:outline-none transition-colors"
/>
</div>
<span class="text-D/50 text-sm">s/d</span>
<div class="flex-1">
<label class="text-xs text-D/80 block mb-1">Sampai</label>
<input
type="date"
v-model="tempEndDate"
@input="validateDates"
:min="tempStartDate"
:max="maxDate"
class="w-full text-xs px-2 py-2 bg-A text-D border border-B rounded focus:border-C focus:outline-none transition-colors"
/>
</div>
</div>
<!-- Range Info -->
<div v-if="tempStartDate && tempEndDate" class="text-xs text-D/60 mt-2">
{{ rangeDaysText }} ({{ formatDisplayDate(tempStartDate) }} - {{ formatDisplayDate(tempEndDate) }})
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-between items-center pt-3 border-t border-C">
<button
@click="clearDates"
class="px-3 py-1 text-xs text-D/60 hover:text-D transition-colors"
>
Bersihkan
</button>
<div class="flex gap-2">
<button
@click="cancel"
class="px-4 py-1 text-xs bg-gray-200 hover:bg-gray-300 text-gray-700 rounded transition-colors"
>
Batal
</button>
<button
@click="confirm"
:disabled="!isValidRange"
class="px-4 py-1 text-xs bg-C hover:bg-C/80 text-D rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Terapkan
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
const props = defineProps({
modelValue: { type: Object, default: () => ({ start: '', end: '' }) },
label: { type: String, default: 'Pilih Periode' },
placeholder: { type: String, default: 'Pilih rentang tanggal' },
maxDays: { type: Number, default: 31 },
position: { type: String, default: 'left', validator: (v) => ['left', 'right'].includes(v) }
})
const emit = defineEmits(['update:modelValue', 'change'])
const datePickerRef = ref(null)
const showCalendar = ref(false)
const tempStartDate = ref('')
const tempEndDate = ref('')
const errorMessage = ref('')
const maxDate = computed(() => new Date().toISOString().split('T')[0])
const displayText = computed(() => {
if (props.modelValue.start && props.modelValue.end) {
const startFormatted = formatDisplayDate(props.modelValue.start)
const endFormatted = formatDisplayDate(props.modelValue.end)
return props.modelValue.start === props.modelValue.end
? startFormatted
: `${startFormatted} - ${endFormatted}`
}
return ''
})
const isValidRange = computed(() => {
if (!tempStartDate.value || !tempEndDate.value) return false
const start = new Date(tempStartDate.value)
const end = new Date(tempEndDate.value)
if (start > end) return false
const diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
return diffDays <= props.maxDays
})
const rangeDaysText = computed(() => {
if (!tempStartDate.value || !tempEndDate.value) return ''
const start = new Date(tempStartDate.value)
const end = new Date(tempEndDate.value)
const diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
return diffDays > props.maxDays
? `⚠️ Maksimal ${props.maxDays} hari`
: `${diffDays} hari`
})
const popupPositionClass = computed(() => props.position === 'right' ? 'right-0' : 'left-0')
const formatDisplayDate = (dateString) => {
const date = new Date(dateString + 'T00:00:00')
return date.toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' })
}
const toggleCalendar = () => {
showCalendar.value = !showCalendar.value
if (showCalendar.value) {
tempStartDate.value = props.modelValue.start
tempEndDate.value = props.modelValue.end
errorMessage.value = ''
}
}
const validateDates = () => {
errorMessage.value = ''
if (!tempStartDate.value || !tempEndDate.value) return
const start = new Date(tempStartDate.value)
const end = new Date(tempEndDate.value)
if (start > end) {
errorMessage.value = 'Tanggal akhir harus setelah tanggal mulai'
return
}
const diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (diffDays > props.maxDays) {
errorMessage.value = `Maksimal ${props.maxDays} hari`
}
}
const clearDates = () => {
tempStartDate.value = ''
tempEndDate.value = ''
errorMessage.value = ''
}
const cancel = () => {
showCalendar.value = false
errorMessage.value = ''
}
const confirm = () => {
if (isValidRange.value) {
const newValue = { start: tempStartDate.value, end: tempEndDate.value }
emit('update:modelValue', newValue)
emit('change', newValue)
showCalendar.value = false
}
}
const handleClickOutside = (e) => {
if (datePickerRef.value && !datePickerRef.value.contains(e.target)) {
showCalendar.value = false
}
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
watch(() => props.modelValue, (newValue) => {
if (newValue.start !== tempStartDate.value || newValue.end !== tempEndDate.value) {
tempStartDate.value = newValue.start
tempEndDate.value = newValue.end
}
}, { deep: true })
</script>

View File

@ -3,11 +3,15 @@
<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="flex flex-row my-3 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" />
<DatePicker
v-model="dateRange"
label="Filter Tanggal"
placeholder="Pilih rentang tanggal"
:max-days="31"
@change="handleDateChange"
/>
</div>
<div class="mb-3 w-full">
<label class="text-D/80" for="pilihSales">Filter Sales:</label>
@ -26,26 +30,27 @@
<!-- 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>
<div v-else>
<div v-if="loading">
<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="flex gap-4" v-else-if="data?.rekap_interval">
<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_interval.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_interval.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_interval.total_pendapatan }}</div>
</div>
</div>
<div v-else></div>
<!-- Export Dropdown -->
<div class="relative w-40" ref="exportDropdownRef">
@ -151,19 +156,15 @@
</div>
</div>
</div>
<button @click="njir">njir</button>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue';
import InputSelect from './InputSelect.vue';
import InputField from './InputField.vue';
import DatePicker from './DatePicker.vue';
import axios from 'axios';
const njir = ()=>{
console.log(sortedNampan.value);
}
// --- State ---
const isExportOpen = ref(false);
const exportDropdownRef = ref(null);
@ -175,7 +176,7 @@ const exportOptions = ref([
]);
const exportFormat = ref(null);
const tanggalDipilih = ref('');
const dateRange = ref({ start: '', end: '' });
const data = ref(null);
const loading = ref(false);
const loadingExport = ref(false);
@ -301,6 +302,13 @@ const getSortIcon = (column) => {
}
};
const handleDateChange = (newDateRange) => {
console.log('Date range changed:', newDateRange);
// Reset pagination when date changes
pagination.value.current_page = 1;
fetchData(1);
};
const fetchSales = async () => {
try {
const response = await axios.get('/api/sales', {
@ -342,12 +350,12 @@ const fetchProduk = async () => {
};
const fetchData = async (page = 1) => {
if (!tanggalDipilih.value) return;
if (!dateRange.value.start || !dateRange.value.end) return;
loading.value = true;
pendapatanElements.value = [];
let queryParams = `tanggal=${tanggalDipilih.value}&page=${page}`;
let queryParams = `start_date=${dateRange.value.start}&end_date=${dateRange.value.end}&page=${page}`;
if (salesDipilih.value != 0 ) queryParams += `&sales_id=${salesDipilih.value}`;
if (produkDipilih.value != 0) queryParams += `&produk_id=${produkDipilih.value}`;
if (namaPembeli.value) queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`;
@ -398,6 +406,11 @@ const goToPage = (page) => {
};
const selectExport = async (option) => {
if (!dateRange.value.start || !dateRange.value.end) {
alert('Silakan pilih rentang tanggal terlebih dahulu');
return;
}
exportFormat.value = option.value;
isExportOpen.value = false;
loadingExport.value = true;
@ -405,7 +418,8 @@ const selectExport = async (option) => {
try {
const response = await axios.get(`/api/laporan/export/detail-pernampan`, {
params: {
tanggal: tanggalDipilih.value,
start_date: dateRange.value.start,
end_date: dateRange.value.end,
format: exportFormat.value,
page: pagination.value.current_page,
sales_id: salesDipilih.value != 0 ? salesDipilih.value : null,
@ -420,7 +434,7 @@ const selectExport = async (option) => {
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
const fileName = `laporan_${tanggalDipilih.value}_${new Date().toISOString().split('T')[0]}.${exportFormat.value}`;
const fileName = `laporan_${dateRange.value.start}_to_${dateRange.value.end}_${new Date().toISOString().split('T')[0]}.${exportFormat.value}`;
link.href = url;
link.setAttribute('download', fileName);
@ -431,6 +445,7 @@ const selectExport = async (option) => {
window.URL.revokeObjectURL(url);
} catch (e) {
console.error("Gagal mengekspor laporan:", e);
alert('Gagal mengekspor laporan. Silakan coba lagi.');
} finally {
loadingExport.value = false;
}
@ -444,8 +459,9 @@ const closeDropdownsOnClickOutside = (event) => {
// --- Lifecycle Hooks ---
onMounted(() => {
// Set default date range to today
const today = new Date().toISOString().split('T')[0];
tanggalDipilih.value = today;
dateRange.value = { start: today, end: today };
fetchSales();
fetchProduk();
@ -457,9 +473,19 @@ 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 });
// Watch for filter changes (except date range which has its own handler)
watch([salesDipilih, produkDipilih, namaPembeli], () => {
if (dateRange.value.start && dateRange.value.end) {
pagination.value.current_page = 1; // Reset to first page when filters change
fetchData(1);
}
});
// Watch for date range changes
watch(dateRange, (newDateRange) => {
if (newDateRange.start && newDateRange.end) {
pagination.value.current_page = 1;
fetchData(1);
}
}, { deep: true, immediate: true });
</script>

View File

@ -2,11 +2,16 @@
<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" />
<!-- Filter Section -->
<div class="flex flex-row my-3 gap-1 md:gap-5 lg:gap-8">
<div class="mb-3 w-full">
<DatePicker
v-model="dateRange"
label="Filter Tanggal"
placeholder="Pilih rentang tanggal"
:max-days="31"
@change="handleDateChange"
/>
</div>
<div class="mb-3 w-full min-w-fit">
<label class="text-D/80" for="pilihSales">Filter Sales:</label>
@ -22,29 +27,32 @@
</div>
</div>
<!-- Export Section -->
<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>
<!-- Summary Cards -->
<div v-if="loading">
<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="flex gap-4" v-if="data?.rekap_interval">
<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_interval.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_interval.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_interval.total_pendapatan }}</div>
</div>
</div>
<div v-else></div>
<!-- Export Dropdown -->
<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>
@ -66,6 +74,7 @@
</div>
</div>
<!-- Table Section -->
<div class="mt-5 overflow-x-auto">
<table class="w-full border-collapse border border-C rounded-md">
<thead>
@ -130,6 +139,7 @@
</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">
@ -152,6 +162,7 @@
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue';
import InputSelect from './InputSelect.vue';
import InputField from './InputField.vue';
import DatePicker from './DatePicker.vue';
import axios from 'axios';
// --- State ---
@ -165,14 +176,14 @@ const exportOptions = ref([
]);
const exportFormat = ref(null);
const tanggalDipilih = ref('');
const dateRange = ref({ start: '', end: '' });
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 sortOrder = ref('asc');
const pagination = ref({
current_page: 1,
@ -185,12 +196,12 @@ const pendapatanElements = ref([]);
const salesDipilih = ref(0);
const opsiSales = ref([
{ label: 'Semua Sales', value: 0, selected: true },
{ label: 'Semua Sales', value: 0 },
]);
const nampanDipilih = ref(0);
const opsiNampan = ref([
{ label: 'Semua Nampan', value: 0, selected: true },
{ label: 'Semua Nampan', value: 0 },
]);
const namaPembeli = ref(null);
@ -292,6 +303,13 @@ const getSortIcon = (column) => {
}
};
const handleDateChange = (newDateRange) => {
console.log('Date range changed:', newDateRange);
// Reset pagination when date changes
pagination.value.current_page = 1;
fetchData(1);
};
const fetchSales = async () => {
try {
const response = await axios.get('/api/sales', {
@ -334,12 +352,12 @@ const fetchNampan = async () => {
};
const fetchData = async (page = 1) => {
if (!tanggalDipilih.value) return;
if (!dateRange.value.start || !dateRange.value.end) return;
loading.value = true;
pendapatanElements.value = [];
let queryParams = `tanggal=${tanggalDipilih.value}&page=${page}`;
let queryParams = `start_date=${dateRange.value.start}&end_date=${dateRange.value.end}&page=${page}`;
if (salesDipilih.value != 0 ) queryParams += `&sales_id=${salesDipilih.value}`;
if (nampanDipilih.value != 0) queryParams += `&nampan_id=${nampanDipilih.value}`;
if (namaPembeli.value) queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`;
@ -353,6 +371,7 @@ const fetchData = async (page = 1) => {
data.value = response.data;
// Handle pagination data if provided by backend
if (response.data.pagination) {
pagination.value = {
current_page: response.data.pagination.current_page,
@ -360,12 +379,14 @@ const fetchData = async (page = 1) => {
total: response.data.pagination.total,
};
} else {
// Reset pagination if no pagination data
pagination.value = {
current_page: 1,
last_page: 1,
total: response.data.produk ? response.data.produk.length : 0,
};
}
console.log('Data laporan produk berhasil diambil:', data.value);
} catch (error) {
console.error('Gagal mengambil data laporan produk:', error);
data.value = null;
@ -387,14 +408,20 @@ const goToPage = (page) => {
};
const selectExport = async (option) => {
if (!dateRange.value.start || !dateRange.value.end) {
alert('Silakan pilih rentang tanggal terlebih dahulu');
return;
}
exportFormat.value = option.value;
isExportOpen.value = false;
loadingExport.value = true
loadingExport.value = true;
try {
const response = await axios.get('/api/laporan/export/detail-perproduk', {
params: {
tanggal: tanggalDipilih.value,
start_date: dateRange.value.start,
end_date: dateRange.value.end,
format: exportFormat.value,
page: pagination.value.current_page,
sales_id: salesDipilih.value != 0 ? salesDipilih.value : null,
@ -409,7 +436,7 @@ const selectExport = async (option) => {
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}`;
const fileName = `laporan_per_produk_${dateRange.value.start}_to_${dateRange.value.end}_${new Date().toISOString().split('T')[0]}.${exportFormat.value}`;
link.href = url;
link.setAttribute('download', fileName);
@ -420,8 +447,9 @@ const selectExport = async (option) => {
window.URL.revokeObjectURL(url);
} catch (e) {
console.error("Gagal mengekspor laporan per produk:", e);
alert('Gagal mengekspor laporan. Silakan coba lagi.');
} finally {
loadingExport.value = false
loadingExport.value = false;
}
};
@ -433,11 +461,12 @@ const closeDropdownsOnClickOutside = (event) => {
// --- Lifecycle Hooks ---
onMounted(() => {
// Set default date range to today
const today = new Date().toISOString().split('T')[0];
tanggalDipilih.value = today;
dateRange.value = { start: today, end: today };
fetchSales();
fetchNampan(); // Changed from fetchProduk to fetchNampan
fetchNampan();
document.addEventListener('click', closeDropdownsOnClickOutside);
});
@ -446,9 +475,19 @@ 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 });
// Watch for filter changes (except date range which has its own handler)
watch([salesDipilih, nampanDipilih, namaPembeli], () => {
if (dateRange.value.start && dateRange.value.end) {
pagination.value.current_page = 1; // Reset to first page when filters change
fetchData(1);
}
});
// Watch for date range changes
watch(dateRange, (newDateRange) => {
if (newDateRange.start && newDateRange.end) {
pagination.value.current_page = 1;
fetchData(1);
}
}, { deep: true, immediate: true });
</script>

View File

@ -102,7 +102,7 @@
<div class="filter-info">
<h3>Informasi Filter</h3>
<div class="filter-item">
<span class="filter-label">Tanggal:</span> {{ $data['filter']['tanggal'] }}
<span class="filter-label">Tanggal:</span> {{ $data['filter']['periode'] }}
</div>
@if($data['filter']['nama_sales'])
<div class="filter-item">

View File

@ -102,7 +102,7 @@
<div class="filter-info">
<h3>Informasi Filter</h3>
<div class="filter-item">
<span class="filter-label">Tanggal:</span> {{ $data['filter']['tanggal'] }}
<span class="filter-label">Tanggal:</span> {{ $data['filter']['periode'] }}
</div>
@if($data['filter']['nama_sales'])
<div class="filter-item">