From 7a4c70bee26057db12bcb1f07b609e59f19f0795 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Wed, 22 Jul 2026 19:23:05 +0700 Subject: [PATCH] update: pisah tabel stock activity, refactor controller & report, tambah integrasi WhatsApp gateway, perbaikan responsive layout untuk mobile --- .env.example | 5 + app/Http/Controllers/DashboardController.php | 13 + app/Http/Controllers/ProductController.php | 22 +- app/Http/Controllers/ReportController.php | 172 ++++---- .../Controllers/StockMovementController.php | 167 ++++++-- .../Concerns/HasIndonesianTimestamps.php | 53 +++ app/Models/IncomingStock.php | 8 + app/Models/LostStock.php | 8 + app/Models/OutgoingStock.php | 8 + app/Models/Product.php | 2 +- app/Models/StockActivity.php | 42 ++ app/Services/WhatsAppStockNotifier.php | 92 +++++ config/services.php | 10 + ...026_04_22_101015_create_products_table.php | 2 +- ..._create_separate_stock_activity_tables.php | 111 +++++ ...000002_align_product_keterangan_column.php | 50 +++ ...3_align_stock_activity_display_columns.php | 76 ++++ resources/views/auth/login.blade.php | 16 +- resources/views/dashboard.blade.php | 179 +++++++-- resources/views/layouts/sidebar.blade.php | 69 ++-- .../views/mutasi-stok/barang-hilang.blade.php | 29 +- .../views/mutasi-stok/barang-keluar.blade.php | 99 ++++- .../views/mutasi-stok/barang-masuk.blade.php | 162 +++++--- resources/views/products/index.blade.php | 270 +++++++++---- .../views/reports/barang-hilang.blade.php | 6 +- resources/views/reports/pembelian.blade.php | 10 +- resources/views/reports/penjualan.blade.php | 10 +- .../views/reports/rekap-pembelian.blade.php | 28 +- .../views/reports/rekap-penjualan.blade.php | 28 +- resources/views/reports/stock.blade.php | 378 ++++++++++-------- routes/web.php | 2 +- 31 files changed, 1601 insertions(+), 526 deletions(-) create mode 100644 app/Models/Concerns/HasIndonesianTimestamps.php create mode 100644 app/Models/IncomingStock.php create mode 100644 app/Models/LostStock.php create mode 100644 app/Models/OutgoingStock.php create mode 100644 app/Models/StockActivity.php create mode 100644 app/Services/WhatsAppStockNotifier.php create mode 100644 database/migrations/2026_06_10_000001_create_separate_stock_activity_tables.php create mode 100644 database/migrations/2026_06_10_000002_align_product_keterangan_column.php create mode 100644 database/migrations/2026_06_10_000003_align_stock_activity_display_columns.php diff --git a/.env.example b/.env.example index 855e9d6..83eaa3b 100644 --- a/.env.example +++ b/.env.example @@ -64,3 +64,8 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +WHATSAPP_GATEWAY_ENDPOINT=https://api.fonnte.com/send +WHATSAPP_GATEWAY_TOKEN= +WHATSAPP_TARGET_NUMBER=6281234567890 +WHATSAPP_TARGET_NUMBERS=6281234567890,6289876543210 diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 8d2fc37..0693be9 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -46,6 +46,17 @@ public function index(Request $request): View ->latest(StockMovement::columnDibuat()) ->limit(6) ->get(); + $pesanWhatsAppStokMinimum = "Peringatan Stok Minimum\n\n"; + if ($produkStokMenipis->isNotEmpty()) { + $pesanWhatsAppStokMinimum .= "Barang berikut perlu segera direstock:\n"; + foreach ($produkStokMenipis as $produk) { + $pesanWhatsAppStokMinimum .= "- {$produk->nama}: stok {$produk->stok}, minimum {$produk->stok_minimum}\n"; + } + $pesanWhatsAppStokMinimum .= "\nMohon segera dilakukan pengadaan barang."; + } else { + $pesanWhatsAppStokMinimum .= 'Semua stok barang masih aman.'; + } + $nomorPenerimaWhatsApp = collect(config('services.whatsapp.target_numbers', [])); $stokAman = max(0, $totalProduk - $stokMenipis); $persentaseStokAman = $totalProduk > 0 ? (int) round(($stokAman / $totalProduk) * 100) : 100; $topPergerakanProduk = StockMovement::with('product') @@ -164,6 +175,8 @@ public function index(Request $request): View 'perubahanBarangKeluar' => $perubahanBarangKeluar, 'produkStokMenipis' => $produkStokMenipis, 'aktivitasTerbaru' => $aktivitasTerbaru, + 'pesanWhatsAppStokMinimum' => $pesanWhatsAppStokMinimum, + 'nomorPenerimaWhatsApp' => $nomorPenerimaWhatsApp, 'persentaseStokAman' => $persentaseStokAman, 'topPergerakanProduk' => $topPergerakanProduk, 'produkTercepatTerjual' => $produkTercepatTerjual, diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index b43e9ca..f599050 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -11,7 +11,7 @@ class ProductController extends Controller { public function index(Request $request): View { - $this->ensureAdmin(); + $this->ensureAdminOrOwner(); $opsiUrut = $request->input('urut', 'nama_asc'); $query = Product::query(); @@ -38,8 +38,11 @@ public function store(Request $request): RedirectResponse 'nama' => ['required', 'string', 'max:255'], 'kategori' => ['nullable', 'string', 'max:100'], 'stok' => ['required', 'integer', 'min:0'], - 'stok_minimum' => ['required', 'integer', 'min:0'], - 'deskripsi' => ['nullable', 'string'], + 'stok_minimum' => ['required', 'integer', 'min:0', 'lte:stok'], + 'keterangan' => ['nullable', 'string'], + ], [ + 'kode.unique' => 'Kode barang sudah digunakan, gunakan kode lain.', + 'stok_minimum.lte' => 'Stok minimum tidak boleh lebih besar dari stok barang.', ]); Product::create($validated); @@ -56,8 +59,11 @@ public function update(Request $request, Product $product): RedirectResponse 'nama' => ['required', 'string', 'max:255'], 'kategori' => ['nullable', 'string', 'max:100'], 'stok' => ['required', 'integer', 'min:0'], - 'stok_minimum' => ['required', 'integer', 'min:0'], - 'deskripsi' => ['nullable', 'string'], + 'stok_minimum' => ['required', 'integer', 'min:0', 'lte:stok'], + 'keterangan' => ['nullable', 'string'], + ], [ + 'kode.unique' => 'Kode barang sudah digunakan, gunakan kode lain.', + 'stok_minimum.lte' => 'Stok minimum tidak boleh lebih besar dari stok barang.', ]); $product->update($validated); @@ -78,4 +84,10 @@ private function ensureAdmin(): void { abort_unless(auth()->user()?->role === 'admin', 403); } + + private function ensureAdminOrOwner(): void + { + abort_unless(in_array(auth()->user()?->role, ['admin', 'owner'], true), 403); + } + } diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php index 77fb2fd..50740e2 100644 --- a/app/Http/Controllers/ReportController.php +++ b/app/Http/Controllers/ReportController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\LostStock; use App\Models\StockMovement; use Carbon\Carbon; use Dompdf\Dompdf; @@ -21,12 +22,7 @@ public function pembelian(Request $request): View $this->ensureReportAccess(); [$tanggalMulai, $tanggalSelesai] = $this->resolveTanggal($request); - $data = StockMovement::with(['product', 'user']) - ->kategori(StockMovement::KATEGORI_MASUK) - ->when($tanggalMulai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '>=', $this->mulaiHariDiJakarta($tanggalMulai))) - ->when($tanggalSelesai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '<=', $this->akhirHariDiJakarta($tanggalSelesai))) - ->latest(StockMovement::columnDibuat()) - ->get(); + $data = $this->detailMutasi(StockMovement::KATEGORI_MASUK, $tanggalMulai, $tanggalSelesai); return view('reports.pembelian', [ 'tanggalMulai' => $tanggalMulai, @@ -41,12 +37,7 @@ public function penjualan(Request $request): View $this->ensureReportAccess(); [$tanggalMulai, $tanggalSelesai] = $this->resolveTanggal($request); - $data = StockMovement::with(['product', 'user']) - ->kategori(StockMovement::KATEGORI_KELUAR) - ->when($tanggalMulai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '>=', $this->mulaiHariDiJakarta($tanggalMulai))) - ->when($tanggalSelesai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '<=', $this->akhirHariDiJakarta($tanggalSelesai))) - ->latest(StockMovement::columnDibuat()) - ->get(); + $data = $this->detailMutasi(StockMovement::KATEGORI_KELUAR, $tanggalMulai, $tanggalSelesai); return view('reports.penjualan', [ 'tanggalMulai' => $tanggalMulai, @@ -61,14 +52,7 @@ public function rekapPembelian(Request $request): View $this->ensureReportAccess(); [$tanggalMulai, $tanggalSelesai] = $this->resolveTanggal($request); - $data = StockMovement::with('product') - ->selectRaw('id_barang, SUM(jumlah) as total_jumlah, COUNT(*) as total_transaksi') - ->kategori(StockMovement::KATEGORI_MASUK) - ->when($tanggalMulai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '>=', $this->mulaiHariDiJakarta($tanggalMulai))) - ->when($tanggalSelesai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '<=', $this->akhirHariDiJakarta($tanggalSelesai))) - ->groupBy('id_barang') - ->orderByDesc('total_jumlah') - ->get(); + $data = $this->rekapMutasi(StockMovement::KATEGORI_MASUK, $tanggalMulai, $tanggalSelesai); return view('reports.rekap-pembelian', [ 'tanggalMulai' => $tanggalMulai, @@ -83,14 +67,7 @@ public function rekapPenjualan(Request $request): View $this->ensureReportAccess(); [$tanggalMulai, $tanggalSelesai] = $this->resolveTanggal($request); - $data = StockMovement::with('product') - ->selectRaw('id_barang, SUM(jumlah) as total_jumlah, COUNT(*) as total_transaksi') - ->kategori(StockMovement::KATEGORI_KELUAR) - ->when($tanggalMulai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '>=', $this->mulaiHariDiJakarta($tanggalMulai))) - ->when($tanggalSelesai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '<=', $this->akhirHariDiJakarta($tanggalSelesai))) - ->groupBy('id_barang') - ->orderByDesc('total_jumlah') - ->get(); + $data = $this->rekapMutasi(StockMovement::KATEGORI_KELUAR, $tanggalMulai, $tanggalSelesai); return view('reports.rekap-penjualan', [ 'tanggalMulai' => $tanggalMulai, @@ -105,12 +82,7 @@ public function barangHilang(Request $request): View $this->ensureReportAccess(); [$tanggalMulai, $tanggalSelesai] = $this->resolveTanggal($request); - $data = StockMovement::with(['product', 'user']) - ->kategori(StockMovement::KATEGORI_HILANG) - ->when($tanggalMulai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '>=', $this->mulaiHariDiJakarta($tanggalMulai))) - ->when($tanggalSelesai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '<=', $this->akhirHariDiJakarta($tanggalSelesai))) - ->latest(StockMovement::columnDibuat()) - ->get(); + $data = $this->detailBarangHilang($tanggalMulai, $tanggalSelesai); return view('reports.barang-hilang', [ 'tanggalMulai' => $tanggalMulai, @@ -125,24 +97,19 @@ public function exportPembelian(Request $request) $this->ensureReportAccess(); [$tanggalMulai, $tanggalSelesai] = $this->resolveTanggal($request); - $data = StockMovement::with(['product', 'user']) - ->kategori(StockMovement::KATEGORI_MASUK) - ->when($tanggalMulai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '>=', $this->mulaiHariDiJakarta($tanggalMulai))) - ->when($tanggalSelesai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '<=', $this->akhirHariDiJakarta($tanggalSelesai))) - ->latest(StockMovement::columnDibuat()) - ->get(); + $data = $this->detailMutasi(StockMovement::KATEGORI_MASUK, $tanggalMulai, $tanggalSelesai); return $this->exportReport( $request, 'Laporan Pembelian', $data, - ['Tanggal', 'Produk', 'Jumlah', 'User', 'Keterangan'], + ['Waktu', 'Barang', 'Jumlah', 'Keterangan', 'Input Oleh'], fn ($item) => [ - $item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '-', + $item->created_at?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '-', $item->product->nama ?? '-', $item->jumlah, - $item->user->name ?? '-', $item->keterangan ?? '-', + $item->user->name ?? '-', ], 'laporan-pembelian-' . now()->format('YmdHis') ); @@ -153,24 +120,19 @@ public function exportPenjualan(Request $request) $this->ensureReportAccess(); [$tanggalMulai, $tanggalSelesai] = $this->resolveTanggal($request); - $data = StockMovement::with(['product', 'user']) - ->kategori(StockMovement::KATEGORI_KELUAR) - ->when($tanggalMulai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '>=', $this->mulaiHariDiJakarta($tanggalMulai))) - ->when($tanggalSelesai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '<=', $this->akhirHariDiJakarta($tanggalSelesai))) - ->latest(StockMovement::columnDibuat()) - ->get(); + $data = $this->detailMutasi(StockMovement::KATEGORI_KELUAR, $tanggalMulai, $tanggalSelesai); return $this->exportReport( $request, 'Laporan Penjualan', $data, - ['Tanggal', 'Produk', 'Jumlah', 'User', 'Keterangan'], + ['Waktu', 'Barang', 'Jumlah', 'Keterangan', 'Input Oleh'], fn ($item) => [ - $item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '-', + $item->created_at?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '-', $item->product->nama ?? '-', $item->jumlah, - $item->user->name ?? '-', $item->keterangan ?? '-', + $item->user->name ?? '-', ], 'laporan-penjualan-' . now()->format('YmdHis') ); @@ -181,24 +143,27 @@ public function exportRekapPembelian(Request $request) $this->ensureReportAccess(); [$tanggalMulai, $tanggalSelesai] = $this->resolveTanggal($request); - $data = StockMovement::with('product') - ->selectRaw('id_barang, SUM(jumlah) as total_jumlah, COUNT(*) as total_transaksi') - ->kategori(StockMovement::KATEGORI_MASUK) - ->when($tanggalMulai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '>=', $this->mulaiHariDiJakarta($tanggalMulai))) - ->when($tanggalSelesai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '<=', $this->akhirHariDiJakarta($tanggalSelesai))) - ->groupBy('id_barang') - ->orderByDesc('total_jumlah') - ->get(); + $data = $this->rekapMutasi(StockMovement::KATEGORI_MASUK, $tanggalMulai, $tanggalSelesai); return $this->exportReport( $request, 'Rekap Pembelian', $data, - ['Produk', 'Total Qty', 'Total Transaksi'], + ['Kode', 'Barang', 'Kategori', 'Stok Saat Ini', 'Total Qty', 'Aktivitas', 'Rata-rata', 'Qty Terkecil', 'Qty Terbesar', 'Aktivitas Pertama', 'Aktivitas Terakhir', 'Input Oleh', 'Keterangan'], fn ($item) => [ + $item->product->kode ?? '-', $item->product->nama ?? '-', + $item->product->kategori ?? '-', + (int) ($item->product->stok ?? 0), (int) $item->total_jumlah, (int) $item->total_transaksi, + number_format((float) $item->rata_rata, 2, ',', '.'), + (int) $item->jumlah_terkecil, + (int) $item->jumlah_terbesar, + $item->aktivitas_pertama?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '-', + $item->aktivitas_terakhir?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '-', + $item->input_oleh, + $item->keterangan, ], 'rekap-pembelian-' . now()->format('YmdHis') ); @@ -209,24 +174,27 @@ public function exportRekapPenjualan(Request $request) $this->ensureReportAccess(); [$tanggalMulai, $tanggalSelesai] = $this->resolveTanggal($request); - $data = StockMovement::with('product') - ->selectRaw('id_barang, SUM(jumlah) as total_jumlah, COUNT(*) as total_transaksi') - ->kategori(StockMovement::KATEGORI_KELUAR) - ->when($tanggalMulai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '>=', $this->mulaiHariDiJakarta($tanggalMulai))) - ->when($tanggalSelesai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '<=', $this->akhirHariDiJakarta($tanggalSelesai))) - ->groupBy('id_barang') - ->orderByDesc('total_jumlah') - ->get(); + $data = $this->rekapMutasi(StockMovement::KATEGORI_KELUAR, $tanggalMulai, $tanggalSelesai); return $this->exportReport( $request, 'Rekap Penjualan', $data, - ['Produk', 'Total Qty', 'Total Transaksi'], + ['Kode', 'Barang', 'Kategori', 'Stok Saat Ini', 'Total Qty', 'Aktivitas', 'Rata-rata', 'Qty Terkecil', 'Qty Terbesar', 'Aktivitas Pertama', 'Aktivitas Terakhir', 'Input Oleh', 'Keterangan'], fn ($item) => [ + $item->product->kode ?? '-', $item->product->nama ?? '-', + $item->product->kategori ?? '-', + (int) ($item->product->stok ?? 0), (int) $item->total_jumlah, (int) $item->total_transaksi, + number_format((float) $item->rata_rata, 2, ',', '.'), + (int) $item->jumlah_terkecil, + (int) $item->jumlah_terbesar, + $item->aktivitas_pertama?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '-', + $item->aktivitas_terakhir?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '-', + $item->input_oleh, + $item->keterangan, ], 'rekap-penjualan-' . now()->format('YmdHis') ); @@ -237,12 +205,7 @@ public function exportBarangHilang(Request $request) $this->ensureReportAccess(); [$tanggalMulai, $tanggalSelesai] = $this->resolveTanggal($request); - $data = StockMovement::with(['product', 'user']) - ->kategori(StockMovement::KATEGORI_HILANG) - ->when($tanggalMulai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '>=', $this->mulaiHariDiJakarta($tanggalMulai))) - ->when($tanggalSelesai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '<=', $this->akhirHariDiJakarta($tanggalSelesai))) - ->latest(StockMovement::columnDibuat()) - ->get(); + $data = $this->detailBarangHilang($tanggalMulai, $tanggalSelesai); return $this->exportReport( $request, @@ -251,9 +214,9 @@ public function exportBarangHilang(Request $request) ['Tanggal', 'Produk', 'Jumlah', 'User', 'Keterangan'], fn ($item) => [ $item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '-', - $item->product->nama ?? '-', + $item->barang ?? $item->product->nama ?? '-', $item->jumlah, - $item->user->name ?? '-', + $item->input_oleh ?? $item->user->name ?? '-', $item->keterangan ?? '-', ], 'barang-hilang-' . now()->format('YmdHis') @@ -274,6 +237,59 @@ private function exportReport(Request $request, string $title, iterable $data, a return $this->exportExcel($title, $filenameBase . '.xls', $columns, $rows); } + private function detailMutasi(string $kategori, ?string $tanggalMulai, ?string $tanggalSelesai) + { + return StockMovement::with(['product', 'user']) + ->kategori($kategori) + ->when($tanggalMulai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '>=', $this->mulaiHariDiJakarta($tanggalMulai))) + ->when($tanggalSelesai, fn ($q) => $q->where($this->kolomWaktuMutasi(), '<=', $this->akhirHariDiJakarta($tanggalSelesai))) + ->latest(StockMovement::columnDibuat()) + ->get(); + } + + private function detailBarangHilang(?string $tanggalMulai, ?string $tanggalSelesai) + { + return LostStock::with(['product', 'user']) + ->when($tanggalMulai, fn ($q) => $q->where('waktu', '>=', $this->mulaiHariDiJakarta($tanggalMulai))) + ->when($tanggalSelesai, fn ($q) => $q->where('waktu', '<=', $this->akhirHariDiJakarta($tanggalSelesai))) + ->latest(LostStock::CREATED_AT) + ->get(); + } + + private function rekapMutasi(string $kategori, ?string $tanggalMulai, ?string $tanggalSelesai) + { + return $this->detailMutasi($kategori, $tanggalMulai, $tanggalSelesai) + ->groupBy('id_barang') + ->map(function ($items) { + $sorted = $items->sortBy(fn ($item) => $item->created_at?->timestamp ?? 0); + $keterangan = $items->pluck('keterangan') + ->filter() + ->unique() + ->values() + ->implode('; '); + $inputOleh = $items->map(fn ($item) => $item->user?->name) + ->filter() + ->unique() + ->values() + ->implode(', '); + + return (object) [ + 'product' => $items->first()->product, + 'total_jumlah' => (int) $items->sum('jumlah'), + 'total_transaksi' => $items->count(), + 'rata_rata' => (float) $items->avg('jumlah'), + 'jumlah_terkecil' => (int) $items->min('jumlah'), + 'jumlah_terbesar' => (int) $items->max('jumlah'), + 'aktivitas_pertama' => $sorted->first()?->created_at, + 'aktivitas_terakhir' => $sorted->last()?->created_at, + 'input_oleh' => $inputOleh !== '' ? $inputOleh : '-', + 'keterangan' => $keterangan !== '' ? $keterangan : '-', + ]; + }) + ->sortByDesc('total_jumlah') + ->values(); + } + private function exportExcel(string $title, string $filename, array $columns, array $rows) { $spreadsheet = new Spreadsheet(); diff --git a/app/Http/Controllers/StockMovementController.php b/app/Http/Controllers/StockMovementController.php index 398e05a..b79f4c9 100644 --- a/app/Http/Controllers/StockMovementController.php +++ b/app/Http/Controllers/StockMovementController.php @@ -3,7 +3,12 @@ namespace App\Http\Controllers; use App\Models\Product; +use App\Models\IncomingStock; +use App\Models\LostStock; +use App\Models\OutgoingStock; use App\Models\StockMovement; +use App\Models\StockActivity; +use App\Services\WhatsAppStockNotifier; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -13,6 +18,10 @@ class StockMovementController extends Controller { + public function __construct(private readonly WhatsAppStockNotifier $whatsAppStockNotifier) + { + } + // public function incoming(): View // { // $this->ensureAdmin(); @@ -29,18 +38,17 @@ class StockMovementController extends Controller public function incoming(Request $request): View { - $this->ensureAdmin(); + $this->ensureAdminOrOwner(); - $query = StockMovement::with(['product', 'user']) - ->kategori(StockMovement::KATEGORI_MASUK) - ->latest(StockMovement::columnDibuat()); + $query = IncomingStock::with(['product', 'user']) + ->latest(IncomingStock::CREATED_AT); if ($request->filled('tanggal_mulai')) { - $query->whereDate(StockMovement::columnDibuat(), '>=', $request->tanggal_mulai); + $query->whereDate(IncomingStock::CREATED_AT, '>=', $request->tanggal_mulai); } if ($request->filled('tanggal_selesai')) { - $query->whereDate(StockMovement::columnDibuat(), '<=', $request->tanggal_selesai); + $query->whereDate(IncomingStock::CREATED_AT, '<=', $request->tanggal_selesai); } if (!$request->filled('tanggal_mulai') && !$request->filled('tanggal_selesai')) { @@ -69,18 +77,17 @@ public function incoming(Request $request): View public function outgoing(Request $request): View { - $this->ensureAdmin(); + $this->ensureAdminOrOwner(); - $query = StockMovement::with(['product', 'user']) - ->kategori(StockMovement::KATEGORI_KELUAR) - ->latest(StockMovement::columnDibuat()); + $query = OutgoingStock::with(['product', 'user']) + ->latest(OutgoingStock::CREATED_AT); if ($request->filled('tanggal_mulai')) { - $query->whereDate(StockMovement::columnDibuat(), '>=', $request->tanggal_mulai); + $query->whereDate(OutgoingStock::CREATED_AT, '>=', $request->tanggal_mulai); } if ($request->filled('tanggal_selesai')) { - $query->whereDate(StockMovement::columnDibuat(), '<=', $request->tanggal_selesai); + $query->whereDate(OutgoingStock::CREATED_AT, '<=', $request->tanggal_selesai); } if (!$request->filled('tanggal_mulai') && !$request->filled('tanggal_selesai')) { @@ -99,9 +106,8 @@ public function lost(): View return view('mutasi-stok.barang-hilang', [ 'products' => Product::orderBy('nama')->get(), - 'movements' => StockMovement::with(['product', 'user']) - ->kategori(StockMovement::KATEGORI_HILANG) - ->latest(StockMovement::columnDibuat()) + 'movements' => LostStock::with(['product', 'user']) + ->latest(LostStock::CREATED_AT) ->limit(50) ->get(), ]); @@ -120,7 +126,20 @@ public function storeOutgoing(Request $request): RedirectResponse { $this->ensureAdmin(); - $this->saveMovement($request, 'keluar'); + $minimumStockNotificationSent = $this->saveMovement($request, 'keluar'); + + if ($minimumStockNotificationSent === true) { + return redirect() + ->route('stock-movements.outgoing') + ->with('success', 'Barang keluar berhasil disimpan dan notifikasi stok minimum berhasil dikirim.'); + } + + if ($minimumStockNotificationSent === false) { + return redirect() + ->route('stock-movements.outgoing') + ->with('success', 'Barang keluar berhasil disimpan.') + ->with('warning', 'Notifikasi WhatsApp stok minimum gagal dikirim. Pastikan perangkat WhatsApp gateway terhubung.'); + } return redirect()->route('stock-movements.outgoing')->with('success', 'Barang keluar berhasil disimpan.'); } @@ -129,24 +148,61 @@ public function storeLost(Request $request): RedirectResponse { $this->ensureAdmin(); - $this->saveMovement($request, 'keluar', true); + $minimumStockNotificationSent = $this->saveMovement($request, 'keluar', true); + + if ($minimumStockNotificationSent === true) { + return redirect() + ->route('stock-movements.lost') + ->with('success', 'Input barang hilang berhasil disimpan dan notifikasi stok minimum berhasil dikirim.'); + } + + if ($minimumStockNotificationSent === false) { + return redirect() + ->route('stock-movements.lost') + ->with('success', 'Input barang hilang berhasil disimpan.') + ->with('warning', 'Notifikasi WhatsApp stok minimum gagal dikirim. Pastikan perangkat WhatsApp gateway terhubung.'); + } return redirect()->route('stock-movements.lost')->with('success', 'Input barang hilang berhasil disimpan.'); } - private function saveMovement(Request $request, string $type, bool $isLost = false): void + private function saveMovement(Request $request, string $type, bool $isLost = false): ?bool { - $validated = $request->validate([ + $rules = [ 'id_barang' => ['required', 'exists:barang,id'], 'jumlah' => ['required', 'integer', 'min:1'], - 'keterangan' => ['nullable', 'string', 'max:255'], - ]); + ]; - DB::transaction(function () use ($validated, $type, $isLost): void { + if (!$isLost) { + $rules['kategori'] = ['required', 'string', 'max:100']; + } + + if ($type === 'masuk') { + $rules['keterangan'] = ['nullable', 'string', 'max:255']; + } + + if ($type === 'keluar') { + $rules['keterangan'] = ['required', 'string', 'max:255']; + } + + $validated = $request->validate($rules); + $description = $this->movementDescription($validated, $type, $isLost); + + $minimumStockAlertProduct = DB::transaction(function () use ($validated, $description, $type, $isLost): ?Product { $product = Product::lockForUpdate()->findOrFail($validated['id_barang']); + + if (!$isLost && strtolower(trim((string) $product->kategori)) !== strtolower(trim($validated['kategori']))) { + throw ValidationException::withMessages([ + 'kategori' => 'Kategori yang dipilih tidak sesuai dengan barang.', + ]); + } + + $user = Auth::user(); + $waktu = now(); + $oldStock = (int) $product->stok; $newStock = $type === 'masuk' - ? $product->stok + $validated['jumlah'] - : $product->stok - $validated['jumlah']; + ? $oldStock + $validated['jumlah'] + : $oldStock - $validated['jumlah']; if ($newStock < 0) { throw ValidationException::withMessages([ @@ -154,25 +210,80 @@ private function saveMovement(Request $request, string $type, bool $isLost = fal ]); } - StockMovement::create([ + $movement = StockMovement::create([ 'id_barang' => $product->id, 'tipe' => $type, 'kategori' => $isLost ? StockMovement::KATEGORI_HILANG : ($type === 'masuk' ? StockMovement::KATEGORI_MASUK : StockMovement::KATEGORI_KELUAR), 'jumlah' => $validated['jumlah'], - 'keterangan' => $isLost - ? ($validated['keterangan'] ?? null) - : ($validated['keterangan'] ?? null), + 'keterangan' => $description, 'id_pengguna' => Auth::id(), ]); + $activityModel = $this->activityModel($type, $isLost); + + $activityModel::create([ + 'id_barang' => $product->id, + 'id_mutasi_stok' => $movement->id, + 'waktu' => $waktu, + 'barang' => $product->nama, + 'jumlah' => $validated['jumlah'], + 'keterangan' => $description, + 'id_pengguna' => $user?->id, + 'input_oleh' => $user?->name, + ]); + $product->update(['stok' => $newStock]); + + $shouldSendMinimumStockAlert = $type === 'keluar' + && $newStock <= (int) $product->stok_minimum; + + if ($shouldSendMinimumStockAlert) { + $product->stok = $newStock; + + return $product; + } + + return null; }); + + if ($minimumStockAlertProduct !== null) { + return $this->whatsAppStockNotifier->sendMinimumStockAlert( + $minimumStockAlertProduct, + (int) $validated['jumlah'] + ); + } + + return null; + } + + private function movementDescription(array $validated, string $type, bool $isLost): ?string + { + return $validated['keterangan'] ?? null; + } + + /** + * @return class-string + */ + private function activityModel(string $type, bool $isLost): string + { + if ($isLost) { + return LostStock::class; + } + + return $type === 'masuk' + ? IncomingStock::class + : OutgoingStock::class; } private function ensureAdmin(): void { abort_unless(auth()->user()?->role === 'admin', 403); } + + private function ensureAdminOrOwner(): void + { + abort_unless(in_array(auth()->user()?->role, ['admin', 'owner'], true), 403); + } } diff --git a/app/Models/Concerns/HasIndonesianTimestamps.php b/app/Models/Concerns/HasIndonesianTimestamps.php new file mode 100644 index 0000000..192c7f2 --- /dev/null +++ b/app/Models/Concerns/HasIndonesianTimestamps.php @@ -0,0 +1,53 @@ +attributes[self::CREATED_AT] ?? null; + + if ($raw === null || $raw === '') { + return null; + } + + return $this->asDateTime($raw); + }); + } + + protected function updatedAt(): Attribute + { + return Attribute::get(function () { + $raw = $this->attributes[self::UPDATED_AT] ?? null; + + if ($raw === null || $raw === '') { + return null; + } + + return $this->asDateTime($raw); + }); + } + + protected function casts(): array + { + return [ + 'waktu' => 'datetime', + self::CREATED_AT => 'datetime', + self::UPDATED_AT => 'datetime', + ]; + } +} diff --git a/app/Models/IncomingStock.php b/app/Models/IncomingStock.php new file mode 100644 index 0000000..33d80eb --- /dev/null +++ b/app/Models/IncomingStock.php @@ -0,0 +1,8 @@ +belongsTo(Product::class, 'id_barang'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'id_pengguna'); + } + + public function stockMovement(): BelongsTo + { + return $this->belongsTo(StockMovement::class, 'id_mutasi_stok'); + } +} diff --git a/app/Services/WhatsAppStockNotifier.php b/app/Services/WhatsAppStockNotifier.php new file mode 100644 index 0000000..9911314 --- /dev/null +++ b/app/Services/WhatsAppStockNotifier.php @@ -0,0 +1,92 @@ + $product->id, + 'product_name' => $product->nama, + ]); + + return false; + } + + $message = $this->minimumStockMessage($product, $outgoingQuantity); + $allSent = true; + + foreach ($targets as $target) { + try { + $response = Http::timeout(10)->withHeaders([ + 'Authorization' => $token, + ])->asForm()->post($endpoint, [ + 'target' => $target, + 'message' => $message, + ]); + $responseData = $response->json(); + + if (!$response->successful() || ($responseData !== null && ($responseData['status'] ?? true) === false)) { + Log::warning('Notifikasi stok minimum WhatsApp gagal dikirim.', [ + 'product_id' => $product->id, + 'target' => $target, + 'status' => $response->status(), + 'response' => $response->body(), + ]); + + $allSent = false; + } else { + Log::info('Notifikasi stok minimum WhatsApp berhasil dikirim.', [ + 'product_id' => $product->id, + 'product_name' => $product->nama, + 'target' => $target, + 'outgoing_quantity' => $outgoingQuantity, + 'stock' => $product->stok, + 'minimum_stock' => $product->stok_minimum, + ]); + } + } catch (\Throwable $exception) { + Log::warning('Notifikasi stok minimum WhatsApp gagal diproses.', [ + 'product_id' => $product->id, + 'target' => $target, + 'error' => $exception->getMessage(), + ]); + + $allSent = false; + } + } + + return $allSent; + } + + public function minimumStockMessage(Product $product, ?int $outgoingQuantity = null): string + { + $lines = [ + 'Peringatan Stok Minimum', + '', + "Barang: {$product->nama}", + ]; + + if ($outgoingQuantity !== null) { + $lines[] = "Stok keluar: {$outgoingQuantity} unit"; + } + + return implode("\n", [ + ...$lines, + "Stok saat ini: {$product->stok} unit", + "Stok minimum: {$product->stok_minimum} unit", + '', + 'Mohon segera lakukan restock barang.', + ]); + } +} diff --git a/config/services.php b/config/services.php index 6a90eb8..9426738 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,14 @@ ], ], + 'whatsapp' => [ + 'endpoint' => env('WHATSAPP_GATEWAY_ENDPOINT', 'https://api.fonnte.com/send'), + 'token' => env('WHATSAPP_GATEWAY_TOKEN'), + 'target_number' => env('WHATSAPP_TARGET_NUMBER'), + 'target_numbers' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('WHATSAPP_TARGET_NUMBERS', env('WHATSAPP_TARGET_NUMBER', ''))) + ))), + ], + ]; diff --git a/database/migrations/2026_04_22_101015_create_products_table.php b/database/migrations/2026_04_22_101015_create_products_table.php index 830a0cb..68707d7 100644 --- a/database/migrations/2026_04_22_101015_create_products_table.php +++ b/database/migrations/2026_04_22_101015_create_products_table.php @@ -18,7 +18,7 @@ public function up(): void $table->string('kategori')->nullable(); $table->unsignedInteger('stok')->default(0); $table->unsignedInteger('stok_minimum')->default(0); - $table->text('deskripsi')->nullable(); + $table->text('keterangan')->nullable(); $table->timestamps(); }); } diff --git a/database/migrations/2026_06_10_000001_create_separate_stock_activity_tables.php b/database/migrations/2026_06_10_000001_create_separate_stock_activity_tables.php new file mode 100644 index 0000000..65aed35 --- /dev/null +++ b/database/migrations/2026_06_10_000001_create_separate_stock_activity_tables.php @@ -0,0 +1,111 @@ +createActivityTable('barang_masuk'); + $this->createActivityTable('barang_keluar'); + $this->createActivityTable('barang_hilang'); + + $this->copyMutasiToActivityTable('barang_masuk', 'masuk'); + $this->copyMutasiToActivityTable('barang_keluar', 'keluar'); + $this->copyMutasiToActivityTable('barang_hilang', 'hilang'); + } + + public function down(): void + { + Schema::dropIfExists('barang_hilang'); + Schema::dropIfExists('barang_keluar'); + Schema::dropIfExists('barang_masuk'); + + $this->createCategoryView('barang_masuk', 'masuk'); + $this->createCategoryView('barang_keluar', 'keluar'); + $this->createCategoryView('barang_hilang', 'hilang'); + } + + private function createActivityTable(string $tableName): void + { + if (Schema::hasTable($tableName)) { + return; + } + + Schema::create($tableName, function (Blueprint $table) { + $table->id(); + $table->foreignId('id_barang')->constrained('barang')->cascadeOnDelete(); + $table->foreignId('id_mutasi_stok')->nullable()->unique()->constrained('mutasi_stok')->nullOnDelete(); + $table->timestamp('waktu')->nullable(); + $table->string('barang')->nullable(); + $table->unsignedInteger('jumlah'); + $table->string('keterangan')->nullable(); + $table->foreignId('id_pengguna')->nullable()->constrained('users')->nullOnDelete(); + $table->string('input_oleh')->nullable(); + $table->timestamp('dibuat_pada')->nullable(); + $table->timestamp('diperbarui_pada')->nullable(); + }); + } + + private function copyMutasiToActivityTable(string $tableName, string $kategori): void + { + if (! Schema::hasTable('mutasi_stok') || ! Schema::hasTable($tableName)) { + return; + } + + DB::statement(" + INSERT INTO {$tableName} + (id_mutasi_stok, id_barang, waktu, barang, jumlah, keterangan, id_pengguna, input_oleh, dibuat_pada, diperbarui_pada) + SELECT + ms.id, + ms.id_barang, + ms.dibuat_pada, + b.nama, + ms.jumlah, + ms.keterangan, + ms.id_pengguna, + u.name, + ms.dibuat_pada, + ms.diperbarui_pada + FROM mutasi_stok ms + LEFT JOIN barang b ON b.id = ms.id_barang + LEFT JOIN users u ON u.id = ms.id_pengguna + WHERE ms.kategori = ? + AND NOT EXISTS ( + SELECT 1 FROM {$tableName} target + WHERE target.id_mutasi_stok = ms.id + ) + ", [$kategori]); + } + + private function createCategoryView(string $viewName, string $category): void + { + DB::statement(" + CREATE VIEW {$viewName} AS + SELECT + ms.id, + ms.id_barang, + b.kode AS kode_barang, + b.nama AS nama_barang, + ms.tipe, + ms.kategori, + ms.jumlah, + ms.keterangan, + ms.id_pengguna, + u.name AS nama_pengguna, + ms.dibuat_pada, + ms.diperbarui_pada + FROM mutasi_stok ms + LEFT JOIN barang b ON b.id = ms.id_barang + LEFT JOIN users u ON u.id = ms.id_pengguna + WHERE ms.kategori = '{$category}' + "); + } +}; diff --git a/database/migrations/2026_06_10_000002_align_product_keterangan_column.php b/database/migrations/2026_06_10_000002_align_product_keterangan_column.php new file mode 100644 index 0000000..fe98848 --- /dev/null +++ b/database/migrations/2026_06_10_000002_align_product_keterangan_column.php @@ -0,0 +1,50 @@ +renameColumn('deskripsi', 'keterangan'); + }); + + return; + } + + if (! Schema::hasColumn('barang', 'keterangan')) { + Schema::table('barang', function (Blueprint $table) { + $table->text('keterangan')->nullable(); + }); + } + + if (Schema::hasColumn('barang', 'deskripsi') && Schema::hasColumn('barang', 'keterangan')) { + DB::table('barang') + ->whereNull('keterangan') + ->whereNotNull('deskripsi') + ->update(['keterangan' => DB::raw('deskripsi')]); + } + } + + public function down(): void + { + if (! Schema::hasTable('barang')) { + return; + } + + if (Schema::hasColumn('barang', 'keterangan') && ! Schema::hasColumn('barang', 'deskripsi')) { + Schema::table('barang', function (Blueprint $table) { + $table->renameColumn('keterangan', 'deskripsi'); + }); + } + } +}; diff --git a/database/migrations/2026_06_10_000003_align_stock_activity_display_columns.php b/database/migrations/2026_06_10_000003_align_stock_activity_display_columns.php new file mode 100644 index 0000000..49be15c --- /dev/null +++ b/database/migrations/2026_06_10_000003_align_stock_activity_display_columns.php @@ -0,0 +1,76 @@ +activityTables as $tableName) { + if (! Schema::hasTable($tableName)) { + continue; + } + + Schema::table($tableName, function (Blueprint $table) use ($tableName) { + if (! Schema::hasColumn($tableName, 'waktu')) { + $table->timestamp('waktu')->nullable()->after('id_mutasi_stok'); + } + + if (! Schema::hasColumn($tableName, 'barang')) { + $table->string('barang')->nullable()->after('waktu'); + } + + if (! Schema::hasColumn($tableName, 'input_oleh')) { + $table->string('input_oleh')->nullable()->after('id_pengguna'); + } + }); + + $this->backfillDisplayColumns($tableName); + } + } + + public function down(): void + { + foreach ($this->activityTables as $tableName) { + if (! Schema::hasTable($tableName)) { + continue; + } + + Schema::table($tableName, function (Blueprint $table) use ($tableName) { + if (Schema::hasColumn($tableName, 'input_oleh')) { + $table->dropColumn('input_oleh'); + } + + if (Schema::hasColumn($tableName, 'barang')) { + $table->dropColumn('barang'); + } + + if (Schema::hasColumn($tableName, 'waktu')) { + $table->dropColumn('waktu'); + } + }); + } + } + + private function backfillDisplayColumns(string $tableName): void + { + DB::statement(" + UPDATE {$tableName} aktivitas + LEFT JOIN barang b ON b.id = aktivitas.id_barang + LEFT JOIN users u ON u.id = aktivitas.id_pengguna + SET + aktivitas.waktu = COALESCE(aktivitas.waktu, aktivitas.dibuat_pada), + aktivitas.barang = COALESCE(aktivitas.barang, b.nama), + aktivitas.input_oleh = COALESCE(aktivitas.input_oleh, u.name) + "); + } +}; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 554be88..b569110 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -17,17 +17,17 @@ .card { width: 100%; - max-width: 420px; + max-width: 450px; background: rgba(255, 255, 255, 0.98); border-radius: 16px; border: 1px solid #dbe2ea; box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08); - padding: 30px 28px; + padding: 30px 45px; } h1 { margin-top: 0; margin-bottom: 8px; - font-size: 28px; + font-size: 40px; color: #14532d; letter-spacing: -0.02em; } @@ -49,7 +49,7 @@ border-radius: 10px; padding: 11px 12px; margin-bottom: 14px; - font-size: 14px; + font-size: 16px; transition: all 0.18s ease; } input:focus { @@ -64,7 +64,7 @@ border: 0; border-radius: 10px; padding: 12px; - font-size: 16px; + font-size: 18px; font-weight: 700; cursor: pointer; transition: all 0.16s ease; @@ -93,8 +93,8 @@
-

SIM Meubel

-

Login untuk akses web

+

SIM Mebel

+

@if ($errors->any())
{{ $errors->first() }}
@@ -109,7 +109,7 @@ - +
diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 2d62037..5c5650d 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -87,7 +87,7 @@ .stat-value { margin-top: 4px; - font-size: 34px; + font-size: 35px; line-height: 1.1; font-weight: 800; color: #0f172a; @@ -132,38 +132,6 @@ letter-spacing: -0.015em; } - /* .bars { - display: flex; - align-items: flex-end; - gap: 20px; - height: 300px; - } */ - - /* .bar-row { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - width: 60px; - } */ - - /* .bar-track { - width: 100%; - height: 150px; - background: #eef2f7; - border-radius: 10px; - position: relative; - overflow: hidden; - } */ - - /* .bar-fill { - position: absolute; - bottom: 0; - width: 100%; - height: 60%; - background: linear-gradient(to top, forestgreen, #34d399); - } */ - .list { margin: 0; padding-left: 20px; @@ -433,6 +401,92 @@ font-weight: 600; color: #713f12; } + + .wa-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin: 14px 0; + } + + .wa-metric { + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #f8fafc; + padding: 12px 14px; + min-width: 0; + } + + .wa-metric span { + display: block; + color: #64748b; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.03em; + text-transform: uppercase; + font-family: var(--font-heading); + } + + .wa-metric strong { + display: block; + margin-top: 4px; + color: #0f172a; + font-size: 24px; + line-height: 1.1; + font-family: var(--font-heading); + font-variant-numeric: tabular-nums; + } + + .wa-auto-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 5px 10px; + border-radius: 999px; + background: #dcfce7; + color: #166534; + font-size: 12px; + font-weight: 800; + font-family: var(--font-heading); + } + + .wa-auto-badge.pending { + background: #fef3c7; + color: #92400e; + } + + .wa-message-preview { + margin-top: 14px; + border: 1px solid #dbe2ea; + border-radius: 10px; + background: #f8fafc; + padding: 12px 14px; + color: #334155; + font-size: 13px; + line-height: 1.6; + white-space: pre-wrap; + } + + .wa-note { + margin: 10px 0 0; + color: #64748b; + font-size: 13px; + font-weight: 600; + } + + @media (max-width: 760px) { + .wa-summary, + .compare-grid { + grid-template-columns: 1fr; + } + + .yearly-chart { + overflow-x: auto; + grid-template-columns: repeat(12, 54px); + padding-bottom: 8px; + } + }
@@ -507,6 +561,63 @@ +
+
+
+

Notifikasi Stok Minimum via WhatsApp

+
+ Sistem mengirim pesan otomatis ke nomor penerima saat stok barang mencapai batas minimum. +
+
+ +
+ + + + + + + + + + + + + + @forelse ($produkStokMenipis as $produk) + + + + + + + @empty + + + + @endforelse + +
ProdukStokStok MinimumKondisi
{{ $produk->nama }}{{ number_format((int) $produk->stok) }} unit{{ number_format((int) $produk->stok_minimum) }} unitPerlu restock
Semua stok barang masih berada di atas stok minimum.
+ + + +
+