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,9 +222,7 @@ 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
|
$salesDataTransaksi = $transaksisPerTanggal
|
||||||
->groupBy('nama_sales')
|
->groupBy('nama_sales')
|
||||||
->map(fn($transaksisPerSales) => $this->hitungDataSales($transaksisPerSales));
|
->map(fn($transaksisPerSales) => $this->hitungDataSales($transaksisPerSales));
|
||||||
@ -121,9 +237,9 @@ class LaporanController extends Controller
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'tanggal' => $tanggal,
|
'tanggal' => $tanggal,
|
||||||
'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(),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@ -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');
|
|
||||||
$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
|
private function formatWeight(float $weight): string
|
||||||
$totalPendapatan = $produkTerjualQuery->sum('pendapatan');
|
{
|
||||||
$totalItemTerjual = $produkTerjualQuery->sum('jumlah_item_terjual');
|
return number_format($weight, 2, ',', '.') . self::WEIGHT_UNIT;
|
||||||
$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,11 +1,10 @@
|
|||||||
<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>
|
||||||
@ -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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div v-if="activeTab === 'ringkasan'" id="ringkasan-content" role="tabpanel">
|
||||||
<RingkasanLaporanB />
|
<RingkasanLaporanB />
|
||||||
|
</div>
|
||||||
|
|
||||||
<DetailLaporan />
|
<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