Compare commits
	
		
			2 Commits
		
	
	
		
			86f3e101c8
			...
			10e666a9ce
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 10e666a9ce | ||
|  | ebb17c2a43 | 
| @ -5,6 +5,8 @@ namespace App\Http\Controllers; | |||||||
| use App\Models\ItemTransaksi; | use App\Models\ItemTransaksi; | ||||||
| use App\Models\Produk; | use App\Models\Produk; | ||||||
| use App\Models\Transaksi; | use App\Models\Transaksi; | ||||||
|  | use App\Models\Sales; | ||||||
|  | use App\Models\Nampan; | ||||||
| use Carbon\Carbon; | use Carbon\Carbon; | ||||||
| use Carbon\CarbonPeriod; | use Carbon\CarbonPeriod; | ||||||
| use Illuminate\Http\Request; | use Illuminate\Http\Request; | ||||||
| @ -14,6 +16,10 @@ use Illuminate\Support\Facades\DB; | |||||||
| 
 | 
 | ||||||
| class LaporanController extends Controller | class LaporanController extends Controller | ||||||
| { | { | ||||||
|  |     private const CURRENCY_SYMBOL = 'Rp '; | ||||||
|  |     private const WEIGHT_UNIT = ' g'; | ||||||
|  |     private const DEFAULT_DISPLAY = '-'; | ||||||
|  | 
 | ||||||
|     public function ringkasan(Request $request) |     public function ringkasan(Request $request) | ||||||
|     { |     { | ||||||
|         $filter = $request->query('filter', 'bulan'); |         $filter = $request->query('filter', 'bulan'); | ||||||
| @ -28,10 +34,122 @@ class LaporanController extends Controller | |||||||
|         return $this->laporanBulanan($page, $allSalesNames); |         return $this->laporanBulanan($page, $allSalesNames); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     public function detailPerProduk(Request $request) | ||||||
|  |     { | ||||||
|  |         $request->validate([ | ||||||
|  |             'tanggal' => 'required|date_format:Y-m-d', | ||||||
|  |             'page' => 'nullable|integer|min:1', | ||||||
|  |             'per_page' => 'nullable|integer|min:1|max:100', | ||||||
|  |         ]); | ||||||
|  | 
 | ||||||
|  |         $tanggal = $request->query('tanggal'); | ||||||
|  |         $salesId = $request->query('sales_id'); | ||||||
|  |         $nampanId = $request->query('nampan_id'); | ||||||
|  |         $namaPembeli = $request->query('nama_pembeli'); | ||||||
|  |         $page = $request->query('page', 1); | ||||||
|  |         $perPage = $request->query('per_page', 15); | ||||||
|  | 
 | ||||||
|  |         $carbonDate = Carbon::parse($tanggal); | ||||||
|  | 
 | ||||||
|  |         $produkTerjualQuery = $this->buildBaseItemQuery($carbonDate); | ||||||
|  |         $this->applyFilters($produkTerjualQuery, $salesId, $nampanId, $namaPembeli); | ||||||
|  | 
 | ||||||
|  |         $produkTerjual = $produkTerjualQuery | ||||||
|  |             ->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() | ||||||
|  |             ->keyBy('id_produk'); | ||||||
|  | 
 | ||||||
|  |         $totals = $this->calculateTotals($produkTerjual); | ||||||
|  |         $semuaProdukPaginated = Produk::select('id', 'nama')->orderBy('nama')->paginate($perPage, ['*'], 'page', $page); | ||||||
|  |         $detailItem = $this->mapProductsWithSalesData($semuaProdukPaginated, $produkTerjual); | ||||||
|  |         $filterInfo = $this->buildFilterInfo($carbonDate, $salesId, $nampanId, $namaPembeli); | ||||||
|  | 
 | ||||||
|  |         return response()->json([ | ||||||
|  |             'filter' => $filterInfo, | ||||||
|  |             'rekap_harian' => $totals, | ||||||
|  |             'produk' => $detailItem->values(), | ||||||
|  |             'pagination' => $this->buildPaginationInfo($semuaProdukPaginated), | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public function detailPerNampan(Request $request) | ||||||
|  |     { | ||||||
|  |         $request->validate([ | ||||||
|  |             'tanggal' => 'required|date_format:Y-m-d', | ||||||
|  |             'page' => 'nullable|integer|min:1', | ||||||
|  |             'per_page' => 'nullable|integer|min:1|max:100', | ||||||
|  |         ]); | ||||||
|  | 
 | ||||||
|  |         $tanggal = $request->query('tanggal'); | ||||||
|  |         $salesId = $request->query('sales_id'); | ||||||
|  |         $produkId = $request->query('produk_id'); | ||||||
|  |         $namaPembeli = $request->query('nama_pembeli'); | ||||||
|  |         $page = $request->query('page', 1); | ||||||
|  |         $perPage = $request->query('per_page', 15); | ||||||
|  | 
 | ||||||
|  |         $carbonDate = Carbon::parse($tanggal); | ||||||
|  | 
 | ||||||
|  |         // Query untuk mendapatkan data penjualan per nampan
 | ||||||
|  |         $nampanTerjualQuery = $this->buildBaseItemQuery($carbonDate); | ||||||
|  |         $this->applyNampanFilters($nampanTerjualQuery, $salesId, $produkId, $namaPembeli); | ||||||
|  | 
 | ||||||
|  |         // Menggunakan COALESCE untuk menggabungkan nampan dan brankas
 | ||||||
|  |         $nampanTerjual = $nampanTerjualQuery | ||||||
|  |             ->leftJoin('nampans', 'items.id_nampan', '=', 'nampans.id') | ||||||
|  |             ->select( | ||||||
|  |                 DB::raw('COALESCE(items.id_nampan, 0) as id_nampan'), | ||||||
|  |                 DB::raw('COALESCE(nampans.nama, "Brankas") as nama_nampan'), | ||||||
|  |                 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('id_nampan', 'nama_nampan') | ||||||
|  |             ->get() | ||||||
|  |             ->keyBy('id_nampan'); | ||||||
|  | 
 | ||||||
|  |         $totals = $this->calculateTotals($nampanTerjual); | ||||||
|  | 
 | ||||||
|  |         // Mendapatkan semua nampan + entry untuk brankas
 | ||||||
|  |         $semuaNampan = Nampan::select('id', 'nama')->orderBy('nama')->get(); | ||||||
|  |          | ||||||
|  |         // Tambahkan entry brankas (id = 0)
 | ||||||
|  |         $brankasEntry = (object) ['id' => 0, 'nama' => 'Brankas']; | ||||||
|  |         $semuaNampanCollection = $semuaNampan->prepend($brankasEntry); | ||||||
|  | 
 | ||||||
|  |         // Pagination manual
 | ||||||
|  |         $currentPage = $page; | ||||||
|  |         $offset = ($currentPage - 1) * $perPage; | ||||||
|  |         $itemsForCurrentPage = $semuaNampanCollection->slice($offset, $perPage); | ||||||
|  | 
 | ||||||
|  |         $semuaNampanPaginated = new LengthAwarePaginator( | ||||||
|  |             $itemsForCurrentPage, | ||||||
|  |             $semuaNampanCollection->count(), | ||||||
|  |             $perPage, | ||||||
|  |             $currentPage, | ||||||
|  |             ['path' => request()->url(), 'query' => request()->query()] | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         $detailItem = $this->mapNampanWithSalesData($semuaNampanPaginated, $nampanTerjual); | ||||||
|  |         $filterInfo = $this->buildNampanFilterInfo($carbonDate, $salesId, $produkId, $namaPembeli); | ||||||
|  | 
 | ||||||
|  |         return response()->json([ | ||||||
|  |             'filter' => $filterInfo, | ||||||
|  |             'rekap_harian' => $totals, | ||||||
|  |             'nampan' => $detailItem->values(), | ||||||
|  |             'pagination' => $this->buildPaginationInfo($semuaNampanPaginated), | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     private function laporanHarian(int $page, Collection $allSalesNames) |     private function laporanHarian(int $page, Collection $allSalesNames) | ||||||
|     { |     { | ||||||
|         $perPage = 7; |         $perPage = 7; | ||||||
| 
 |  | ||||||
|         $endDate = Carbon::today()->subDays(($page - 1) * $perPage); |         $endDate = Carbon::today()->subDays(($page - 1) * $perPage); | ||||||
|         $startDate = $endDate->copy()->subDays($perPage - 1); |         $startDate = $endDate->copy()->subDays($perPage - 1); | ||||||
| 
 | 
 | ||||||
| @ -53,7 +171,6 @@ class LaporanController extends Controller | |||||||
| 
 | 
 | ||||||
|             if (isset($transaksisByDay[$dateString])) { |             if (isset($transaksisByDay[$dateString])) { | ||||||
|                 $transaksisPerTanggal = $transaksisByDay[$dateString]; |                 $transaksisPerTanggal = $transaksisByDay[$dateString]; | ||||||
| 
 |  | ||||||
|                 $salesDataTransaksi = $transaksisPerTanggal->groupBy('nama_sales') |                 $salesDataTransaksi = $transaksisPerTanggal->groupBy('nama_sales') | ||||||
|                     ->map(fn($transaksisPerSales) => $this->hitungDataSales($transaksisPerSales)); |                     ->map(fn($transaksisPerSales) => $this->hitungDataSales($transaksisPerSales)); | ||||||
| 
 | 
 | ||||||
| @ -67,21 +184,22 @@ class LaporanController extends Controller | |||||||
| 
 | 
 | ||||||
|                 $laporan[$dateString] = [ |                 $laporan[$dateString] = [ | ||||||
|                     'tanggal' => $tanggalFormatted, |                     'tanggal' => $tanggalFormatted, | ||||||
|                     'total_item_terjual' => $totalItem > 0 ? $totalItem : '-', |                     'total_item_terjual' => $totalItem > 0 ? $totalItem : self::DEFAULT_DISPLAY, | ||||||
|                     'total_berat' => $totalBerat > 0 ? number_format($totalBerat, 2, ',', '.') . 'g' : '-', |                     'total_berat' => $totalBerat > 0 ? $this->formatWeight($totalBerat) : self::DEFAULT_DISPLAY, | ||||||
|                     'total_pendapatan' => $totalPendapatan > 0 ? 'Rp' . number_format($totalPendapatan, 2, ',', '.') : '-', |                     'total_pendapatan' => $totalPendapatan > 0 ? $this->formatCurrency($totalPendapatan) : self::DEFAULT_DISPLAY, | ||||||
|                     'sales' => $this->formatSalesDataValues($fullSalesData)->values(), |                     'sales' => $this->formatSalesDataValues($fullSalesData)->values(), | ||||||
|                 ]; |                 ]; | ||||||
|             } else { |             } else { | ||||||
|                 $laporan[$dateString] = [ |                 $laporan[$dateString] = [ | ||||||
|                     'tanggal' => $tanggalFormatted, |                     'tanggal' => $tanggalFormatted, | ||||||
|                     'total_item_terjual' => '-', |                     'total_item_terjual' => self::DEFAULT_DISPLAY, | ||||||
|                     'total_berat' => '-', |                     'total_berat' => self::DEFAULT_DISPLAY, | ||||||
|                     'total_pendapatan' => '-', |                     'total_pendapatan' => self::DEFAULT_DISPLAY, | ||||||
|                     'sales' => [], |                     'sales' => [], | ||||||
|                 ]; |                 ]; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  | 
 | ||||||
|         $totalHariUntukPaginasi = 365; |         $totalHariUntukPaginasi = 365; | ||||||
|         $paginatedData = new LengthAwarePaginator( |         $paginatedData = new LengthAwarePaginator( | ||||||
|             array_reverse(array_values($laporan)), |             array_reverse(array_values($laporan)), | ||||||
| @ -104,30 +222,28 @@ class LaporanController extends Controller | |||||||
| 
 | 
 | ||||||
|         $laporan = $transaksis->groupBy(function ($transaksi) { |         $laporan = $transaksis->groupBy(function ($transaksi) { | ||||||
|             return Carbon::parse($transaksi->created_at)->format('F Y'); |             return Carbon::parse($transaksi->created_at)->format('F Y'); | ||||||
|         }) |         })->map(function ($transaksisPerTanggal, $tanggal) use ($allSalesNames) { | ||||||
|             ->map(function ($transaksisPerTanggal, $tanggal) use ($allSalesNames) { |             $salesDataTransaksi = $transaksisPerTanggal | ||||||
|  |                 ->groupBy('nama_sales') | ||||||
|  |                 ->map(fn($transaksisPerSales) => $this->hitungDataSales($transaksisPerSales)); | ||||||
| 
 | 
 | ||||||
|                 $salesDataTransaksi = $transaksisPerTanggal |             $fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) { | ||||||
|                     ->groupBy('nama_sales') |                 return $salesDataTransaksi->get($namaSales) ?? $this->defaultSalesData($namaSales); | ||||||
|                     ->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(), |  | ||||||
|                 ]; |  | ||||||
|             }); |             }); | ||||||
| 
 | 
 | ||||||
|  |             $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 : self::DEFAULT_DISPLAY, | ||||||
|  |                 'total_berat' => $totalBerat > 0 ? $this->formatWeight($totalBerat) : self::DEFAULT_DISPLAY, | ||||||
|  |                 'total_pendapatan' => $totalPendapatan > 0 ? $this->formatCurrency($totalPendapatan) : self::DEFAULT_DISPLAY, | ||||||
|  |                 'sales' => $this->formatSalesDataValues($fullSalesData)->values(), | ||||||
|  |             ]; | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|         $paginatedData = new LengthAwarePaginator( |         $paginatedData = new LengthAwarePaginator( | ||||||
|             $laporan->forPage($page, $perPage)->values(), |             $laporan->forPage($page, $perPage)->values(), | ||||||
|             $laporan->count(), |             $laporan->count(), | ||||||
| @ -139,12 +255,175 @@ class LaporanController extends Controller | |||||||
|         return response()->json($paginatedData); |         return response()->json($paginatedData); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     private function buildBaseItemQuery(Carbon $carbonDate) | ||||||
|  |     { | ||||||
|  |         return 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') | ||||||
|  |             ->whereDate('transaksis.created_at', $carbonDate); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private function applyFilters($query, $salesId, $nampanId, $namaPembeli) | ||||||
|  |     { | ||||||
|  |         if ($salesId) { | ||||||
|  |             $query->join('sales', 'transaksis.id_sales', '=', 'sales.id') | ||||||
|  |                 ->where('sales.id', $salesId); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if ($nampanId) { | ||||||
|  |             if ($nampanId == 0) { | ||||||
|  |                 // Filter untuk brankas (id_nampan = null)
 | ||||||
|  |                 $query->whereNull('items.id_nampan'); | ||||||
|  |             } else { | ||||||
|  |                 // Filter untuk nampan tertentu
 | ||||||
|  |                 $query->where('items.id_nampan', $nampanId); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if ($namaPembeli) { | ||||||
|  |             $query->where('transaksis.nama_pembeli', 'like', "%{$namaPembeli}%"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private function applyNampanFilters($query, $salesId, $produkId, $namaPembeli) | ||||||
|  |     { | ||||||
|  |         if ($salesId) { | ||||||
|  |             $query->join('sales', 'transaksis.id_sales', '=', 'sales.id') | ||||||
|  |                 ->where('sales.id', $salesId); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if ($produkId) { | ||||||
|  |             $query->where('produks.id', $produkId); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if ($namaPembeli) { | ||||||
|  |             $query->where('transaksis.nama_pembeli', 'like', "%{$namaPembeli}%"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private function calculateTotals(Collection $data): array | ||||||
|  |     { | ||||||
|  |         $totalPendapatan = $data->sum('pendapatan'); | ||||||
|  |         $totalItemTerjual = $data->sum('jumlah_item_terjual'); | ||||||
|  |         $totalBeratTerjual = $data->sum('berat_terjual'); | ||||||
|  | 
 | ||||||
|  |         return [ | ||||||
|  |             'total_item_terjual' => $totalItemTerjual, | ||||||
|  |             'total_berat_terjual' => $this->formatWeight($totalBeratTerjual), | ||||||
|  |             'total_pendapatan' => $this->formatCurrency($totalPendapatan), | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private function mapProductsWithSalesData($paginatedData, Collection $salesData): Collection | ||||||
|  |     { | ||||||
|  |         return $paginatedData->getCollection()->map(function ($item) use ($salesData) { | ||||||
|  |             if ($salesData->has($item->id)) { | ||||||
|  |                 $dataTerjual = $salesData->get($item->id); | ||||||
|  |                 return [ | ||||||
|  |                     'nama_produk' => $item->nama, | ||||||
|  |                     'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual, | ||||||
|  |                     'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual), | ||||||
|  |                     'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan), | ||||||
|  |                 ]; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             return [ | ||||||
|  |                 'nama_produk' => $item->nama, | ||||||
|  |                 'jumlah_item_terjual' => self::DEFAULT_DISPLAY, | ||||||
|  |                 'berat_terjual' => self::DEFAULT_DISPLAY, | ||||||
|  |                 'pendapatan' => self::DEFAULT_DISPLAY, | ||||||
|  |             ]; | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private function mapNampanWithSalesData($paginatedData, Collection $salesData): Collection | ||||||
|  |     { | ||||||
|  |         return $paginatedData->getCollection()->map(function ($item) use ($salesData) { | ||||||
|  |             if ($salesData->has($item->id)) { | ||||||
|  |                 $dataTerjual = $salesData->get($item->id); | ||||||
|  |                 return [ | ||||||
|  |                     'nama_nampan' => $item->nama, | ||||||
|  |                     'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual, | ||||||
|  |                     'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual), | ||||||
|  |                     'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan), | ||||||
|  |                 ]; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             return [ | ||||||
|  |                 'nama_nampan' => $item->nama, | ||||||
|  |                 'jumlah_item_terjual' => self::DEFAULT_DISPLAY, | ||||||
|  |                 'berat_terjual' => self::DEFAULT_DISPLAY, | ||||||
|  |                 'pendapatan' => self::DEFAULT_DISPLAY, | ||||||
|  |             ]; | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private function buildFilterInfo(Carbon $carbonDate, $salesId, $nampanId, $namaPembeli): array | ||||||
|  |     { | ||||||
|  |         $filterInfo = [ | ||||||
|  |             'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'), | ||||||
|  |             'nama_sales' => null, | ||||||
|  |             'nampan' => null, | ||||||
|  |             'nama_pembeli' => $namaPembeli, | ||||||
|  |         ]; | ||||||
|  | 
 | ||||||
|  |         if ($salesId) { | ||||||
|  |             $sales = Sales::find($salesId); | ||||||
|  |             $filterInfo['nama_sales'] = $sales?->nama; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if ($nampanId) { | ||||||
|  |             if ($nampanId == 0) { | ||||||
|  |                 $filterInfo['nampan'] = 'Brankas'; | ||||||
|  |             } else { | ||||||
|  |                 $nampan = Nampan::find($nampanId); | ||||||
|  |                 $filterInfo['nampan'] = $nampan?->nama; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return $filterInfo; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private function buildNampanFilterInfo(Carbon $carbonDate, $salesId, $produkId, $namaPembeli): array | ||||||
|  |     { | ||||||
|  |         $filterInfo = [ | ||||||
|  |             'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'), | ||||||
|  |             'nama_sales' => null, | ||||||
|  |             'produk' => null, | ||||||
|  |             'nama_pembeli' => $namaPembeli, | ||||||
|  |         ]; | ||||||
|  | 
 | ||||||
|  |         if ($salesId) { | ||||||
|  |             $sales = Sales::find($salesId); | ||||||
|  |             $filterInfo['nama_sales'] = $sales?->nama; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if ($produkId) { | ||||||
|  |             $produk = Produk::find($produkId); | ||||||
|  |             $filterInfo['produk'] = $produk?->nama; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return $filterInfo; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private function buildPaginationInfo($paginatedData): array | ||||||
|  |     { | ||||||
|  |         return [ | ||||||
|  |             'current_page' => $paginatedData->currentPage(), | ||||||
|  |             'last_page' => $paginatedData->lastPage(), | ||||||
|  |             'per_page' => $paginatedData->perPage(), | ||||||
|  |             'total' => $paginatedData->total(), | ||||||
|  |             'from' => $paginatedData->firstItem(), | ||||||
|  |             'to' => $paginatedData->lastItem(), | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     private function hitungDataSales(Collection $transaksisPerSales): array |     private function hitungDataSales(Collection $transaksisPerSales): array | ||||||
|     { |     { | ||||||
|         $itemTerjual = $transaksisPerSales->sum(fn($t) => $t->itemTransaksi->count()); |         $itemTerjual = $transaksisPerSales->sum(fn($t) => $t->itemTransaksi->count()); | ||||||
|         $beratTerjual = $transaksisPerSales->sum( |         $beratTerjual = $transaksisPerSales->sum( | ||||||
|             fn($t) => |             fn($t) => $t->itemTransaksi->sum(fn($it) => $it->item->produk->berat ?? 0) | ||||||
|             $t->itemTransaksi->sum(fn($it) => $it->item->produk->berat ?? 0) |  | ||||||
|         ); |         ); | ||||||
|         $pendapatan = $transaksisPerSales->sum('total_harga'); |         $pendapatan = $transaksisPerSales->sum('total_harga'); | ||||||
| 
 | 
 | ||||||
| @ -169,108 +448,22 @@ class LaporanController extends Controller | |||||||
|     private function formatSalesDataValues(Collection $salesData): Collection |     private function formatSalesDataValues(Collection $salesData): Collection | ||||||
|     { |     { | ||||||
|         return $salesData->map(function ($sale) { |         return $salesData->map(function ($sale) { | ||||||
|             $sale['item_terjual'] = $sale['item_terjual'] > 0 ? $sale['item_terjual'] : '-'; |             $sale['item_terjual'] = $sale['item_terjual'] > 0 ? $sale['item_terjual'] : self::DEFAULT_DISPLAY; | ||||||
|             $sale['berat_terjual'] = $sale['berat_terjual_raw'] > 0 ? number_format($sale['berat_terjual_raw'], 2, ',', '.') . 'g' : '-'; |             $sale['berat_terjual'] = $sale['berat_terjual_raw'] > 0 ? $this->formatWeight($sale['berat_terjual_raw']) : self::DEFAULT_DISPLAY; | ||||||
|             $sale['pendapatan'] = $sale['pendapatan_raw'] > 0 ? 'Rp' . number_format($sale['pendapatan_raw'], 2, ',', '.') : '-'; |             $sale['pendapatan'] = $sale['pendapatan_raw'] > 0 ? $this->formatCurrency($sale['pendapatan_raw']) : self::DEFAULT_DISPLAY; | ||||||
| 
 | 
 | ||||||
|             unset($sale['berat_terjual_raw'], $sale['pendapatan_raw']); |             unset($sale['berat_terjual_raw'], $sale['pendapatan_raw']); | ||||||
|             return $sale; |             return $sale; | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| public function detail(Request $request) |     private function formatCurrency(float $amount): string | ||||||
|     { |     { | ||||||
|         // 1. VALIDASI DAN PENGAMBILAN PARAMETER FILTER
 |         return self::CURRENCY_SYMBOL . number_format($amount, 2, ',', '.'); | ||||||
|         $request->validate([ |     } | ||||||
|             'tanggal' => 'required|date_format:Y-m-d', |  | ||||||
|         ]); |  | ||||||
| 
 | 
 | ||||||
|         $tanggal = $request->query('tanggal'); |     private function formatWeight(float $weight): string | ||||||
|         $namaSales = $request->query('nama_sales'); |     { | ||||||
|         $posisi = $request->query('posisi'); |         return number_format($weight, 2, ',', '.') . self::WEIGHT_UNIT; | ||||||
|         $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); |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,100 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="my-6"> |  | ||||||
|     <hr class="border-B mb-5" /> |  | ||||||
|     <div class="flex flex-row mb-3 overflow-x-auto"> |  | ||||||
|       <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" /> |  | ||||||
|       <InputSelect class="ml-3" :options="opsiSales" v-model="salesDipilih" /> |  | ||||||
|     </div> |  | ||||||
|     <div class="mt-5 overflow-x-auto"> |  | ||||||
|       <table class="w-full border-collapse border border-C rounded-md"> |  | ||||||
|         <thead> |  | ||||||
|           <tr class="bg-C text-D rounded-t-md"> |  | ||||||
|             <th class="border-x border-C px-3 py-3">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> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
| 
 |  | ||||||
| <script setup> |  | ||||||
| import { ref, onMounted, watch, computed } from 'vue'; |  | ||||||
| import InputSelect from './InputSelect.vue'; |  | ||||||
| import axios from 'axios'; |  | ||||||
| 
 |  | ||||||
| const tanggalDipilih = ref(''); |  | ||||||
| const data = ref(null); |  | ||||||
| const loading = ref(false); |  | ||||||
| 
 |  | ||||||
| const produk = computed(() => data.value?.produk || []); |  | ||||||
| 
 |  | ||||||
| const salesDipilih = ref(null); |  | ||||||
| const opsiSales = ref([ |  | ||||||
|   { label: 'Semua Sales', value: null, selected: true }, |  | ||||||
| ]); |  | ||||||
| 
 |  | ||||||
| const fetchSales = async () => { |  | ||||||
|   try { |  | ||||||
|     const response = await axios.get('/api/sales'); |  | ||||||
|     const salesData = response.data; |  | ||||||
|     opsiSales.value = [{ label: 'Semua Sales', value: null }, ...salesData.map(sales => ({ |  | ||||||
|       label: sales.nama, |  | ||||||
|       value: sales.id, |  | ||||||
|     }))]; |  | ||||||
|   } catch (error) { |  | ||||||
|     console.error('Gagal mengambil data sales:', error); |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const fetchData = async (date) => { |  | ||||||
|   if (!date) return; |  | ||||||
| 
 |  | ||||||
|   loading.value = true; |  | ||||||
| 
 |  | ||||||
|   try { |  | ||||||
|     const response = await axios.get(`/api/detail-laporan?tanggal=${date}`); |  | ||||||
|     data.value = response.data;; |  | ||||||
|   } 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; |  | ||||||
| 
 |  | ||||||
|   fetchSales(); |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| watch(tanggalDipilih, (newDate) => { |  | ||||||
|   fetchData(newDate); |  | ||||||
| }, { immediate: true }); |  | ||||||
| </script> |  | ||||||
							
								
								
									
										420
									
								
								resources/js/components/DetailPerNampan.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										420
									
								
								resources/js/components/DetailPerNampan.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,420 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="my-6"> | ||||||
|  |     <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="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" /> | ||||||
|  |       </div> | ||||||
|  |       <div class="mb-3 w-full"> | ||||||
|  |         <label class="text-D/80" for="pilihSales">Filter Sales:</label> | ||||||
|  |         <InputSelect :options="opsiSales" v-model="salesDipilih" id="pilihSales" /> | ||||||
|  |       </div> | ||||||
|  |       <div class="mb-3 w-full"> | ||||||
|  |         <label class="text-D/80" for="pilihPelanggan">Nama Pembeli:</label> | ||||||
|  |         <InputField placeholder="Nama pelanggan" v-model="namaPembeli" id="pilihPelanggan" /> | ||||||
|  |       </div> | ||||||
|  |       <div class="mb-3 w-full"> | ||||||
|  |         <label class="text-D/80" for="pilihProduk">Filter Produk:</label> | ||||||
|  |         <InputSelect :options="opsiProduk" v-model="produkDipilih" id="pilihProduk" /> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <!-- 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> | ||||||
|  | 
 | ||||||
|  |       <!-- Export Dropdown --> | ||||||
|  |       <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 right-0"> | ||||||
|  |           <ul class="py-1"> | ||||||
|  |             <li v-for="option in exportOptions" :key="option.value" @click="selectExport(option)" | ||||||
|  |               class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B"> | ||||||
|  |               {{ option.label }} | ||||||
|  |             </li> | ||||||
|  |           </ul> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <!-- Table Section --> | ||||||
|  |     <div class="mt-5 overflow-x-auto"> | ||||||
|  |       <table class="w-full border-collapse border border-C rounded-md"> | ||||||
|  |         <thead> | ||||||
|  |           <tr class="bg-C text-D rounded-t-md"> | ||||||
|  |             <th class="border-x border-C px-3 py-3"> | ||||||
|  |               <button @click="handleSort('nama_nampan')"  | ||||||
|  |                       class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors"> | ||||||
|  |                 <span>Nama Nampan</span> | ||||||
|  |                 <i :class="getSortIcon('nama_nampan')" class="ml-2"></i> | ||||||
|  |               </button> | ||||||
|  |             </th> | ||||||
|  |             <th class="border-x border-C px-3 py-3"> | ||||||
|  |               <button @click="handleSort('jumlah_item_terjual')"  | ||||||
|  |                       class="flex items-center justify-center w-full hover:text-D/80 transition-colors"> | ||||||
|  |                 <span>Item Terjual</span> | ||||||
|  |                 <i :class="getSortIcon('jumlah_item_terjual')" class="ml-2"></i> | ||||||
|  |               </button> | ||||||
|  |             </th> | ||||||
|  |             <th class="border-x border-C px-3 py-3"> | ||||||
|  |               <button @click="handleSort('berat_terjual')"  | ||||||
|  |                       class="flex items-center justify-center w-full hover:text-D/80 transition-colors"> | ||||||
|  |                 <span>Total Berat</span> | ||||||
|  |                 <i :class="getSortIcon('berat_terjual')" class="ml-2"></i> | ||||||
|  |               </button> | ||||||
|  |             </th> | ||||||
|  |             <th class="border-x border-C px-3 py-3"> | ||||||
|  |               <button @click="handleSort('pendapatan')"  | ||||||
|  |                       class="flex items-center justify-center w-full hover:text-D/80 transition-colors"> | ||||||
|  |                 <span>Total Pendapatan</span> | ||||||
|  |                 <i :class="getSortIcon('pendapatan')" class="ml-2"></i> | ||||||
|  |               </button> | ||||||
|  |             </th> | ||||||
|  |           </tr> | ||||||
|  |         </thead> | ||||||
|  |         <tbody> | ||||||
|  |           <tr v-if="loading"> | ||||||
|  |             <td colspan="4" class="p-4"> | ||||||
|  |               <div class="flex items-center justify-center w-full h-30"> | ||||||
|  |                 <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div> | ||||||
|  |                 <span class="ml-2 text-gray-600">Memuat data...</span> | ||||||
|  |               </div> | ||||||
|  |             </td> | ||||||
|  |           </tr> | ||||||
|  |           <tr v-else-if="!sortedNampan.length"> | ||||||
|  |             <td colspan="4" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td> | ||||||
|  |           </tr> | ||||||
|  |           <template v-else v-for="item in sortedNampan" :key="item.nama_nampan"> | ||||||
|  |             <tr class="text-center border-y border-C hover:bg-A"> | ||||||
|  |               <td class="border-x border-C px-3 py-2 text-left">{{ item.nama_nampan }}</td> | ||||||
|  |               <td class="border-x border-C px-3 py-2">{{ item.jumlah_item_terjual }}</td> | ||||||
|  |               <td class="border-x border-C px-3 py-2">{{ item.berat_terjual }}</td> | ||||||
|  |               <td class="border-x border-C px-3 py-2"> | ||||||
|  |                 <div class="flex justify-center"> | ||||||
|  |                   <div :ref="el => { if (el) pendapatanElements.push(el) }"  | ||||||
|  |                        :style="pendapatanStyle"  | ||||||
|  |                        :class="item.pendapatan == '-' ? 'text-center' : 'text-right'"> | ||||||
|  |                     {{ item.pendapatan }} | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </td> | ||||||
|  |             </tr> | ||||||
|  |           </template> | ||||||
|  |         </tbody> | ||||||
|  |       </table> | ||||||
|  | 
 | ||||||
|  |       <!-- 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"> | ||||||
|  |           Sebelumnya | ||||||
|  |         </button> | ||||||
|  |         <span class="text-sm text-D"> | ||||||
|  |           Halaman {{ pagination.current_page }} dari {{ pagination.last_page }} | ||||||
|  |         </span> | ||||||
|  |         <button @click="goToPage(pagination.current_page + 1)" | ||||||
|  |                 :disabled="(pagination.current_page === pagination.last_page) || loading" | ||||||
|  |                 class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80"> | ||||||
|  |           Berikutnya | ||||||
|  |         </button> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script setup> | ||||||
|  | import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'; | ||||||
|  | import InputSelect from './InputSelect.vue'; | ||||||
|  | import InputField from './InputField.vue'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | 
 | ||||||
|  | // --- State --- | ||||||
|  | const isExportOpen = ref(false); | ||||||
|  | const exportDropdownRef = ref(null); | ||||||
|  | 
 | ||||||
|  | const exportOptions = ref([ | ||||||
|  |   { value: 'pdf', label: 'Pdf' }, | ||||||
|  |   { value: 'xls', label: 'Excel' }, | ||||||
|  |   { value: 'csv', label: 'Csv' } | ||||||
|  | ]); | ||||||
|  | 
 | ||||||
|  | const exportFormat = ref(null); | ||||||
|  | const tanggalDipilih = ref(''); | ||||||
|  | const data = ref(null); | ||||||
|  | const loading = ref(false); | ||||||
|  | 
 | ||||||
|  | // Sorting state | ||||||
|  | const sortBy = ref(null); | ||||||
|  | const sortOrder = ref('asc'); // 'asc' or 'desc' | ||||||
|  | 
 | ||||||
|  | const pagination = ref({ | ||||||
|  |   current_page: 1, | ||||||
|  |   last_page: 1, | ||||||
|  |   total: 0, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const pendapatanWidth = ref(0); | ||||||
|  | const pendapatanElements = ref([]); | ||||||
|  | 
 | ||||||
|  | const salesDipilih = ref(null); | ||||||
|  | const opsiSales = ref([ | ||||||
|  |   { label: 'Semua Sales', value: null, selected: true }, | ||||||
|  | ]); | ||||||
|  | 
 | ||||||
|  | const produkDipilih = ref(null); | ||||||
|  | const opsiProduk = ref([ | ||||||
|  |   { label: 'Semua Produk', value: null, selected: true }, | ||||||
|  | ]); | ||||||
|  | 
 | ||||||
|  | const namaPembeli = ref(null); | ||||||
|  | 
 | ||||||
|  | // --- Computed --- | ||||||
|  | const nampan = computed(() => data.value?.nampan || []); | ||||||
|  | 
 | ||||||
|  | const sortedNampan = computed(() => { | ||||||
|  |   if (!sortBy.value || !nampan.value.length) { | ||||||
|  |     return nampan.value; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const sorted = [...nampan.value].sort((a, b) => { | ||||||
|  |     let aValue = a[sortBy.value]; | ||||||
|  |     let bValue = b[sortBy.value]; | ||||||
|  | 
 | ||||||
|  |     // Handle different data types | ||||||
|  |     if (sortBy.value === 'nama_nampan') { | ||||||
|  |       // String comparison | ||||||
|  |       aValue = aValue?.toString().toLowerCase() || ''; | ||||||
|  |       bValue = bValue?.toString().toLowerCase() || ''; | ||||||
|  |     } else if (sortBy.value === 'jumlah_item_terjual') { | ||||||
|  |       // Numeric comparison | ||||||
|  |       aValue = parseInt(aValue) || 0; | ||||||
|  |       bValue = parseInt(bValue) || 0; | ||||||
|  |     } else if (sortBy.value === 'berat_terjual') { | ||||||
|  |       // Handle weight values (remove unit if exists) | ||||||
|  |       aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0; | ||||||
|  |       bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0; | ||||||
|  |     } else if (sortBy.value === 'pendapatan') { | ||||||
|  |       // Handle currency values (remove currency symbols and commas) | ||||||
|  |       if (aValue === '-') aValue = 0; | ||||||
|  |       if (bValue === '-') bValue = 0; | ||||||
|  |       aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0; | ||||||
|  |       bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (sortOrder.value === 'asc') { | ||||||
|  |       if (typeof aValue === 'string') { | ||||||
|  |         return aValue.localeCompare(bValue); | ||||||
|  |       } | ||||||
|  |       return aValue - bValue; | ||||||
|  |     } else { | ||||||
|  |       if (typeof aValue === 'string') { | ||||||
|  |         return bValue.localeCompare(aValue); | ||||||
|  |       } | ||||||
|  |       return bValue - aValue; | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   return sorted; | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const selectedExportLabel = computed(() => { | ||||||
|  |   return exportOptions.value.find(opt => opt.value === exportFormat.value)?.label || 'Export Laporan'; | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const pendapatanStyle = computed(() => ({ | ||||||
|  |   minWidth: `${pendapatanWidth.value}px`, | ||||||
|  |   padding: '0.5rem 0.75rem' | ||||||
|  | })); | ||||||
|  | 
 | ||||||
|  | // --- Watchers --- | ||||||
|  | watch(nampan, async (newValue) => { | ||||||
|  |   if (newValue && newValue.length > 0) { | ||||||
|  |     await nextTick(); | ||||||
|  |     pendapatanElements.value = []; | ||||||
|  |     let maxWidth = 0; | ||||||
|  |      | ||||||
|  |     await nextTick(); | ||||||
|  |     pendapatanElements.value.forEach(el => { | ||||||
|  |       if (el && el.scrollWidth > maxWidth) { | ||||||
|  |         maxWidth = el.scrollWidth; | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |     pendapatanWidth.value = maxWidth; | ||||||
|  |   } | ||||||
|  | }, { deep: true }); | ||||||
|  | 
 | ||||||
|  | // --- Methods --- | ||||||
|  | const handleSort = (column) => { | ||||||
|  |   if (sortBy.value === column) { | ||||||
|  |     // If same column, toggle sort order | ||||||
|  |     sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'; | ||||||
|  |   } else { | ||||||
|  |     // If different column, set new column and default to ascending | ||||||
|  |     sortBy.value = column; | ||||||
|  |     sortOrder.value = 'asc'; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const getSortIcon = (column) => { | ||||||
|  |   if (sortBy.value !== column) { | ||||||
|  |     return 'fas fa-sort text-D/40'; // Default sort icon | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   if (sortOrder.value === 'asc') { | ||||||
|  |     return 'fas fa-sort-up text-D'; // Ascending | ||||||
|  |   } else { | ||||||
|  |     return 'fas fa-sort-down text-D'; // Descending | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const fetchSales = async () => { | ||||||
|  |   try { | ||||||
|  |     const response = await axios.get('/api/sales', { | ||||||
|  |       headers: { | ||||||
|  |         Authorization: `Bearer ${localStorage.getItem("token")}`, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |     const salesData = response.data; | ||||||
|  |     opsiSales.value = [ | ||||||
|  |       { label: 'Semua Sales', value: null },  | ||||||
|  |       ...salesData.map(sales => ({ | ||||||
|  |         label: sales.nama, | ||||||
|  |         value: sales.id, | ||||||
|  |       })) | ||||||
|  |     ]; | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Gagal mengambil data sales:', error); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const fetchProduk = async () => { | ||||||
|  |   try { | ||||||
|  |     const response = await axios.get('/api/produk', { | ||||||
|  |       headers: { | ||||||
|  |         Authorization: `Bearer ${localStorage.getItem("token")}`, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |     const produkData = response.data; | ||||||
|  |     opsiProduk.value = [ | ||||||
|  |       { label: 'Semua Produk', value: null },  | ||||||
|  |       ...produkData.map(produk => ({ | ||||||
|  |         label: produk.nama, | ||||||
|  |         value: produk.id, | ||||||
|  |       })) | ||||||
|  |     ]; | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Gagal mengambil data produk:', error); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const fetchData = async (page = 1) => { | ||||||
|  |   if (!tanggalDipilih.value) return; | ||||||
|  | 
 | ||||||
|  |   loading.value = true; | ||||||
|  |   pendapatanElements.value = []; | ||||||
|  | 
 | ||||||
|  |   let queryParams = `tanggal=${tanggalDipilih.value}&page=${page}`; | ||||||
|  |   if (salesDipilih.value) queryParams += `&sales_id=${salesDipilih.value}`; | ||||||
|  |   if (produkDipilih.value) queryParams += `&produk_id=${produkDipilih.value}`; | ||||||
|  |   if (namaPembeli.value) queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`; | ||||||
|  | 
 | ||||||
|  |   try { | ||||||
|  |     const response = await axios.get(`/api/detail-per-nampan?${queryParams}`, { | ||||||
|  |       headers: { | ||||||
|  |         Authorization: `Bearer ${localStorage.getItem("token")}`, | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     data.value = response.data; | ||||||
|  |      | ||||||
|  |     // Handle pagination data if provided by backend | ||||||
|  |     if (response.data.pagination) { | ||||||
|  |       pagination.value = { | ||||||
|  |         current_page: response.data.pagination.current_page, | ||||||
|  |         last_page: response.data.pagination.last_page, | ||||||
|  |         total: response.data.pagination.total, | ||||||
|  |       }; | ||||||
|  |     } else { | ||||||
|  |       // Reset pagination if no pagination data | ||||||
|  |       pagination.value = { | ||||||
|  |         current_page: 1, | ||||||
|  |         last_page: 1, | ||||||
|  |         total: response.data.nampan ? response.data.nampan.length : 0, | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Gagal mengambil data laporan nampan:', error); | ||||||
|  |     data.value = null; | ||||||
|  |     pagination.value = { | ||||||
|  |       current_page: 1, | ||||||
|  |       last_page: 1, | ||||||
|  |       total: 0, | ||||||
|  |     }; | ||||||
|  |   } finally { | ||||||
|  |     loading.value = false; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const goToPage = (page) => { | ||||||
|  |   if (page >= 1 && page <= pagination.value.last_page) { | ||||||
|  |     pagination.value.current_page = page; | ||||||
|  |     fetchData(page); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const selectExport = (option) => { | ||||||
|  |   exportFormat.value = option.value; | ||||||
|  |   isExportOpen.value = false; | ||||||
|  |   alert(`Fitur Belum dikerjakan. Laporan akan diekspor dalam format ${option.label}`); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const closeDropdownsOnClickOutside = (event) => { | ||||||
|  |   if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) { | ||||||
|  |     isExportOpen.value = false; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | // --- Lifecycle Hooks --- | ||||||
|  | onMounted(() => { | ||||||
|  |   const today = new Date().toISOString().split('T')[0]; | ||||||
|  |   tanggalDipilih.value = today; | ||||||
|  | 
 | ||||||
|  |   fetchSales(); | ||||||
|  |   fetchProduk(); | ||||||
|  |    | ||||||
|  |   document.addEventListener('click', closeDropdownsOnClickOutside); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | 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 }); | ||||||
|  | </script> | ||||||
							
								
								
									
										420
									
								
								resources/js/components/DetailPerProduk.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										420
									
								
								resources/js/components/DetailPerProduk.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,420 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="my-6"> | ||||||
|  |     <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="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" /> | ||||||
|  |       </div> | ||||||
|  |       <div class="mb-3 w-full"> | ||||||
|  |         <label class="text-D/80" for="pilihSales">Filter Sales:</label> | ||||||
|  |         <InputSelect :options="opsiSales" v-model="salesDipilih" id="pilihSales" /> | ||||||
|  |       </div> | ||||||
|  |       <div class="mb-3 w-full"> | ||||||
|  |         <label class="text-D/80" for="pilihPelanggan">Nama Pembeli:</label> | ||||||
|  |         <InputField placeholder="Nama pelanggan" v-model="namaPembeli" id="pilihPelanggan" /> | ||||||
|  |       </div> | ||||||
|  |       <div class="mb-3 w-full"> | ||||||
|  |         <label class="text-D/80" for="pilihNampan">Filter Nampan:</label> | ||||||
|  |         <InputSelect :options="opsiNampan" v-model="nampanDipilih" id="pilihNampan" /> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <!-- 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> | ||||||
|  | 
 | ||||||
|  |       <!-- Export Dropdown --> | ||||||
|  |       <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 right-0"> | ||||||
|  |           <ul class="py-1"> | ||||||
|  |             <li v-for="option in exportOptions" :key="option.value" @click="selectExport(option)" | ||||||
|  |               class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B"> | ||||||
|  |               {{ option.label }} | ||||||
|  |             </li> | ||||||
|  |           </ul> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <!-- Table Section --> | ||||||
|  |     <div class="mt-5 overflow-x-auto"> | ||||||
|  |       <table class="w-full border-collapse border border-C rounded-md"> | ||||||
|  |         <thead> | ||||||
|  |           <tr class="bg-C text-D rounded-t-md"> | ||||||
|  |             <th class="border-x border-C px-3 py-3"> | ||||||
|  |               <button @click="handleSort('nama_produk')"  | ||||||
|  |                       class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors"> | ||||||
|  |                 <span>Nama Produk</span> | ||||||
|  |                 <i :class="getSortIcon('nama_produk')" class="ml-2"></i> | ||||||
|  |               </button> | ||||||
|  |             </th> | ||||||
|  |             <th class="border-x border-C px-3 py-3"> | ||||||
|  |               <button @click="handleSort('jumlah_item_terjual')"  | ||||||
|  |                       class="flex items-center justify-center w-full hover:text-D/80 transition-colors"> | ||||||
|  |                 <span>Item Terjual</span> | ||||||
|  |                 <i :class="getSortIcon('jumlah_item_terjual')" class="ml-2"></i> | ||||||
|  |               </button> | ||||||
|  |             </th> | ||||||
|  |             <th class="border-x border-C px-3 py-3"> | ||||||
|  |               <button @click="handleSort('berat_terjual')"  | ||||||
|  |                       class="flex items-center justify-center w-full hover:text-D/80 transition-colors"> | ||||||
|  |                 <span>Total Berat</span> | ||||||
|  |                 <i :class="getSortIcon('berat_terjual')" class="ml-2"></i> | ||||||
|  |               </button> | ||||||
|  |             </th> | ||||||
|  |             <th class="border-x border-C px-3 py-3"> | ||||||
|  |               <button @click="handleSort('pendapatan')"  | ||||||
|  |                       class="flex items-center justify-center w-full hover:text-D/80 transition-colors"> | ||||||
|  |                 <span>Total Pendapatan</span> | ||||||
|  |                 <i :class="getSortIcon('pendapatan')" class="ml-2"></i> | ||||||
|  |               </button> | ||||||
|  |             </th> | ||||||
|  |           </tr> | ||||||
|  |         </thead> | ||||||
|  |         <tbody> | ||||||
|  |           <tr v-if="loading"> | ||||||
|  |             <td colspan="4" class="p-4"> | ||||||
|  |               <div class="flex items-center justify-center w-full h-30"> | ||||||
|  |                 <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div> | ||||||
|  |                 <span class="ml-2 text-gray-600">Memuat data...</span> | ||||||
|  |               </div> | ||||||
|  |             </td> | ||||||
|  |           </tr> | ||||||
|  |           <tr v-else-if="!sortedProduk.length"> | ||||||
|  |             <td colspan="4" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td> | ||||||
|  |           </tr> | ||||||
|  |           <template v-else v-for="item in sortedProduk" :key="item.nama_produk"> | ||||||
|  |             <tr class="text-center border-y border-C hover:bg-A"> | ||||||
|  |               <td class="border-x border-C px-3 py-2 text-left">{{ item.nama_produk }}</td> | ||||||
|  |               <td class="border-x border-C px-3 py-2">{{ item.jumlah_item_terjual }}</td> | ||||||
|  |               <td class="border-x border-C px-3 py-2">{{ item.berat_terjual }}</td> | ||||||
|  |               <td class="border-x border-C px-3 py-2"> | ||||||
|  |                 <div class="flex justify-center"> | ||||||
|  |                   <div :ref="el => { if (el) pendapatanElements.push(el) }"  | ||||||
|  |                        :style="pendapatanStyle"  | ||||||
|  |                        :class="item.pendapatan == '-' ? 'text-center' : 'text-right'"> | ||||||
|  |                     {{ item.pendapatan }} | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </td> | ||||||
|  |             </tr> | ||||||
|  |           </template> | ||||||
|  |         </tbody> | ||||||
|  |       </table> | ||||||
|  | 
 | ||||||
|  |       <!-- 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"> | ||||||
|  |           Sebelumnya | ||||||
|  |         </button> | ||||||
|  |         <span class="text-sm text-D"> | ||||||
|  |           Halaman {{ pagination.current_page }} dari {{ pagination.last_page }} | ||||||
|  |         </span> | ||||||
|  |         <button @click="goToPage(pagination.current_page + 1)" | ||||||
|  |                 :disabled="(pagination.current_page === pagination.last_page) || loading" | ||||||
|  |                 class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80"> | ||||||
|  |           Berikutnya | ||||||
|  |         </button> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script setup> | ||||||
|  | import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'; | ||||||
|  | import InputSelect from './InputSelect.vue'; | ||||||
|  | import InputField from './InputField.vue'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | 
 | ||||||
|  | // --- State --- | ||||||
|  | const isExportOpen = ref(false); | ||||||
|  | const exportDropdownRef = ref(null); | ||||||
|  | 
 | ||||||
|  | const exportOptions = ref([ | ||||||
|  |   { value: 'pdf', label: 'Pdf' }, | ||||||
|  |   { value: 'xls', label: 'Excel' }, | ||||||
|  |   { value: 'csv', label: 'Csv' } | ||||||
|  | ]); | ||||||
|  | 
 | ||||||
|  | const exportFormat = ref(null); | ||||||
|  | const tanggalDipilih = ref(''); | ||||||
|  | const data = ref(null); | ||||||
|  | const loading = ref(false); | ||||||
|  | 
 | ||||||
|  | // Sorting state | ||||||
|  | const sortBy = ref(null); | ||||||
|  | const sortOrder = ref('asc'); // 'asc' or 'desc' | ||||||
|  | 
 | ||||||
|  | const pagination = ref({ | ||||||
|  |   current_page: 1, | ||||||
|  |   last_page: 1, | ||||||
|  |   total: 0, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const pendapatanWidth = ref(0); | ||||||
|  | const pendapatanElements = ref([]); | ||||||
|  | 
 | ||||||
|  | const salesDipilih = ref(null); | ||||||
|  | const opsiSales = ref([ | ||||||
|  |   { label: 'Semua Sales', value: null, selected: true }, | ||||||
|  | ]); | ||||||
|  | 
 | ||||||
|  | const nampanDipilih = ref(null); | ||||||
|  | const opsiNampan = ref([ | ||||||
|  |   { label: 'Semua Nampan', value: null, selected: true }, | ||||||
|  | ]); | ||||||
|  | 
 | ||||||
|  | const namaPembeli = ref(null); | ||||||
|  | 
 | ||||||
|  | // --- Computed --- | ||||||
|  | const produk = computed(() => data.value?.produk || []); | ||||||
|  | 
 | ||||||
|  | const sortedProduk = computed(() => { | ||||||
|  |   if (!sortBy.value || !produk.value.length) { | ||||||
|  |     return produk.value; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const sorted = [...produk.value].sort((a, b) => { | ||||||
|  |     let aValue = a[sortBy.value]; | ||||||
|  |     let bValue = b[sortBy.value]; | ||||||
|  | 
 | ||||||
|  |     // Handle different data types | ||||||
|  |     if (sortBy.value === 'nama_produk') { | ||||||
|  |       // String comparison | ||||||
|  |       aValue = aValue?.toString().toLowerCase() || ''; | ||||||
|  |       bValue = bValue?.toString().toLowerCase() || ''; | ||||||
|  |     } else if (sortBy.value === 'jumlah_item_terjual') { | ||||||
|  |       // Numeric comparison | ||||||
|  |       aValue = parseInt(aValue) || 0; | ||||||
|  |       bValue = parseInt(bValue) || 0; | ||||||
|  |     } else if (sortBy.value === 'berat_terjual') { | ||||||
|  |       // Handle weight values (remove unit if exists) | ||||||
|  |       aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0; | ||||||
|  |       bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0; | ||||||
|  |     } else if (sortBy.value === 'pendapatan') { | ||||||
|  |       // Handle currency values (remove currency symbols and commas) | ||||||
|  |       if (aValue === '-') aValue = 0; | ||||||
|  |       if (bValue === '-') bValue = 0; | ||||||
|  |       aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0; | ||||||
|  |       bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (sortOrder.value === 'asc') { | ||||||
|  |       if (typeof aValue === 'string') { | ||||||
|  |         return aValue.localeCompare(bValue); | ||||||
|  |       } | ||||||
|  |       return aValue - bValue; | ||||||
|  |     } else { | ||||||
|  |       if (typeof aValue === 'string') { | ||||||
|  |         return bValue.localeCompare(aValue); | ||||||
|  |       } | ||||||
|  |       return bValue - aValue; | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   return sorted; | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const selectedExportLabel = computed(() => { | ||||||
|  |   return exportOptions.value.find(opt => opt.value === exportFormat.value)?.label || 'Export Laporan'; | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const pendapatanStyle = computed(() => ({ | ||||||
|  |   minWidth: `${pendapatanWidth.value}px`, | ||||||
|  |   padding: '0.5rem 0.75rem' | ||||||
|  | })); | ||||||
|  | 
 | ||||||
|  | // --- Watchers --- | ||||||
|  | watch(produk, async (newValue) => { | ||||||
|  |   if (newValue && newValue.length > 0) { | ||||||
|  |     await nextTick(); | ||||||
|  |     pendapatanElements.value = []; | ||||||
|  |     let maxWidth = 0; | ||||||
|  |      | ||||||
|  |     await nextTick(); | ||||||
|  |     pendapatanElements.value.forEach(el => { | ||||||
|  |       if (el && el.scrollWidth > maxWidth) { | ||||||
|  |         maxWidth = el.scrollWidth; | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |     pendapatanWidth.value = maxWidth; | ||||||
|  |   } | ||||||
|  | }, { deep: true }); | ||||||
|  | 
 | ||||||
|  | // --- Methods --- | ||||||
|  | const handleSort = (column) => { | ||||||
|  |   if (sortBy.value === column) { | ||||||
|  |     // If same column, toggle sort order | ||||||
|  |     sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'; | ||||||
|  |   } else { | ||||||
|  |     // If different column, set new column and default to ascending | ||||||
|  |     sortBy.value = column; | ||||||
|  |     sortOrder.value = 'asc'; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const getSortIcon = (column) => { | ||||||
|  |   if (sortBy.value !== column) { | ||||||
|  |     return 'fas fa-sort text-D/40'; // Default sort icon | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   if (sortOrder.value === 'asc') { | ||||||
|  |     return 'fas fa-sort-up text-D'; // Ascending | ||||||
|  |   } else { | ||||||
|  |     return 'fas fa-sort-down text-D'; // Descending | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const fetchSales = async () => { | ||||||
|  |   try { | ||||||
|  |     const response = await axios.get('/api/sales', { | ||||||
|  |       headers: { | ||||||
|  |         Authorization: `Bearer ${localStorage.getItem("token")}`, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |     const salesData = response.data; | ||||||
|  |     opsiSales.value = [ | ||||||
|  |       { label: 'Semua Sales', value: null },  | ||||||
|  |       ...salesData.map(sales => ({ | ||||||
|  |         label: sales.nama, | ||||||
|  |         value: sales.id, | ||||||
|  |       })) | ||||||
|  |     ]; | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Gagal mengambil data sales:', error); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const fetchNampan = async () => { | ||||||
|  |   try { | ||||||
|  |     const response = await axios.get('/api/nampan', { | ||||||
|  |       headers: { | ||||||
|  |         Authorization: `Bearer ${localStorage.getItem("token")}`, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |     const nampanData = response.data; | ||||||
|  |     opsiNampan.value = [ | ||||||
|  |       { label: 'Semua Nampan', value: null },  | ||||||
|  |       ...nampanData.map(nampan => ({ | ||||||
|  |         label: nampan.nama, | ||||||
|  |         value: nampan.id, | ||||||
|  |       })) | ||||||
|  |     ]; | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Gagal mengambil data nampan:', error); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const fetchData = async (page = 1) => { | ||||||
|  |   if (!tanggalDipilih.value) return; | ||||||
|  | 
 | ||||||
|  |   loading.value = true; | ||||||
|  |   pendapatanElements.value = []; | ||||||
|  | 
 | ||||||
|  |   let queryParams = `tanggal=${tanggalDipilih.value}&page=${page}`; | ||||||
|  |   if (salesDipilih.value) queryParams += `&sales_id=${salesDipilih.value}`; | ||||||
|  |   if (nampanDipilih.value) queryParams += `&nampan_id=${nampanDipilih.value}`; | ||||||
|  |   if (namaPembeli.value) queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`; | ||||||
|  | 
 | ||||||
|  |   try { | ||||||
|  |     const response = await axios.get(`/api/detail-per-produk?${queryParams}`, { | ||||||
|  |       headers: { | ||||||
|  |         Authorization: `Bearer ${localStorage.getItem("token")}`, | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     data.value = response.data; | ||||||
|  |      | ||||||
|  |     // Handle pagination data if provided by backend | ||||||
|  |     if (response.data.pagination) { | ||||||
|  |       pagination.value = { | ||||||
|  |         current_page: response.data.pagination.current_page, | ||||||
|  |         last_page: response.data.pagination.last_page, | ||||||
|  |         total: response.data.pagination.total, | ||||||
|  |       }; | ||||||
|  |     } else { | ||||||
|  |       // Reset pagination if no pagination data | ||||||
|  |       pagination.value = { | ||||||
|  |         current_page: 1, | ||||||
|  |         last_page: 1, | ||||||
|  |         total: response.data.produk ? response.data.produk.length : 0, | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Gagal mengambil data laporan:', error); | ||||||
|  |     data.value = null; | ||||||
|  |     pagination.value = { | ||||||
|  |       current_page: 1, | ||||||
|  |       last_page: 1, | ||||||
|  |       total: 0, | ||||||
|  |     }; | ||||||
|  |   } finally { | ||||||
|  |     loading.value = false; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const goToPage = (page) => { | ||||||
|  |   if (page >= 1 && page <= pagination.value.last_page) { | ||||||
|  |     pagination.value.current_page = page; | ||||||
|  |     fetchData(page); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const selectExport = (option) => { | ||||||
|  |   exportFormat.value = option.value; | ||||||
|  |   isExportOpen.value = false; | ||||||
|  |   alert(`Fitur Belum dikerjakan. Laporan akan diekspor dalam format ${option.label}`); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const closeDropdownsOnClickOutside = (event) => { | ||||||
|  |   if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) { | ||||||
|  |     isExportOpen.value = false; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | // --- Lifecycle Hooks --- | ||||||
|  | onMounted(() => { | ||||||
|  |   const today = new Date().toISOString().split('T')[0]; | ||||||
|  |   tanggalDipilih.value = today; | ||||||
|  | 
 | ||||||
|  |   fetchSales(); | ||||||
|  |   fetchNampan(); | ||||||
|  |    | ||||||
|  |   document.addEventListener('click', closeDropdownsOnClickOutside); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | onUnmounted(() => { | ||||||
|  |   document.removeEventListener('click', closeDropdownsOnClickOutside); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | // Watch for filter changes | ||||||
|  | watch([tanggalDipilih, salesDipilih, nampanDipilih, namaPembeli], () => { | ||||||
|  |   pagination.value.current_page = 1; // Reset to first page when filters change | ||||||
|  |   fetchData(1); | ||||||
|  | }, { immediate: true }); | ||||||
|  | </script> | ||||||
| @ -164,8 +164,16 @@ const saveMove = async () => { | |||||||
| const refreshData = async () => { | const refreshData = async () => { | ||||||
|   try { |   try { | ||||||
|     const [nampanRes, itemRes] = await Promise.all([ |     const [nampanRes, itemRes] = await Promise.all([ | ||||||
|       axios.get("/api/nampan"), |       axios.get("/api/nampan", { | ||||||
|       axios.get("/api/item"), |         headers: { | ||||||
|  |           Authorization: `Bearer ${localStorage.getItem("token")}`, | ||||||
|  |         }, | ||||||
|  |       }), | ||||||
|  |       axios.get("/api/item", { | ||||||
|  |         headers: { | ||||||
|  |           Authorization: `Bearer ${localStorage.getItem("token")}`, | ||||||
|  |         }, | ||||||
|  |       }), | ||||||
|     ]); |     ]); | ||||||
|     const nampans = nampanRes.data; |     const nampans = nampanRes.data; | ||||||
|     const items = itemRes.data; |     const items = itemRes.data; | ||||||
|  | |||||||
| @ -1,12 +1,11 @@ | |||||||
| <template> | <template> | ||||||
|   <mainLayout> |   <mainLayout> | ||||||
|     <div class="home"> |     <div class="home p-6"> | ||||||
|       <h1 class="text-3xl font-bold text-D mb-4">Contoh penggunaan halaman</h1> |       <h1 class="text-3xl font-bold text-D mb-4">Contoh penggunaan halaman</h1> | ||||||
|        | 
 | ||||||
|       <div class="message-model"> |       <!-- Komponen Struk --> | ||||||
|         <p>{{ message }}</p> |       <StrukOverlay :isOpen="true" /> | ||||||
|       </div> | 
 | ||||||
|        |  | ||||||
|       <hr class="my-6 border-D" /> |       <hr class="my-6 border-D" /> | ||||||
|       <h1 class="text-xl font-bold text-D mb-4">Contoh grid</h1> |       <h1 class="text-xl font-bold text-D mb-4">Contoh grid</h1> | ||||||
|       <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> |       <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> | ||||||
| @ -21,25 +20,8 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref } from 'vue' | import { ref } from 'vue' | ||||||
| import mainLayout from '../layouts/mainLayout.vue' | import mainLayout from '../layouts/mainLayout.vue' | ||||||
|  | import StrukOverlay from '../components/StrukOverlay.vue' // pastikan path sesuai | ||||||
| 
 | 
 | ||||||
| const message = ref("Style dan message dari script dan style di dalam halaman") | const message = ref("Style dan message dari script dan style di dalam halaman") | ||||||
| 
 |  | ||||||
| const data = ref([1, 2, 3, 4, 5]) | const data = ref([1, 2, 3, 4, 5]) | ||||||
| </script> | </script> | ||||||
| 
 |  | ||||||
| <style scoped> |  | ||||||
| .message-model { |  | ||||||
|   border: 1px solid yellow; |  | ||||||
|   text-align: center; |  | ||||||
|   border-radius: 10px; |  | ||||||
|   width: 80%; |  | ||||||
|   margin: auto; |  | ||||||
|   background-color: yellow; |  | ||||||
|   padding: 20px; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .message-model p { |  | ||||||
|   font-weight: bold; |  | ||||||
|   font-size: 24px; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @ -1,18 +1,54 @@ | |||||||
| <template> | <template> | ||||||
|   <mainLayout> |   <mainLayout> | ||||||
|     <div class="p-6"> |     <div class="p-6"> | ||||||
|       <p class="font-serif italic text-[25px] text-D">Laporan</p> |       <p class="font-serif italic text-[25px] text-D mb-4">Laporan</p> | ||||||
| 
 | 
 | ||||||
|       <RingkasanLaporanB /> |       <div class="mb-4"> | ||||||
|  |         <ul class="flex flex-wrap text-center" role="tablist"> | ||||||
|  |           <li v-for="tab in tabs" class="mr-2" role="presentation"> | ||||||
|  |             <button :class="[ | ||||||
|  |               'inline-block p-2 border-b-2 rounded-t-lg', | ||||||
|  |               activeTab === tab.id | ||||||
|  |                 ? 'border-D text-D' | ||||||
|  |                 : 'border-transparent text-D hover:text-D/50 hover:border-D', | ||||||
|  |             ]" @click="activeTab = tab.id" type="button" role="tab" aria-controls="ringkasan-content" | ||||||
|  |               :aria-selected="activeTab === tab.id"> | ||||||
|  |               {{ tab.name }} | ||||||
|  |             </button> | ||||||
|  |           </li> | ||||||
|  |         </ul> | ||||||
|  |       </div> | ||||||
| 
 | 
 | ||||||
|       <DetailLaporan /> |       <div> | ||||||
|  |         <div v-if="activeTab === 'ringkasan'" id="ringkasan-content" role="tabpanel"> | ||||||
|  |           <RingkasanLaporanB /> | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <div v-if="activeTab === 'detail-nampan'" id="detail-content" role="tabpanel"> | ||||||
|  |           <DetailPerNampan /> | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <div v-if="activeTab === 'detail-produk'" id="detail-content" role="tabpanel"> | ||||||
|  |           <DetailPerProduk /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </mainLayout> |   </mainLayout> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script setup> | <script setup> | ||||||
| import DetailLaporan from '../components/DetailLaporan.vue'; | import { ref } from 'vue'; | ||||||
| import RingkasanLaporanA from '../components/RingkasanLaporanA.vue'; | import RingkasanLaporanA from '../components/RingkasanLaporanA.vue'; | ||||||
| import RingkasanLaporanB from '../components/RingkasanLaporanB.vue'; | import RingkasanLaporanB from '../components/RingkasanLaporanB.vue'; | ||||||
| import mainLayout from "../layouts/mainLayout.vue"; | import mainLayout from '../layouts/mainLayout.vue'; | ||||||
|  | import DetailPerNampan from '../components/DetailPerNampan.vue'; | ||||||
|  | import DetailPerProduk from '../components/DetailPerProduk.vue'; | ||||||
|  | 
 | ||||||
|  | const activeTab = ref('ringkasan'); | ||||||
|  | 
 | ||||||
|  | const tabs = [ | ||||||
|  |   { name: 'Ringkasan Laporan', id: 'ringkasan' }, | ||||||
|  |   { name: 'Detail per Nampan', id: 'detail-nampan' }, | ||||||
|  |   { name: 'Detail per Produk', id: 'detail-produk' }, | ||||||
|  | ]; | ||||||
| </script> | </script> | ||||||
| @ -11,6 +11,7 @@ import EditProduk from "../pages/EditProduk.vue"; | |||||||
| import Laporan from "../pages/Laporan.vue"; | import Laporan from "../pages/Laporan.vue"; | ||||||
| import Login from "../pages/Login.vue"; | import Login from "../pages/Login.vue"; | ||||||
| import Akun from "../pages/Akun.vue"; | import Akun from "../pages/Akun.vue"; | ||||||
|  | import Home from "../pages/Home.vue"; | ||||||
| 
 | 
 | ||||||
| import auth from "../middlewares/auth"; | import auth from "../middlewares/auth"; | ||||||
| import guest from "../middlewares/guest"; | import guest from "../middlewares/guest"; | ||||||
| @ -26,6 +27,11 @@ const routes = [ | |||||||
|         component: Login, |         component: Login, | ||||||
|         meta: { middleware: "guest" }, |         meta: { middleware: "guest" }, | ||||||
|     }, |     }, | ||||||
|  |     { | ||||||
|  |         path: "/test", | ||||||
|  |         name: "Test", | ||||||
|  |         component: Home | ||||||
|  |     }, | ||||||
|     { |     { | ||||||
|         path: "/produk", |         path: "/produk", | ||||||
|         name: "Produk", |         name: "Produk", | ||||||
|  | |||||||
| @ -30,8 +30,6 @@ Route::prefix('api')->group(function () { | |||||||
|         Route::apiResource('kategori', KategoriController::class)->except(['index', 'show']); |         Route::apiResource('kategori', KategoriController::class)->except(['index', 'show']); | ||||||
|         Route::apiResource('user', UserController::class); |         Route::apiResource('user', UserController::class); | ||||||
| 
 | 
 | ||||||
|         // Custom Endpoint
 |  | ||||||
| 
 |  | ||||||
|         Route::delete('kosongkan-nampan', [NampanController::class, 'kosongkan']); |         Route::delete('kosongkan-nampan', [NampanController::class, 'kosongkan']); | ||||||
| 
 | 
 | ||||||
|         // Foto Sementara
 |         // Foto Sementara
 | ||||||
| @ -42,7 +40,8 @@ Route::prefix('api')->group(function () { | |||||||
| 
 | 
 | ||||||
|         // Laporan
 |         // Laporan
 | ||||||
|         Route::get('laporan', [LaporanController::class, 'ringkasan']); |         Route::get('laporan', [LaporanController::class, 'ringkasan']); | ||||||
|         Route::get('detail-laporan', [LaporanController::class, 'detail']); |         Route::get('detail-per-produk', [LaporanController::class, 'detailPerProduk']); | ||||||
|  |         Route::get('detail-per-nampan', [LaporanController::class, 'detailPerNampan']); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     Route::middleware(['auth:sanctum', 'role:owner,kasir'])->group(function () { |     Route::middleware(['auth:sanctum', 'role:owner,kasir'])->group(function () { | ||||||
| @ -64,7 +63,7 @@ Route::prefix('api')->group(function () { | |||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| // ============================
 | // ============================
 | ||||||
| // Frontend SPA (Vue / React dll.)
 | // Frontend SPA (Vue)
 | ||||||
| // ============================
 | // ============================
 | ||||||
| Route::get('/{any}', function () { | Route::get('/{any}', function () { | ||||||
|     return view('app'); |     return view('app'); | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user