Compare commits
2 Commits
d58368389e
...
bb487a4c09
Author | SHA1 | Date | |
---|---|---|---|
|
bb487a4c09 | ||
|
fd328b6e35 |
176
app/Http/Controllers/LaporanController.php
Normal file
176
app/Http/Controllers/LaporanController.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Transaksi;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class LaporanController extends Controller
|
||||
{
|
||||
public function ringkasan(Request $request)
|
||||
{
|
||||
$filter = $request->query('filter', 'bulan');
|
||||
$page = $request->query('page', 1);
|
||||
|
||||
$allSalesNames = Transaksi::select('nama_sales')->distinct()->pluck('nama_sales');
|
||||
|
||||
if ($filter === 'hari') {
|
||||
return $this->laporanHarian($page, $allSalesNames);
|
||||
}
|
||||
|
||||
return $this->laporanBulanan($page, $allSalesNames);
|
||||
}
|
||||
|
||||
private function laporanHarian(int $page, Collection $allSalesNames)
|
||||
{
|
||||
$perPage = 7;
|
||||
|
||||
$endDate = Carbon::today()->subDays(($page - 1) * $perPage);
|
||||
$startDate = $endDate->copy()->subDays($perPage - 1);
|
||||
|
||||
$transaksis = Transaksi::with('itemTransaksi.item.produk')
|
||||
->whereBetween('created_at', [$startDate->startOfDay(), $endDate->endOfDay()])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$transaksisByDay = $transaksis->groupBy(function ($transaksi) {
|
||||
return Carbon::parse($transaksi->created_at)->format('Y-m-d');
|
||||
});
|
||||
|
||||
$period = CarbonPeriod::create($startDate, $endDate);
|
||||
$laporan = [];
|
||||
|
||||
foreach ($period as $date) {
|
||||
$dateString = $date->format('Y-m-d');
|
||||
$tanggalFormatted = $date->isoFormat('dddd, D MMMM Y');
|
||||
|
||||
if (isset($transaksisByDay[$dateString])) {
|
||||
$transaksisPerTanggal = $transaksisByDay[$dateString];
|
||||
|
||||
$salesDataTransaksi = $transaksisPerTanggal->groupBy('nama_sales')
|
||||
->map(fn ($transaksisPerSales) => $this->hitungDataSales($transaksisPerSales));
|
||||
|
||||
$fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) {
|
||||
return $salesDataTransaksi->get($namaSales) ?? $this->defaultSalesData($namaSales);
|
||||
});
|
||||
|
||||
$totalItem = $fullSalesData->sum('item_terjual');
|
||||
$totalBerat = $fullSalesData->sum('berat_terjual_raw');
|
||||
$totalPendapatan = $fullSalesData->sum('pendapatan_raw');
|
||||
|
||||
$laporan[$dateString] = [
|
||||
'tanggal' => $tanggalFormatted,
|
||||
'total_item_terjual' => $totalItem > 0 ? $totalItem : '-',
|
||||
'total_berat' => $totalBerat > 0 ? number_format($totalBerat, 2, ',', '.') . 'g' : '-',
|
||||
'total_pendapatan' => $totalPendapatan > 0 ? 'Rp' . number_format($totalPendapatan, 2, ',', '.') : '-',
|
||||
'sales' => $this->formatSalesDataValues($fullSalesData)->values(),
|
||||
];
|
||||
} else {
|
||||
$laporan[$dateString] = [
|
||||
'tanggal' => $tanggalFormatted,
|
||||
'total_item_terjual' => '-',
|
||||
'total_berat' => '-',
|
||||
'total_pendapatan' => '-',
|
||||
'sales' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
$totalHariUntukPaginasi = 365;
|
||||
$paginatedData = new LengthAwarePaginator(
|
||||
array_reverse(array_values($laporan)),
|
||||
$totalHariUntukPaginasi,
|
||||
$perPage,
|
||||
$page,
|
||||
['path' => request()->url(), 'query' => request()->query()]
|
||||
);
|
||||
|
||||
return response()->json($paginatedData);
|
||||
}
|
||||
|
||||
private function laporanBulanan(int $page, Collection $allSalesNames)
|
||||
{
|
||||
$perPage = 12;
|
||||
|
||||
$transaksis = Transaksi::with('itemTransaksi.item.produk')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$laporan = $transaksis->groupBy(function ($transaksi) {
|
||||
return Carbon::parse($transaksi->created_at)->format('F Y');
|
||||
})
|
||||
->map(function ($transaksisPerTanggal, $tanggal) use ($allSalesNames) {
|
||||
|
||||
$salesDataTransaksi = $transaksisPerTanggal
|
||||
->groupBy('nama_sales')
|
||||
->map(fn ($transaksisPerSales) => $this->hitungDataSales($transaksisPerSales));
|
||||
|
||||
$fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) {
|
||||
return $salesDataTransaksi->get($namaSales) ?? $this->defaultSalesData($namaSales);
|
||||
});
|
||||
|
||||
$totalItem = $fullSalesData->sum('item_terjual');
|
||||
$totalBerat = $fullSalesData->sum('berat_terjual_raw');
|
||||
$totalPendapatan = $fullSalesData->sum('pendapatan_raw');
|
||||
|
||||
return [
|
||||
'tanggal' => $tanggal,
|
||||
'total_item_terjual' => $totalItem > 0 ? $totalItem : '-',
|
||||
'total_berat' => $totalBerat > 0 ? number_format($totalBerat, 2, ',', '.') . 'g' : '-',
|
||||
'total_pendapatan' => $totalPendapatan > 0 ? 'Rp' . number_format($totalPendapatan, 2, ',', '.') : '-',
|
||||
'sales' => $this->formatSalesDataValues($fullSalesData)->values(),
|
||||
];
|
||||
});
|
||||
|
||||
$paginatedData = new LengthAwarePaginator(
|
||||
$laporan->forPage($page, $perPage)->values(),
|
||||
$laporan->count(),
|
||||
$perPage,
|
||||
$page,
|
||||
['path' => request()->url(), 'query' => request()->query()]
|
||||
);
|
||||
|
||||
return response()->json($paginatedData);
|
||||
}
|
||||
|
||||
private function hitungDataSales(Collection $transaksisPerSales): array
|
||||
{
|
||||
$itemTerjual = $transaksisPerSales->sum(fn ($t) => $t->itemTransaksi->count());
|
||||
$beratTerjual = $transaksisPerSales->sum(fn ($t) =>
|
||||
$t->itemTransaksi->sum(fn ($it) => $it->item->produk->berat ?? 0)
|
||||
);
|
||||
$pendapatan = $transaksisPerSales->sum('total_harga');
|
||||
|
||||
return [
|
||||
'nama' => $transaksisPerSales->first()->nama_sales,
|
||||
'item_terjual' => $itemTerjual,
|
||||
'berat_terjual_raw' => $beratTerjual,
|
||||
'pendapatan_raw' => $pendapatan,
|
||||
];
|
||||
}
|
||||
|
||||
private function defaultSalesData(string $namaSales): array
|
||||
{
|
||||
return [
|
||||
'nama' => $namaSales,
|
||||
'item_terjual' => 0,
|
||||
'berat_terjual_raw' => 0,
|
||||
'pendapatan_raw' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatSalesDataValues(Collection $salesData): Collection
|
||||
{
|
||||
return $salesData->map(function ($sale) {
|
||||
$sale['item_terjual'] = $sale['item_terjual'] > 0 ? $sale['item_terjual'] : '-';
|
||||
$sale['berat_terjual'] = $sale['berat_terjual_raw'] > 0 ? number_format($sale['berat_terjual_raw'], 2, ',', '.') . 'g' : '-';
|
||||
$sale['pendapatan'] = $sale['pendapatan_raw'] > 0 ? 'Rp' . number_format($sale['pendapatan_raw'], 2, ',', '.') : '-';
|
||||
|
||||
unset($sale['berat_terjual_raw'], $sale['pendapatan_raw']);
|
||||
return $sale;
|
||||
});
|
||||
}
|
||||
}
|
@ -22,6 +22,7 @@ class TransaksiFactory extends Factory
|
||||
$sales = Sales::inRandomOrder()->first();
|
||||
$kasir = User::inRandomOrder()->first();
|
||||
|
||||
$date = $this->faker->dateTimeBetween('-3 months');
|
||||
return [
|
||||
'id_kasir' => $kasir?->id,
|
||||
'id_sales' => $sales?->id,
|
||||
@ -31,7 +32,8 @@ class TransaksiFactory extends Factory
|
||||
'alamat' => $this->faker->address(),
|
||||
'ongkos_bikin' => $this->faker->randomFloat(2, 0, 1000000),
|
||||
'total_harga' => $this->faker->randomFloat(2, 100000, 5000000),
|
||||
'created_at' => now(),
|
||||
'created_at' => $date,
|
||||
'updated_at' => $date,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
:placeholder="placeholder"
|
||||
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:ring-D focus:ring-opacity-50 p-2"
|
||||
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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
<label class="block text-sm font-medium text-D">Kode Item *</label>
|
||||
<div class="flex flex-row justify-between mt-1 w-full rounded-md bg-A shadow-sm sm:text-sm border-B">
|
||||
<input type="text" v-model="kodeItem" @keyup.enter="inputItem" placeholder="Scan atau masukkan kode item"
|
||||
class=" bg-A focus:border-C focus:ring focus:ring-D focus:ring-opacity-50 p-2 w-full" />
|
||||
class=" bg-A focus:outline-none focus:border-C focus:ring focus:ring-D focus:ring-opacity-50 p-2 w-full rounded-l-md" />
|
||||
<button v-if="!loadingItem" @click="inputItem" class="px-3 bg-D hover:bg-D/80 text-A rounded-r-md"><i
|
||||
class="fas fa-arrow-right"></i></button>
|
||||
<div v-else class="flex items-center justify-center px-3">
|
||||
|
241
resources/js/components/RingkasanLaporanA.vue
Normal file
241
resources/js/components/RingkasanLaporanA.vue
Normal file
@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center justify-end mt-5 gap-3">
|
||||
<div class="relative w-32" ref="filterDropdownRef">
|
||||
<button @click="isFilterOpen = !isFilterOpen" 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>{{ selectedFilterLabel }}</span>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<div v-if="isFilterOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C">
|
||||
<ul class="py-1">
|
||||
<li v-for="option in filterOptions" :key="option.value" @click="selectFilter(option)"
|
||||
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
|
||||
{{ option.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<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">Tanggal</th>
|
||||
<th class="border-x border-C px-3 py-3">Nama Sales</th>
|
||||
<th class="border-x border-C px-3 py-3">Jumlah Item Terjual</th>
|
||||
<th class="border-x border-C px-3 py-3">Total Berat Terjual</th>
|
||||
<th class="border-x border-C px-3 py-3">Total Pendapatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="5" 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="!ringkasanLaporan.length">
|
||||
<td colspan="5" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
|
||||
</tr>
|
||||
<template v-else v-for="item in ringkasanLaporan" :key="item.tanggal">
|
||||
<template v-if="item.sales && item.sales.length > 0">
|
||||
<tr class="text-center border-y border-C"
|
||||
:class="item.sales[0].item_terjual == 0 ? 'bg-red-200 hover:bg-red-300' : 'hover:bg-A'">
|
||||
<td class="px-3 py-2 border-x border-C bg-white" :rowspan="item.sales.length">{{
|
||||
item.tanggal }}</td>
|
||||
<td class="px-3 py-2 border-x border-C text-left">{{ item.sales[0].nama }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.sales[0].item_terjual }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.sales[0].berat_terjual }}</td>
|
||||
<td class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="item.sales[0].pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ item.sales[0].pendapatan }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="sales in item.sales.slice(1)" :key="sales.nama"
|
||||
class="text-center border-y border-C"
|
||||
:class="sales.item_terjual == '-' ? 'bg-red-200 hover:bg-red-300' : 'hover:bg-A'">
|
||||
<td class="px-3 py-2 text-left border-x border-C">{{ sales.nama }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ sales.item_terjual }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ sales.berat_terjual }}</td>
|
||||
<td class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="sales.pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ sales.pendapatan }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="font-semibold text-center border-y border-C bg-B hover:bg-C/80">
|
||||
<td class="px-3 py-2 border-x border-C" colspan="2">Total</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.total_item_terjual }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.total_berat }}</td>
|
||||
<td class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="item.sales[0].pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ item.total_pendapatan }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr class="text-center border-y border-C hover:bg-A">
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.tanggal }}</td>
|
||||
<td colspan="4" class="px-3 py-2 italic text-gray-500 border-x border-C bg-yellow-50 hover:bg-yellow-100">Tidak ada transaksi
|
||||
pada hari ini</td>
|
||||
</tr>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
// --- State ---
|
||||
const isFilterOpen = ref(false);
|
||||
const isExportOpen = ref(false);
|
||||
const filterDropdownRef = ref(null);
|
||||
const exportDropdownRef = ref(null);
|
||||
|
||||
const filterOptions = ref([
|
||||
{ value: 'bulan', label: 'Bulanan' },
|
||||
{ value: 'hari', label: 'Harian' }
|
||||
]);
|
||||
const exportOptions = ref([
|
||||
{ value: 'pdf', label: 'Pdf' },
|
||||
{ value: 'xls', label: 'Excel' },
|
||||
{ value: 'csv', label: 'Csv' }
|
||||
]);
|
||||
|
||||
const filterRingkasan = ref("bulan");
|
||||
const exportFormat = ref(null);
|
||||
const ringkasanLaporan = ref([]);
|
||||
const loading = ref(false);
|
||||
const pagination = ref({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const pendapatanWidth = ref(0);
|
||||
const pendapatanElements = ref([]);
|
||||
|
||||
// --- Computed ---
|
||||
const selectedFilterLabel = computed(() => {
|
||||
return filterOptions.value.find(opt => opt.value === filterRingkasan.value)?.label;
|
||||
});
|
||||
|
||||
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(ringkasanLaporan, async (newValue) => {
|
||||
if (newValue && newValue.length > 0) {
|
||||
await nextTick();
|
||||
let maxWidth = 0;
|
||||
pendapatanElements.value.forEach(el => {
|
||||
if (el && el.scrollWidth > maxWidth) {
|
||||
maxWidth = el.scrollWidth;
|
||||
}
|
||||
});
|
||||
pendapatanWidth.value = maxWidth;
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// --- Methods ---
|
||||
const fetchRingkasan = async (page = 1) => {
|
||||
loading.value = true;
|
||||
pendapatanElements.value = [];
|
||||
try {
|
||||
const response = await axios.get(`/api/laporan?filter=${filterRingkasan.value}&page=${page}`);
|
||||
ringkasanLaporan.value = response.data.data;
|
||||
pagination.value = {
|
||||
current_page: response.data.current_page,
|
||||
last_page: response.data.last_page,
|
||||
total: response.data.total,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching laporan:", error);
|
||||
ringkasanLaporan.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= pagination.value.last_page) {
|
||||
fetchRingkasan(page);
|
||||
}
|
||||
};
|
||||
|
||||
const selectFilter = (option) => {
|
||||
filterRingkasan.value = option.value;
|
||||
isFilterOpen.value = false;
|
||||
goToPage(1);
|
||||
};
|
||||
|
||||
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 (filterDropdownRef.value && !filterDropdownRef.value.contains(event.target)) {
|
||||
isFilterOpen.value = false;
|
||||
}
|
||||
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
|
||||
isExportOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
onMounted(() => {
|
||||
fetchRingkasan(pagination.value.current_page);
|
||||
document.addEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
</script>
|
239
resources/js/components/RingkasanLaporanB.vue
Normal file
239
resources/js/components/RingkasanLaporanB.vue
Normal file
@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center justify-end mt-5 gap-3">
|
||||
<div class="relative w-32" ref="filterDropdownRef">
|
||||
<button @click="isFilterOpen = !isFilterOpen" 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>{{ selectedFilterLabel }}</span>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<div v-if="isFilterOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C">
|
||||
<ul class="py-1">
|
||||
<li v-for="option in filterOptions" :key="option.value" @click="selectFilter(option)"
|
||||
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
|
||||
{{ option.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<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">Tanggal</th>
|
||||
<th class="border-x border-C px-3 py-3">Nama Sales</th>
|
||||
<th class="border-x border-C px-3 py-3">Jumlah Item Terjual</th>
|
||||
<th class="border-x border-C px-3 py-3">Total Berat Terjual</th>
|
||||
<th class="border-x border-C px-3 py-3">Total Pendapatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="5" 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="!ringkasanLaporan.length">
|
||||
<td colspan="5" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
|
||||
</tr>
|
||||
<template v-else v-for="item in ringkasanLaporan" :key="item.tanggal">
|
||||
<template v-if="item.sales && item.sales.length > 0">
|
||||
<tr class="text-center border-y border-C hover:bg-A">
|
||||
<td class="px-3 py-2 border-x border-C bg-white" :rowspan="item.sales.length">{{
|
||||
item.tanggal }}</td>
|
||||
<td class="px-3 py-2 border-x border-C text-left">{{ item.sales[0].nama }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.sales[0].item_terjual }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.sales[0].berat_terjual }}</td>
|
||||
<td class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="item.sales[0].pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ item.sales[0].pendapatan }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="sales in item.sales.slice(1)" :key="sales.nama"
|
||||
class="text-center border-y border-C hover:bg-A">
|
||||
<td class="px-3 py-2 text-left border-x border-C">{{ sales.nama }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ sales.item_terjual }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ sales.berat_terjual }}</td>
|
||||
<td class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="sales.pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ sales.pendapatan }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="font-semibold text-center border-y border-C bg-B hover:bg-C/80">
|
||||
<td class="px-3 py-2 border-x border-C" colspan="2">Total</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.total_item_terjual }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.total_berat }}</td>
|
||||
<td class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="item.sales[0].pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ item.total_pendapatan }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr class="text-center border-y border-C hover:bg-A">
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.tanggal }}</td>
|
||||
<td colspan="4" class="px-3 py-2 italic text-gray-500 border-x border-C">Tidak ada transaksi
|
||||
pada hari ini</td>
|
||||
</tr>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
// --- State ---
|
||||
const isFilterOpen = ref(false);
|
||||
const isExportOpen = ref(false);
|
||||
const filterDropdownRef = ref(null);
|
||||
const exportDropdownRef = ref(null);
|
||||
|
||||
const filterOptions = ref([
|
||||
{ value: 'bulan', label: 'Bulanan' },
|
||||
{ value: 'hari', label: 'Harian' }
|
||||
]);
|
||||
const exportOptions = ref([
|
||||
{ value: 'pdf', label: 'Pdf' },
|
||||
{ value: 'xls', label: 'Excel' },
|
||||
{ value: 'csv', label: 'Csv' }
|
||||
]);
|
||||
|
||||
const filterRingkasan = ref("bulan");
|
||||
const exportFormat = ref(null);
|
||||
const ringkasanLaporan = ref([]);
|
||||
const loading = ref(false);
|
||||
const pagination = ref({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const pendapatanWidth = ref(0);
|
||||
const pendapatanElements = ref([]);
|
||||
|
||||
// --- Computed ---
|
||||
const selectedFilterLabel = computed(() => {
|
||||
return filterOptions.value.find(opt => opt.value === filterRingkasan.value)?.label;
|
||||
});
|
||||
|
||||
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(ringkasanLaporan, async (newValue) => {
|
||||
if (newValue && newValue.length > 0) {
|
||||
await nextTick();
|
||||
let maxWidth = 0;
|
||||
pendapatanElements.value.forEach(el => {
|
||||
if (el && el.scrollWidth > maxWidth) {
|
||||
maxWidth = el.scrollWidth;
|
||||
}
|
||||
});
|
||||
pendapatanWidth.value = maxWidth;
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// --- Methods ---
|
||||
const fetchRingkasan = async (page = 1) => {
|
||||
loading.value = true;
|
||||
pendapatanElements.value = [];
|
||||
try {
|
||||
const response = await axios.get(`/api/laporan?filter=${filterRingkasan.value}&page=${page}`);
|
||||
ringkasanLaporan.value = response.data.data;
|
||||
pagination.value = {
|
||||
current_page: response.data.current_page,
|
||||
last_page: response.data.last_page,
|
||||
total: response.data.total,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching laporan:", error);
|
||||
ringkasanLaporan.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= pagination.value.last_page) {
|
||||
fetchRingkasan(page);
|
||||
}
|
||||
};
|
||||
|
||||
const selectFilter = (option) => {
|
||||
filterRingkasan.value = option.value;
|
||||
isFilterOpen.value = false;
|
||||
goToPage(1);
|
||||
};
|
||||
|
||||
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 (filterDropdownRef.value && !filterDropdownRef.value.contains(event.target)) {
|
||||
isFilterOpen.value = false;
|
||||
}
|
||||
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
|
||||
isExportOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
onMounted(() => {
|
||||
fetchRingkasan(pagination.value.current_page);
|
||||
document.addEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
</script>
|
@ -4,7 +4,7 @@
|
||||
v-model="searchText"
|
||||
type="text"
|
||||
placeholder="Cari ..."
|
||||
class="border rounded-md px-3 py-2 w-64 focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
class="border border-C bg-A rounded-md px-3 py-2 w-64 focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
@input="$emit('update:search', searchText)"
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<div class="lg:p-2 pt-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-5 gap-3 sm:gap-2 max-w-7xl mx-auto">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-5 gap-3 sm:gap-2 mx-auto min-h-[75vh]">
|
||||
<!-- Left Section - Form Kasir -->
|
||||
<div class="lg:col-span-3">
|
||||
<div class="bg-white rounded-xl shadow-lg border border-B overflow-hidden h-full">
|
||||
|
15
resources/js/pages/Laporan.vue
Normal file
15
resources/js/pages/Laporan.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<div class="p-6">
|
||||
<p class="font-serif italic text-[25px] text-D">Laporan</p>
|
||||
|
||||
<RingkasanLaporanB />
|
||||
</div>
|
||||
</mainLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import RingkasanLaporanA from '../components/RingkasanLaporanA.vue';
|
||||
import RingkasanLaporanB from '../components/RingkasanLaporanB.vue';
|
||||
import mainLayout from "../layouts/mainLayout.vue";
|
||||
</script>
|
@ -8,6 +8,7 @@ import InputProduk from '../pages/InputProduk.vue'
|
||||
import Kategori from '../pages/Kategori.vue'
|
||||
import Sales from '../pages/Sales.vue'
|
||||
import EditProduk from '../pages/EditProduk.vue'
|
||||
import Laporan from '../pages/Laporan.vue'
|
||||
|
||||
import Login from '../pages/Login.vue'
|
||||
|
||||
@ -66,6 +67,11 @@ const routes = [
|
||||
name: 'EditProduk',
|
||||
component: EditProduk,
|
||||
props: true // biar id bisa langsung jadi props di komponen
|
||||
},
|
||||
{
|
||||
path: '/laporan',
|
||||
name: 'EditProduk',
|
||||
component: Laporan
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -3,6 +3,7 @@
|
||||
use App\Http\Controllers\FotoSementaraController;
|
||||
use App\Http\Controllers\ItemController;
|
||||
use App\Http\Controllers\KategoriController;
|
||||
use App\Http\Controllers\LaporanController;
|
||||
use App\Http\Controllers\NampanController;
|
||||
use App\Http\Controllers\ProdukController;
|
||||
use App\Http\Controllers\SalesController;
|
||||
@ -28,6 +29,9 @@ Route::prefix('api')->group(function () {
|
||||
Route::delete('foto/hapus/{id}', [FotoSementaraController::class, 'hapus']);
|
||||
Route::get('foto/{user_id}', [FotoSementaraController::class, 'getAll']);
|
||||
Route::delete('foto/reset/{user_id}', [FotoSementaraController::class, 'reset']);
|
||||
|
||||
// Laporan
|
||||
Route::get('laporan', [LaporanController::class, 'ringkasan']);
|
||||
});
|
||||
|
||||
// Frontend SPA
|
||||
|
Loading…
Reference in New Issue
Block a user