[Feat] detail laporan

This commit is contained in:
Baghaztra 2025-09-03 17:04:37 +07:00
parent ae259cc273
commit 644d6fb222
11 changed files with 272 additions and 63 deletions

View File

@ -2,12 +2,15 @@
namespace App\Http\Controllers;
use App\Models\ItemTransaksi;
use App\Models\Produk;
use App\Models\Transaksi;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class LaporanController extends Controller
{
@ -139,7 +142,8 @@ class LaporanController extends Controller
private function hitungDataSales(Collection $transaksisPerSales): array
{
$itemTerjual = $transaksisPerSales->sum(fn($t) => $t->itemTransaksi->count());
$beratTerjual = $transaksisPerSales->sum(fn ($t) =>
$beratTerjual = $transaksisPerSales->sum(
fn($t) =>
$t->itemTransaksi->sum(fn($it) => $it->item->produk->berat ?? 0)
);
$pendapatan = $transaksisPerSales->sum('total_harga');
@ -173,4 +177,100 @@ class LaporanController extends Controller
return $sale;
});
}
public function detail(Request $request)
{
// 1. VALIDASI DAN PENGAMBILAN PARAMETER FILTER
$request->validate([
'tanggal' => 'required|date_format:Y-m-d',
]);
$tanggal = $request->query('tanggal');
$namaSales = $request->query('nama_sales');
$posisi = $request->query('posisi');
$namaPembeli = $request->query('nama_pembeli'); // Untuk pencarian
$carbonDate = Carbon::parse($tanggal);
// 2. QUERY UTAMA UNTUK MENGAMBIL DATA PRODUK YANG TERJUAL BERDASARKAN FILTER
// Query ini hanya akan mengambil produk yang memiliki transaksi sesuai filter.
$produkTerjualQuery = ItemTransaksi::query()
->join('items', 'item_transaksis.id_item', '=', 'items.id')
->join('produks', 'items.id_produk', '=', 'produks.id')
->join('transaksis', 'item_transaksis.id_transaksi', '=', 'transaksis.id')
// Filter Wajib: Tanggal
->whereDate('transaksis.created_at', $carbonDate)
// Filter Opsional: Nama Sales
->when($namaSales, function ($query, $namaSales) {
return $query->where('transaksis.nama_sales', $namaSales);
})
// Filter Opsional: Posisi Asal Item
->when($posisi, function ($query, $posisi) {
return $query->where('item_transaksis.posisi_asal', $posisi);
})
// Filter Opsional: Nama Pembeli (menggunakan LIKE untuk pencarian)
->when($namaPembeli, function ($query, $namaPembeli) {
return $query->where('transaksis.nama_pembeli', 'like', "%{$namaPembeli}%");
})
->select(
'produks.id as id_produk',
'produks.nama as nama_produk',
DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'),
DB::raw('SUM(produks.berat) as berat_terjual'),
DB::raw('SUM(item_transaksis.harga_deal) as pendapatan')
)
->groupBy('produks.id', 'produks.nama')
->get()
// Mengubah collection menjadi array asosiatif dengan key id_produk agar mudah dicari
->keyBy('id_produk');
// 3. MENGAMBIL SEMUA PRODUK DARI DATABASE
$semuaProduk = Produk::query()->select('id', 'nama')->get();
// 4. MENGGABUNGKAN DATA SEMUA PRODUK DENGAN PRODUK YANG TERJUAL
$detailItem = $semuaProduk->map(function ($produk) use ($produkTerjualQuery) {
// Cek apakah produk ini ada di dalam daftar produk yang terjual
if ($produkTerjualQuery->has($produk->id)) {
$dataTerjual = $produkTerjualQuery->get($produk->id);
return [
'nama_produk' => $produk->nama,
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual,
'berat_terjual' => (float) $dataTerjual->berat_terjual,
'pendapatan' => (float) $dataTerjual->pendapatan,
];
} else {
// Jika produk tidak terjual, berikan nilai default "-"
return [
'nama_produk' => $produk->nama,
'jumlah_item_terjual' => '-',
'berat_terjual' => '-',
'pendapatan' => '-',
];
}
});
// 5. MENGHITUNG TOTAL REKAP HARIAN DARI DATA YANG SUDAH DIFILTER
$totalPendapatan = $produkTerjualQuery->sum('pendapatan');
$totalItemTerjual = $produkTerjualQuery->sum('jumlah_item_terjual');
$totalBeratTerjual = $produkTerjualQuery->sum('berat_terjual');
// 6. MENYUSUN STRUKTUR RESPONSE FINAL
$response = [
'filter' => [
'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'),
'nama_sales' => $namaSales,
'posisi' => $posisi,
'nama_pembeli' => $namaPembeli,
],
'rekap_harian' => [
'total_item_terjual' => $totalItemTerjual,
'total_berat_terjual' => $totalBeratTerjual,
'total_pendapatan' => $totalPendapatan,
],
'produk' => $detailItem,
];
return response()->json($response);
}
}

View File

@ -13,7 +13,8 @@ class ItemTransaksi extends Model
protected $fillable = [
'id_transaksi',
'id_item',
'harga_deal'
'harga_deal',
'posisi_asal'
];
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];

View File

@ -37,14 +37,4 @@ class Transaksi extends Model
{
return $this->hasMany(ItemTransaksi::class, 'id_transaksi');
}
public function items()
{
return $this->hasMany(ItemTransaksi::class, 'id_transaksi');
}
public function foto ()
{
return $this->hasMany(Foto::class, 'id_produk');
}
}

View File

@ -17,12 +17,16 @@ class ProdukFactory extends Factory
*/
public function definition(): array
{
$kategori = Kategori::inRandomOrder()->first();
$harga_per_gram = $this->faker->numberBetween(80, 120) * 10000;
$berat = $this->faker->randomFloat(2, 1, 10);
$kategoriList = Kategori::all()->pluck('id')->toArray();
return [
'nama' => $this->faker->words(3, true),
'id_kategori' => $this->faker->randomElement($kategoriList),
'nama' => $kategori->nama . ' ' . $this->faker->words(mt_rand(1, 2), true),
'id_kategori' => $kategori->id,
'berat' => $berat,
'kadar' => $this->faker->numberBetween(10, 24),
'harga_per_gram' => $harga_per_gram,

View File

@ -23,15 +23,16 @@ class TransaksiFactory extends Factory
$kasir = User::inRandomOrder()->first();
$date = $this->faker->dateTimeBetween('-3 months');
$ongkos_bikin = $this->faker->numberBetween(8, 12) * 10000;
return [
'id_kasir' => $kasir?->id,
'id_sales' => $sales?->id,
'nama_sales' => $sales?->nama ?? $this->faker->name(),
'nama_pembeli' => $sales?->nama ?? $this->faker->name(),
'nama_sales' => $sales?->nama,
'nama_pembeli' => $this->faker->name(),
'no_hp' => $this->faker->phoneNumber(),
'alamat' => $this->faker->address(),
'ongkos_bikin' => $this->faker->randomFloat(2, 0, 1000000),
'total_harga' => $this->faker->randomFloat(2, 100000, 5000000),
'ongkos_bikin' => $ongkos_bikin,
'total_harga' => $ongkos_bikin,
'created_at' => $date,
'updated_at' => $date,
];

View File

@ -16,6 +16,7 @@ return new class extends Migration
$table->foreignId('id_transaksi')->constrained('transaksis')->onDelete('cascade');
$table->foreignId('id_item')->constrained('items');
$table->double('harga_deal');
$table->string('posisi_asal', 100);
$table->timestamps();
});
}

View File

@ -77,17 +77,24 @@ class DatabaseSeeder extends Seeder
}
}
Transaksi::factory(20)->create()->each(function ($transaksi) {
$jumlah_item = rand(1, 5);
Transaksi::factory(40)->create()->each(function ($transaksi) {
$jumlah_item = rand(1, 2);
$items = Item::where('is_sold', false)->inRandomOrder()->limit($jumlah_item)->get();
if ($items->isEmpty()) return;
$total_harga = $transaksi->total_harga;
foreach ($items as $item) {
$transaksi->itemTransaksi()->create([
'id_item' => $item->id,
'harga_deal' => $item->produk->harga_jual,
'posisi_asal' => $item->id_nampan ? 'Nampan ' . $item->nampan->nama : 'Brankas',
]);
$item->update(['is_sold' => true]);
$item->update([
'id_nampan' => null,
'is_sold' => true,
]);
$total_harga += $item->produk->harga_jual;
}
$transaksi->update(['total_harga' => $total_harga]);
});
}
}

View File

@ -0,0 +1,79 @@
<template>
<div class="my-6">
<hr class="border-B mb-5" />
<div class="flez flex-row mb-3">
<input type="date" v-model="tanggalDipilih"
class="mt-1 block 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>
<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">Nama Produk</th>
<th class="border-x border-C px-3 py-3">Item Terjual</th>
<th class="border-x border-C px-3 py-3">Total Berat</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="!produk.length">
<td colspan="5" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
</tr>
<template v-else v-for="item in produk" :key="item.nama_produk">
<tr class="hover:bg-B">
<td class="border-x border-C px-3 py-2 text-center">{{ item.nama_produk }}</td>
<td class="border-x border-C px-3 py-2 text-center">{{ item.jumlah_item_terjual }}</td>
<td class="border-x border-C px-3 py-2 text-center">{{ item.berat_terjual }} gr</td>
<td class="border-x border-C px-3 py-2 text-center">Rp {{ item.pendapatan }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue';
import axios from 'axios';
const tanggalDipilih = ref('');
const data = ref(null);
const loading = ref(false);
const produk = computed(() => data.value?.produk || []);
const fetchData = async (date) => {
if (!date) return;
loading.value = true;
try {
const response = await axios.get(`/api/detail-laporan?tanggal=${date}`);
data.value = response.data;
// console.log("Data berhasil diambil:", data.value);
} catch (error) {
console.error('Gagal mengambil data laporan:', error);
data.value = null;
}finally {
loading.value = false;
}
};
onMounted(() => {
const today = new Date().toISOString().split('T')[0];
tanggalDipilih.value = today;
});
watch(tanggalDipilih, (newDate) => {
fetchData(newDate);
}, { immediate: true });
</script>

View File

@ -4,11 +4,14 @@
<p class="font-serif italic text-[25px] text-D">Laporan</p>
<RingkasanLaporanB />
<DetailLaporan />
</div>
</mainLayout>
</template>
<script setup>
import DetailLaporan from '../components/DetailLaporan.vue';
import RingkasanLaporanA from '../components/RingkasanLaporanA.vue';
import RingkasanLaporanB from '../components/RingkasanLaporanB.vue';
import mainLayout from "../layouts/mainLayout.vue";

View File

@ -1,12 +1,34 @@
<!DOCTYPE html>
<html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable-no" />
<title>Abbauf App</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@yield('title', config('app.name', 'Abbauf App'))</title>
<meta name="description" content="@yield('description', 'Deskripsi default untuk aplikasi Abbauf.')">
<meta name="author" content="Nama Anda atau Perusahaan Anda">
<meta property="og:title" content="@yield('title', config('app.name', 'Abbauf App'))" />
<meta property="og:description" content="@yield('description', 'Deskripsi default untuk aplikasi Abbauf.')" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ url()->current() }}" />
<meta property="og:image" content="@yield('og_image', asset('images/default-social-image.jpg'))" />
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="@yield('title', config('app.name', 'Abbauf App'))">
<meta name="twitter:description" content="@yield('description', 'Deskripsi default untuk aplikasi Abbauf.')">
<meta name="twitter:image" content="@yield('og_image', asset('images/default-social-image.jpg'))">
<link rel="icon" href="{{ asset('favicon.ico') }}" type="image/x-icon">
<link rel="apple-touch-icon" href="{{ asset('apple-touch-icon.png') }}">
<meta name="theme-color" content="#FFFFFF">
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
<div id="app"></div>
</body>
@vite(['resources/js/app.js', 'resources/css/app.css'])
</html>

View File

@ -32,6 +32,7 @@ Route::prefix('api')->group(function () {
// Laporan
Route::get('laporan', [LaporanController::class, 'ringkasan']);
Route::get('detail-laporan', [LaporanController::class, 'detail']);
});
// Frontend SPA