update: pisah tabel stock activity, refactor controller & report, tambah integrasi WhatsApp gateway, perbaikan responsive layout untuk mobile

This commit is contained in:
Zakaria 2026-07-22 19:23:05 +07:00
parent 6fcb4b445e
commit 7a4c70bee2
31 changed files with 1601 additions and 526 deletions

View File

@ -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

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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();

View File

@ -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<StockActivity>
*/
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);
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Casts\Attribute;
trait HasIndonesianTimestamps
{
public function getCreatedAtColumn(): ?string
{
return self::CREATED_AT;
}
public function getUpdatedAtColumn(): ?string
{
return self::UPDATED_AT;
}
protected function createdAt(): Attribute
{
return Attribute::get(function () {
$raw = $this->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',
];
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Models;
class IncomingStock extends StockActivity
{
protected $table = 'barang_masuk';
}

8
app/Models/LostStock.php Normal file
View File

@ -0,0 +1,8 @@
<?php
namespace App\Models;
class LostStock extends StockActivity
{
protected $table = 'barang_hilang';
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Models;
class OutgoingStock extends StockActivity
{
protected $table = 'barang_keluar';
}

View File

@ -24,7 +24,7 @@ class Product extends Model
'kategori',
'stok',
'stok_minimum',
'deskripsi',
'keterangan',
];
public function stockMovements(): HasMany

View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasIndonesianTimestamps;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
abstract class StockActivity extends Model
{
use HasIndonesianTimestamps;
public const CREATED_AT = 'dibuat_pada';
public const UPDATED_AT = 'diperbarui_pada';
protected $fillable = [
'id_barang',
'id_mutasi_stok',
'waktu',
'barang',
'jumlah',
'keterangan',
'id_pengguna',
'input_oleh',
];
public function product(): BelongsTo
{
return $this->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');
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WhatsAppStockNotifier
{
public function sendMinimumStockAlert(Product $product, ?int $outgoingQuantity = null): bool
{
$targets = config('services.whatsapp.target_numbers', []);
$token = config('services.whatsapp.token');
$endpoint = config('services.whatsapp.endpoint');
if ($targets === [] || !$token || !$endpoint) {
Log::info('Notifikasi stok minimum WhatsApp belum dikirim karena konfigurasi belum lengkap.', [
'product_id' => $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.',
]);
}
}

View File

@ -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', '')))
))),
],
];

View File

@ -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();
});
}

View File

@ -0,0 +1,111 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
DB::statement('DROP VIEW IF EXISTS barang_masuk');
DB::statement('DROP VIEW IF EXISTS barang_keluar');
DB::statement('DROP VIEW IF EXISTS barang_hilang');
$this->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}'
");
}
};

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('barang')) {
return;
}
if (Schema::hasColumn('barang', 'deskripsi') && ! Schema::hasColumn('barang', 'keterangan')) {
Schema::table('barang', function (Blueprint $table) {
$table->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');
});
}
}
};

View File

@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private array $activityTables = [
'barang_masuk',
'barang_keluar',
'barang_hilang',
];
public function up(): void
{
foreach ($this->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)
");
}
};

View File

@ -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 @@
<body>
<div class="card">
<h1><center>SIM Meubel</center></h1>
<p class="hint" style="text-align:center; font-size:16px; color:grey; font-weight:700;">Login untuk akses web</p>
<h1><center>SIM Mebel</center></h1>
<p class="hint" style="text-align:center; font-size:16px; color:grey; font-weight:700;"></p>
@if ($errors->any())
<div class="error">{{ $errors->first() }}</div>
@ -109,7 +109,7 @@
<label for="password">Password</label>
<input id="password" name="password" type="password" required>
<button type="submit">Masuk</button>
<button type="submit">Login</button>
</form>
</div>

View File

@ -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;
}
}
</style>
<section class="hero">
@ -507,6 +561,63 @@
</article>
</div>
<section class="panel" style="margin-top:14px;">
<div class="yearly-header">
<div>
<h3 style="margin:0;">Notifikasi Stok Minimum via WhatsApp</h3>
<div style="font-size:13px; color:#64748b; margin-top:4px;">
Sistem mengirim pesan otomatis ke nomor penerima saat stok barang mencapai batas minimum.
</div>
</div>
<span class="wa-auto-badge {{ $nomorPenerimaWhatsApp->isNotEmpty() ? '' : 'pending' }}">
{{ $nomorPenerimaWhatsApp->isNotEmpty() ? 'Otomatis aktif' : 'Nomor belum diatur' }}
</span>
</div>
<div class="wa-summary">
<div class="wa-metric">
<span>Barang Stok Minimum</span>
<strong>{{ number_format($stokMenipis) }}</strong>
</div>
<div class="wa-metric">
<span>Persentase Stok Aman</span>
<strong>{{ $persentaseStokAman }}%</strong>
</div>
<div class="wa-metric">
<span>Nomor Penerima</span>
<strong>{{ $nomorPenerimaWhatsApp->isNotEmpty() ? $nomorPenerimaWhatsApp->implode(', ') : '-' }}</strong>
</div>
</div>
<table class="app-table">
<thead>
<tr>
<th>Produk</th>
<th>Stok</th>
<th>Stok Minimum</th>
<th>Kondisi</th>
</tr>
</thead>
<tbody>
@forelse ($produkStokMenipis as $produk)
<tr>
<td><strong>{{ $produk->nama }}</strong></td>
<td>{{ number_format((int) $produk->stok) }} unit</td>
<td>{{ number_format((int) $produk->stok_minimum) }} unit</td>
<td>Perlu restock</td>
</tr>
@empty
<tr>
<td colspan="4">Semua stok barang masih berada di atas stok minimum.</td>
</tr>
@endforelse
</tbody>
</table>
<div class="wa-message-preview">{{ $pesanWhatsAppStokMinimum }}</div>
<p class="wa-note">Pesan dikirim otomatis oleh sistem setelah transaksi barang keluar atau barang hilang membuat stok mencapai batas minimum.</p>
</section>
<!-- <div class="stock-alert">
Stok Menipis: <strong>{{ $stokMenipis }}</strong> produk perlu perhatian.
Persentase stok aman saat ini <strong>{{ $persentaseStokAman }}%</strong>.
@ -727,4 +838,4 @@
</tbody>
</table>
</section>
@endsection
@endsection

View File

@ -31,13 +31,13 @@
font-family: "Segoe UI", Arial, sans-serif;
background: var(--bg-app);
color: var(--text-main);
font-size: 14px;
font-size: 18px;
line-height: 1.55;
overflow-x: hidden;
}
.topbar {
height: 74px;
height: 75px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
@ -51,8 +51,8 @@
}
.brand {
font-size: 24px;
font-weight: 700;
font-size: 30px;
font-weight: 850;
color: #166534;
margin: 0;
letter-spacing: -0.02em;
@ -73,7 +73,7 @@
border-radius: 8px;
border: 1px solid #cde9d2;
background: #f3fff4;
font-size: 13px;
font-size: 28px;
letter-spacing: 0.01em;
}
@ -91,7 +91,7 @@
}
.main {
padding: 18px;
padding: 15px;
}
.flash {
@ -120,10 +120,10 @@
button,
textarea {
font-family: inherit;
font-size: 14px;
font-size: 19px;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 9px 10px;
padding: 10px 10px;
transition: all 0.18s ease;
background: #fff;
color: var(--text-main);
@ -143,7 +143,7 @@
color: #fff;
border-color: var(--primary);
font-weight: 600;
padding: 9px 14px;
padding: 12px 14px;
}
button:hover {
@ -181,7 +181,8 @@
overflow-x: auto;
}
.app-table {
/* lebar kolom */
.app-table {
width: 100%;
min-width: 680px;
border-collapse: separate;
@ -190,17 +191,17 @@
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-card);
}
}
.app-table thead th {
background: var(--bg-soft);
color: #334155;
font-size: 13px;
font-size: 16px;
font-weight: 700;
border-bottom: 1px solid var(--line);
text-transform: uppercase;
letter-spacing: 0.02em;
}
} /* Table Daftar User */
.app-table th,
.app-table td {
@ -245,11 +246,11 @@
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-size: 19px;
line-height: 1.25;
border: 1px solid transparent;
transition: all 0.15s ease;
}
} /* Sidebar Font*/
aside a:hover {
background: rgba(22, 101, 52, 0.95);
@ -282,7 +283,7 @@
}
.layout-sidebar {
width: 280px;
width: 300px;
background: linear-gradient(180deg, #14532d 0%, #166534 100%);
color: #fff;
padding: 16px;
@ -365,6 +366,11 @@
min-width: 0;
}
.app-table th,
.app-table td {
font-size: 16px;
}
.btn-link {
display: inline-flex;
align-items: center;
@ -406,7 +412,7 @@
@media (max-width: 1024px) {
.layout-sidebar {
width: 236px;
width: 250px;
}
aside a {
@ -466,7 +472,7 @@
}
.table-card {
padding: 12px;
padding: 16px;
border-radius: 10px;
}
@ -538,7 +544,7 @@
.app-table th,
.app-table td {
padding: 8px 9px;
font-size: 13px;
font-size: 16px;
}
}
</style>
@ -551,7 +557,8 @@
<!-- SIDEBAR -->
<aside class="layout-sidebar">
<h2 style="margin-top:0; margin-bottom:20px; color:#ffffff; font-size:26px; letter-spacing:-0.01em;">SIM Meubel</h2>
<h2 style="margin-top:0; margin-bottom:20px; color:#ffffff; font-size:26px; letter-spacing:-0.01em;">SIM
Meubel</h2>
<a href="{{ route('dashboard') }}" class="{{ $activeMenu === 'dashboard' ? 'active' : '' }}">
<span class="menu-icon"><i class="bi bi-speedometer2" aria-hidden="true"></i></span>
@ -565,14 +572,14 @@
</a>
@endif
@if (auth()->user()?->role === 'admin')
@if (in_array(auth()->user()?->role, ['admin', 'owner'], true))
<a href="{{ route('products.index') }}" class="{{ $activeMenu === 'barang' ? 'active' : '' }}">
<span class="menu-icon"><i class="bi bi-box-seam" aria-hidden="true"></i></span>
<span>Data Barang</span>
</a>
@endif
@if (auth()->user()?->role === 'admin')
@if (in_array(auth()->user()?->role, ['admin', 'owner'], true))
<a href="{{ route('stock-movements.incoming') }}" class="{{ $activeMenu === 'masuk' ? 'active' : '' }}">
<span class="menu-icon"><i class="bi bi-box-arrow-in-down" aria-hidden="true"></i></span>
<span>Barang Masuk</span>
@ -582,7 +589,9 @@
<span class="menu-icon"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i></span>
<span>Barang Keluar</span>
</a>
@endif
@if (auth()->user()?->role === 'admin')
<a href="{{ route('stock-movements.lost') }}" class="{{ $activeMenu === 'hilang' ? 'active' : '' }}">
<span class="menu-icon"><i class="bi bi-exclamation-triangle" aria-hidden="true"></i></span>
<span>Input Barang Hilang</span>
@ -601,16 +610,16 @@ class="{{ $activeMenu === 'reports-pembelian' ? 'active' : '' }}">
<span class="menu-icon"><i class="bi bi-receipt" aria-hidden="true"></i></span>
<span>Laporan Pembelian</span>
</a>
<a href="{{ route('reports.penjualan') }}"
class="{{ $activeMenu === 'reports-penjualan' ? 'active' : '' }}">
<span class="menu-icon"><i class="bi bi-cash-coin" aria-hidden="true"></i></span>
<span>Laporan Penjualan</span>
</a>
<a href="{{ route('reports.rekap-pembelian') }}"
class="{{ $activeMenu === 'reports-rekap-pembelian' ? 'active' : '' }}">
<span class="menu-icon"><i class="bi bi-bar-chart-line" aria-hidden="true"></i></span>
<span>Rekap Pembelian</span>
</a>
<a href="{{ route('reports.penjualan') }}"
class="{{ $activeMenu === 'reports-penjualan' ? 'active' : '' }}">
<span class="menu-icon"><i class="bi bi-cash-coin" aria-hidden="true"></i></span>
<span>Laporan Penjualan</span>
</a>
<a href="{{ route('reports.rekap-penjualan') }}"
class="{{ $activeMenu === 'reports-rekap-penjualan' ? 'active' : '' }}">
<span class="menu-icon"><i class="bi bi-graph-up-arrow" aria-hidden="true"></i></span>
@ -640,6 +649,10 @@ class="{{ $activeMenu === 'reports-rekap-penjualan' ? 'active' : '' }}">
<div class="flash success">{{ session('success') }}</div>
@endif
@if (session('warning'))
<div class="flash error">{{ session('warning') }}</div>
@endif
@if ($errors->any())
<div class="flash error">{{ $errors->first() }}</div>
@endif
@ -651,4 +664,4 @@ class="{{ $activeMenu === 'reports-rekap-penjualan' ? 'active' : '' }}">
</div>
</body>
</html>
</html>

View File

@ -4,10 +4,22 @@
@section('active_menu', 'hilang')
@section('content')
<style>
@php($keteranganKehilangan = ['Hilang'])
<!-- <style>
.panel { margin-bottom: 14px; }
.inline { display: grid; grid-template-columns: 1fr 220px 1fr auto; gap: 10px; }
</style>
</style> -->
<style>
.panel { margin-bottom: 14px; }
.inline { display: grid; grid-template-columns: 1fr 220px 1fr auto; gap: 10px; }
@media (max-width: 768px) {
.inline {
grid-template-columns: 1fr;
}
}
</style>
<div class="table-card panel">
<h2>Input Barang Hilang</h2>
@ -20,7 +32,12 @@
@endforeach
</select>
<input type="number" name="jumlah" min="1" placeholder="Jumlah hilang" required>
<input type="text" name="keterangan" placeholder="Keterangan kehilangan">
<select name="keterangan" required>
<option value="">Pilih keterangan</option>
@foreach ($keteranganKehilangan as $keterangan)
<option value="{{ $keterangan }}">{{ $keterangan }}</option>
@endforeach
</select>
<button type="submit" style="background:#b91c1c; border-color:#b91c1c;">Simpan</button>
</form>
</div>
@ -40,11 +57,11 @@
<tbody>
@forelse ($movements as $movement)
<tr>
<td>{{ $movement->created_at?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '—' }}</td>
<td>{{ $movement->product->nama }}</td>
<td>{{ ($movement->waktu ?? $movement->created_at)?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '—' }}</td>
<td>{{ $movement->barang ?? $movement->product?->nama ?? '-' }}</td>
<td>{{ $movement->jumlah }}</td>
<td>{{ $movement->keterangan ?? '-' }}</td>
<td>{{ $movement->user?->name ?? '-' }}</td>
<td>{{ $movement->input_oleh ?? $movement->user?->name ?? '-' }}</td>
</tr>
@empty
<tr><td colspan="5">Belum ada data barang hilang.</td></tr>

View File

@ -4,33 +4,66 @@
@section('active_menu', 'keluar')
@section('content')
@php($isAdmin = auth()->user()?->role === 'admin')
@php($kategoriBarang = ['Meja', 'Kursi', 'Lemari', 'Kasur', 'Sofa', 'Rak', 'Bufet'])
@php($keteranganBarangKeluar = ['Terjual'])
<style>
.panel {
margin-bottom: 14px;
}
.inline {
form.outgoing-form {
display: grid;
grid-template-columns: 1fr 220px 1fr auto;
grid-template-columns: minmax(150px, 190px) minmax(230px, 1fr) minmax(170px, 220px) minmax(150px, 180px) max-content !important;
gap: 10px;
}
.inline button {
min-width: 72px;
height: 38px;
padding: 8px 12px;
}
@media (max-width: 768px) {
form.outgoing-form {
grid-template-columns: 1fr !important;
}
}
</style>
<div class="table-card panel">
<h2>Input Barang Keluar</h2>
<form action="{{ route('stock-movements.outgoing.store') }}" method="POST" class="inline">
@csrf
<select name="id_barang" required>
<option value="">Pilih barang</option>
@foreach ($products as $product)
<option value="{{ $product->id }}">{{ $product->nama }} (stok: {{ $product->stok }})</option>
@endforeach
</select>
<input type="number" name="jumlah" min="1" placeholder="Jumlah keluar" required>
<input type="text" name="keterangan" placeholder="Keterangan">
<button type="submit">Simpan</button>
</form>
</div>
@if ($isAdmin)
<div class="table-card panel">
<h2>Input Barang Keluar</h2>
<form action="{{ route('stock-movements.outgoing.store') }}" method="POST" class="inline outgoing-form">
@csrf
<select name="kategori" class="category-filter" data-target="outgoing-product-select" required
oninvalid="this.setCustomValidity('Pilih kategori terlebih dahulu.')"
onchange="this.setCustomValidity('')">
<option value="">Pilih kategori</option>
@foreach ($kategoriBarang as $kategori)
<option value="{{ strtolower($kategori) }}">{{ ucfirst($kategori) }}</option>
@endforeach
</select>
<select name="id_barang" id="outgoing-product-select" required>
<option value="">Pilih barang</option>
@foreach ($products as $product)
<option value="{{ $product->id }}" data-kategori="{{ strtolower($product->kategori ?? '') }}">
{{ $product->nama }} (stok: {{ $product->stok }})
</option>
@endforeach
</select>
<input type="number" name="jumlah" min="1" placeholder="Jumlah keluar" required>
<select name="keterangan" required>
<option value="">Pilih keterangan</option>
@foreach ($keteranganBarangKeluar as $keterangan)
<option value="{{ $keterangan }}" @selected($keterangan === 'Terjual')>{{ $keterangan }}</option>
@endforeach
</select>
<button type="submit">Simpan</button>
</form>
</div>
@endif
<div class="table-card panel">
<h2>Filter Barang Keluar</h2>
@ -65,11 +98,11 @@
<tbody>
@forelse ($movements as $movement)
<tr>
<td>{{ $movement->created_at?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '—' }}</td>
<td>{{ $movement->product->nama }}</td>
<td>{{ ($movement->waktu ?? $movement->created_at)?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '—' }}</td>
<td>{{ $movement->barang ?? $movement->product?->nama ?? '-' }}</td>
<td>{{ $movement->jumlah }}</td>
<td>{{ $movement->keterangan ?? '-' }}</td>
<td>{{ $movement->user?->name ?? '-' }}</td>
<td>{{ $movement->input_oleh ?? $movement->user?->name ?? '-' }}</td>
</tr>
@empty
<tr>
@ -79,4 +112,30 @@
</tbody>
</table>
</div>
@if ($isAdmin)
<script>
document.querySelectorAll('.category-filter').forEach((categorySelect) => {
categorySelect.addEventListener('change', () => {
const productSelect = document.getElementById(categorySelect.dataset.target);
const selectedCategory = categorySelect.value;
if (!productSelect) {
return;
}
productSelect.value = '';
productSelect.querySelectorAll('option').forEach((option) => {
if (!option.value) {
option.hidden = false;
return;
}
option.hidden = selectedCategory !== '' && option.dataset.kategori !== selectedCategory;
});
});
});
</script>
@endif
@endsection

View File

@ -4,26 +4,50 @@
@section('active_menu', 'masuk')
@section('content')
<style>
.panel {
margin-bottom: 14px;
}
@php($isAdmin = auth()->user()?->role === 'admin')
@php($kategoriBarang = ['Meja', 'Kursi', 'Lemari', 'Kasur', 'Sofa', 'Rak', 'Bufet'])
<style>
.panel {
margin-bottom: 14px;
}
.inline {
display: grid;
grid-template-columns: 180px 1fr 220px 1fr auto;
gap: 10px;
}
.inline button {
min-width: 72px;
height: 38px;
padding: 8px 12px;
}
@media (max-width: 768px) {
.inline {
display: grid;
grid-template-columns: 1fr 220px 1fr auto;
gap: 10px;
grid-template-columns: 1fr;
}
</style>
}
</style>
@if ($isAdmin)
<div class="table-card panel">
<h2>Input Barang Masuk</h2>
<form action="{{ route('stock-movements.incoming.store') }}" method="POST" class="inline">
@csrf
<select name="id_barang" required>
<select name="kategori" class="category-filter" data-target="incoming-product-select" required
oninvalid="this.setCustomValidity('Pilih kategori terlebih dahulu.')" onchange="this.setCustomValidity('')">
<option value="">Pilih kategori</option>
@foreach ($kategoriBarang as $kategori)
<option value="{{ strtolower($kategori) }}">{{ ucfirst($kategori) }}</option>
@endforeach
</select>
<select name="id_barang" id="incoming-product-select" required>
<option value="">Pilih barang</option>
@foreach ($products as $product)
<option value="{{ $product->id }}">{{ $product->nama }} (stok: {{ $product->stok }})</option>
<option value="{{ $product->id }}" data-kategori="{{ strtolower($product->kategori ?? '') }}">
{{ $product->nama }} (stok: {{ $product->stok }})
</option>
@endforeach
</select>
<input type="number" name="jumlah" min="1" placeholder="Jumlah masuk" required>
@ -31,52 +55,80 @@
<button type="submit">Simpan</button>
</form>
</div>
@endif
<div class="table-card panel">
<h2>Filter Barang Masuk</h2>
<form method="GET" action="{{ route('stock-movements.incoming') }}" class="report-filter-form">
<div>
<label for="tanggal_mulai">Tanggal Mulai</label>
<input id="tanggal_mulai" type="date" name="tanggal_mulai" value="{{ request('tanggal_mulai') }}">
</div>
<div>
<label for="tanggal_selesai">Tanggal Selesai</label>
<input id="tanggal_selesai" type="date" name="tanggal_selesai" value="{{ request('tanggal_selesai') }}">
</div>
<button type="submit">Terapkan</button>
<a href="{{ route('stock-movements.incoming') }}" class="btn-link">
Reset
</a>
</form>
</div>
<div class="table-card panel">
<h2>Filter Barang Masuk</h2>
<form method="GET" action="{{ route('stock-movements.incoming') }}" class="report-filter-form">
<div>
<label for="tanggal_mulai">Tanggal Mulai</label>
<input id="tanggal_mulai" type="date" name="tanggal_mulai" value="{{ request('tanggal_mulai') }}">
</div>
<div>
<label for="tanggal_selesai">Tanggal Selesai</label>
<input id="tanggal_selesai" type="date" name="tanggal_selesai" value="{{ request('tanggal_selesai') }}">
</div>
<button type="submit">Terapkan</button>
<a href="{{ route('stock-movements.incoming') }}" class="btn-link">
Reset
</a>
</form>
</div>
<div class="table-card panel">
<h3>Riwayat Barang Masuk</h3>
<table class="app-table">
<thead>
<div class="table-card panel">
<h3>Riwayat Barang Masuk</h3>
<table class="app-table">
<thead>
<tr>
<th>Waktu</th>
<th>Barang</th>
<th>Jumlah</th>
<th>Keterangan</th>
<th>Input Oleh</th>
</tr>
</thead>
<tbody>
@forelse ($movements as $movement)
<tr>
<th>Waktu</th>
<th>Barang</th>
<th>Jumlah</th>
<th>Keterangan</th>
<th>Input Oleh</th>
<td>{{ ($movement->waktu ?? $movement->created_at)?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '—' }}
</td>
<td>{{ $movement->barang ?? $movement->product?->nama ?? '-' }}</td>
<td>{{ $movement->jumlah }}</td>
<td>{{ $movement->keterangan ?? '-' }}</td>
<td>{{ $movement->input_oleh ?? $movement->user?->name ?? '-' }}</td>
</tr>
</thead>
<tbody>
@forelse ($movements as $movement)
<tr>
<td>{{ $movement->created_at?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '—' }}</td>
<td>{{ $movement->product->nama }}</td>
<td>{{ $movement->jumlah }}</td>
<td>{{ $movement->keterangan ?? '-' }}</td>
<td>{{ $movement->user?->name ?? '-' }}</td>
</tr>
@empty
<tr>
<td colspan="5">Belum ada data barang masuk.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endsection
@empty
<tr>
<td colspan="5">Belum ada data barang masuk.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($isAdmin)
<script>
document.querySelectorAll('.category-filter').forEach((categorySelect) => {
categorySelect.addEventListener('change', () => {
const productSelect = document.getElementById(categorySelect.dataset.target);
const selectedCategory = categorySelect.value;
if (!productSelect) {
return;
}
productSelect.value = '';
productSelect.querySelectorAll('option').forEach((option) => {
if (!option.value) {
option.hidden = false;
return;
}
option.hidden = selectedCategory !== '' && option.dataset.kategori !== selectedCategory;
});
});
});
</script>
@endif
@endsection

View File

@ -4,31 +4,71 @@
@section('active_menu', 'barang')
@section('content')
<style>
.grid-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.panel {
margin-bottom: 10px;
}
.inline-form { display: grid; grid-template-columns: 60px 1fr 1fr 95px 95px 1fr auto; gap: 6px; }
.table-toolbar {
margin-bottom: 10px;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.table-toolbar label {
font-size: 13px;
color: #64748b;
font-weight: 600;
}
</style>
@php($isAdmin = auth()->user()?->role === 'admin')
@php($kategoriBarang = ['meja', 'kursi', 'lemari', 'kasur', 'sofa', 'rak', 'bufet'])
<style>
.grid-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.panel {
margin-bottom: 10px;
}
.inline-form {
display: grid;
grid-template-columns: 60px 1fr 1fr 95px 95px 1fr auto auto;
gap: 6px;
}
.action-form {
display: inline;
}
.action-button {
min-width: 72px;
height: 38px;
padding: 8px 12px;
}
.edit-row[hidden] {
display: none;
}
.edit-row td {
background: #f8fafc;
}
.button-secondary {
background: #64748b;
border-color: #64748b;
}
.table-toolbar {
margin-bottom: 10px;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.table-toolbar label {
font-size: 13px;
color: #64748b;
font-weight: 600;
}
.app-table th,
.app-table td {
font-size: 16px;
}
</style>
@if ($isAdmin)
<div class="table-card panel">
<h2>Data Barang</h2>
<form action="{{ route('products.store') }}" method="POST">
@ -36,64 +76,142 @@
<div class="grid-form">
<input type="text" name="kode" placeholder="Kode barang" value="{{ old('kode') }}" required>
<input type="text" name="nama" placeholder="Nama barang" value="{{ old('nama') }}" required>
<input type="text" name="kategori" placeholder="Kategori" value="{{ old('kategori') }}">
<input type="number" name="stok" placeholder="Stok awal" min="0" value="{{ old('stok', 0) }}" required>
<input type="number" name="stok_minimum" placeholder="Stok minimum" min="0" value="{{ old('stok_minimum', 0) }}" required>
<input type="text" name="deskripsi" placeholder="Deskripsi" value="{{ old('deskripsi') }}">
<select name="kategori">
<option value="">Pilih kategori</option>
@foreach ($kategoriBarang as $kategori)
<option value="{{ $kategori }}" @selected(old('kategori') === $kategori)>{{ ucfirst($kategori) }}</option>
@endforeach
</select>
<input type="number" name="stok" placeholder="Stok awal" min="0" value="{{ old('stok', 0) }}"
data-stock-input required>
<input type="number" name="stok_minimum" placeholder="Stok minimum" min="0" max="{{ old('stok', 0) }}"
value="{{ old('stok_minimum', 0) }}" data-minimum-stock-input required>
<input type="text" name="keterangan" placeholder="Keterangan" value="{{ old('keterangan') }}">
</div>
<button type="submit">Simpan Data Barang</button>
</form>
</div>
@endif
<div class="table-card panel">
<h3>Daftar Barang</h3>
<form method="GET" action="{{ route('products.index') }}" class="table-toolbar">
<label for="urut">Urutkan</label>
<select name="urut" id="urut" onchange="this.form.submit()">
<option value="nama_asc" @selected(($opsiUrut ?? 'nama_asc') === 'nama_asc')>Nama (A-Z)</option>
<option value="lama_baru" @selected(($opsiUrut ?? 'nama_asc') === 'lama_baru')>Data lama ke terbaru</option>
</select>
</form>
<table class="app-table">
<thead>
<tr>
<th>Kode</th>
<th>Nama</th>
<th style="padding-left: 80px;">Kategori</th>
<th>Stok</th>
<th>Stok Min</th>
<th>Deskripsi</th>
<div class="table-card panel">
<h3>Daftar Barang</h3>
<form method="GET" action="{{ route('products.index') }}" class="table-toolbar">
<label for="urut">Urutkan</label>
<select name="urut" id="urut" onchange="this.form.submit()">
<option value="nama_asc" @selected(($opsiUrut ?? 'nama_asc') === 'nama_asc')>Nama (A-Z)</option>
<option value="lama_baru" @selected(($opsiUrut ?? 'nama_asc') === 'lama_baru')>Data lama ke terbaru</option>
</select>
</form>
<table class="app-table">
<thead>
<tr>
<th>Kode</th>
<th>Nama</th>
<!-- <th style="padding-left: 80px;">Kategori</th> -->
<th>Kategori</th>
<th>Stok</th>
<th>Stok Min</th>
<th>Keterangan</th>
@if ($isAdmin)
<th colspan="2">Aksi</th>
</tr>
</thead>
<tbody>
@forelse ($products as $product)
@endif
</tr>
</thead>
<tbody>
@forelse ($products as $product)
@if ($isAdmin)
<tr>
<td colspan="8">
<div style="display:flex; align-items:start; gap:6px;">
<form action="{{ route('products.update', $product) }}" method="POST" class="inline-form" style="flex:1;">
@csrf
@method('PUT')
<input type="text" name="kode" value="{{ $product->kode }}" required>
<input type="text" name="nama" value="{{ $product->nama }}" required>
<input type="text" name="kategori" value="{{ $product->kategori }}">
<input type="number" name="stok" min="0" value="{{ $product->stok }}" required>
<input type="number" name="stok_minimum" min="0" value="{{ $product->stok_minimum }}" required>
<input type="text" name="deskripsi" value="{{ $product->deskripsi }}">
<button type="submit">Update</button>
</form>
<form action="{{ route('products.destroy', $product) }}" method="POST">
@csrf
@method('DELETE')
<button type="submit" onclick="return confirm('Hapus barang ini?')" style="background:#b91c1c; border-color:#b91c1c;">Hapus</button>
</form>
</div>
<td>{{ $product->kode }}</td>
<td>{{ $product->nama }}</td>
<td>{{ $product->kategori ?? '-' }}</td>
<td>{{ $product->stok }}</td>
<td>{{ $product->stok_minimum }}</td>
<td>{{ $product->keterangan ?? '-' }}</td>
<td>
<button type="button" class="action-button edit-product-button"
data-target="edit-product-{{ $product->id }}">Edit</button>
</td>
<td>
<form action="{{ route('products.destroy', $product) }}" method="POST" class="action-form">
@csrf
@method('DELETE')
<button type="submit" class="action-button" onclick="return confirm('Hapus barang ini?')"
style="background:#b91c1c; border-color:#b91c1c;">Hapus</button>
</form>
</td>
</tr>
@empty
<tr><td colspan="8">Belum ada data barang.</td></tr>
@endforelse
</tbody>
</table>
</div>
@endsection
<tr id="edit-product-{{ $product->id }}" class="edit-row" hidden>
<td colspan="8">
<form action="{{ route('products.update', $product) }}" method="POST" class="inline-form">
@csrf
@method('PUT')
<input type="text" name="kode" value="{{ $product->kode }}" required>
<input type="text" name="nama" value="{{ $product->nama }}" required>
<input type="text" name="kategori" value="{{ $product->kategori }}">
<input type="number" name="stok" min="0" value="{{ $product->stok }}" data-stock-input required>
<input type="number" name="stok_minimum" min="0" max="{{ $product->stok }}"
value="{{ $product->stok_minimum }}" data-minimum-stock-input required>
<input type="text" name="keterangan" value="{{ $product->keterangan }}">
<button type="submit">Update</button>
<button type="button" class="button-secondary cancel-edit-button"
data-target="edit-product-{{ $product->id }}">Batal</button>
</form>
</td>
</tr>
@else
<tr>
<td>{{ $product->kode }}</td>
<td>{{ $product->nama }}</td>
<td>{{ $product->kategori ?? '-' }}</td>
<td>{{ $product->stok }}</td>
<td>{{ $product->stok_minimum }}</td>
<td>{{ $product->keterangan ?? '-' }}</td>
</tr>
@endif
@empty
<tr>
<td colspan="{{ $isAdmin ? 8 : 6 }}">Belum ada data barang.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($isAdmin)
<script>
document.querySelectorAll('.edit-product-button, .cancel-edit-button').forEach((button) => {
button.addEventListener('click', () => {
const editRow = document.getElementById(button.dataset.target);
if (editRow) {
editRow.hidden = !editRow.hidden;
}
});
});
document.querySelectorAll('form').forEach((form) => {
const stockInput = form.querySelector('[data-stock-input]');
const minimumStockInput = form.querySelector('[data-minimum-stock-input]');
if (!stockInput || !minimumStockInput) {
return;
}
const syncMinimumStockLimit = () => {
const stock = Number.parseInt(stockInput.value || '0', 10);
const minimumStock = Number.parseInt(minimumStockInput.value || '0', 10);
minimumStockInput.max = stock;
if (minimumStock > stock) {
minimumStockInput.value = stock;
}
};
stockInput.addEventListener('input', syncMinimumStockLimit);
minimumStockInput.addEventListener('input', syncMinimumStockLimit);
syncMinimumStockLimit();
});
</script>
@endif
@endsection

View File

@ -46,10 +46,10 @@ class="export-link pdf">
<tbody>
@forelse ($data as $item)
<tr>
<td>{{ $item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '—' }}</td>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ ($item->waktu ?? $item->created_at)?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '—' }}</td>
<td>{{ $item->barang ?? $item->product->nama ?? '-' }}</td>
<td>{{ $item->jumlah }}</td>
<td>{{ $item->user->name ?? '-' }}</td>
<td>{{ $item->input_oleh ?? $item->user->name ?? '-' }}</td>
<td>{{ $item->keterangan ?? '-' }}</td>
</tr>
@empty

View File

@ -33,21 +33,21 @@ class="export-link pdf">
<table class="app-table report-table">
<thead>
<tr>
<th>Tanggal</th>
<th>Produk</th>
<th>Waktu</th>
<th>Barang</th>
<th>Jumlah</th>
<th>User</th>
<th>Keterangan</th>
<th>Input Oleh</th>
</tr>
</thead>
<tbody>
@forelse ($data as $item)
<tr>
<td>{{ $item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '—' }}</td>
<td>{{ $item->created_at?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '-' }}</td>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ $item->jumlah }}</td>
<td>{{ $item->user->name ?? '-' }}</td>
<td>{{ $item->keterangan ?? '-' }}</td>
<td>{{ $item->user->name ?? '-' }}</td>
</tr>
@empty
<tr>

View File

@ -33,21 +33,21 @@ class="export-link pdf">
<table class="app-table report-table">
<thead>
<tr>
<th>Tanggal</th>
<th>Produk</th>
<th>Waktu</th>
<th>Barang</th>
<th>Jumlah</th>
<th>User</th>
<th>Keterangan</th>
<th>Input Oleh</th>
</tr>
</thead>
<tbody>
@forelse ($data as $item)
<tr>
<td>{{ $item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '—' }}</td>
<td>{{ $item->created_at?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '-' }}</td>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ $item->jumlah }}</td>
<td>{{ $item->user->name ?? '-' }}</td>
<td>{{ $item->keterangan ?? '-' }}</td>
<td>{{ $item->user->name ?? '-' }}</td>
</tr>
@empty
<tr>

View File

@ -24,25 +24,45 @@ class="export-link pdf">
</div>
<div class="table-card">
<h2>Rekap Pembelian per Produk</h2>
<h2>Rekap Pembelian per Barang</h2>
<table class="app-table">
<thead>
<tr>
<th>Produk</th>
<th>Kode</th>
<th>Barang</th>
<th>Kategori</th>
<th>Stok Saat Ini</th>
<th>Total Qty</th>
<th>Total Transaksi</th>
<th>Aktivitas</th>
<th>Rata-rata</th>
<th>Qty Terkecil</th>
<th>Qty Terbesar</th>
<th>Aktivitas Pertama</th>
<th>Aktivitas Terakhir</th>
<th>Input Oleh</th>
<th>Keterangan</th>
</tr>
</thead>
<tbody>
@forelse ($data as $item)
<tr>
<td>{{ $item->product->kode ?? '-' }}</td>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ $item->product->kategori ?? '-' }}</td>
<td>{{ (int) ($item->product->stok ?? 0) }}</td>
<td>{{ (int) $item->total_jumlah }}</td>
<td>{{ (int) $item->total_transaksi }}</td>
<td>{{ number_format((float) $item->rata_rata, 2, ',', '.') }}</td>
<td>{{ (int) $item->jumlah_terkecil }}</td>
<td>{{ (int) $item->jumlah_terbesar }}</td>
<td>{{ $item->aktivitas_pertama?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '-' }}</td>
<td>{{ $item->aktivitas_terakhir?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '-' }}</td>
<td>{{ $item->input_oleh }}</td>
<td>{{ $item->keterangan }}</td>
</tr>
@empty
<tr>
<td colspan="3">Tidak ada data rekap pembelian.</td>
<td colspan="13">Tidak ada data rekap pembelian.</td>
</tr>
@endforelse
</tbody>

View File

@ -24,25 +24,45 @@ class="export-link pdf">
</div>
<div class="table-card">
<h2>Rekap Penjualan per Produk</h2>
<h2>Rekap Penjualan per Barang</h2>
<table class="app-table">
<thead>
<tr>
<th>Produk</th>
<th>Kode</th>
<th>Barang</th>
<th>Kategori</th>
<th>Stok Saat Ini</th>
<th>Total Qty</th>
<th>Total Transaksi</th>
<th>Aktivitas</th>
<th>Rata-rata</th>
<th>Qty Terkecil</th>
<th>Qty Terbesar</th>
<th>Aktivitas Pertama</th>
<th>Aktivitas Terakhir</th>
<th>Input Oleh</th>
<th>Keterangan</th>
</tr>
</thead>
<tbody>
@forelse ($data as $item)
<tr>
<td>{{ $item->product->kode ?? '-' }}</td>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ $item->product->kategori ?? '-' }}</td>
<td>{{ (int) ($item->product->stok ?? 0) }}</td>
<td>{{ (int) $item->total_jumlah }}</td>
<td>{{ (int) $item->total_transaksi }}</td>
<td>{{ number_format((float) $item->rata_rata, 2, ',', '.') }}</td>
<td>{{ (int) $item->jumlah_terkecil }}</td>
<td>{{ (int) $item->jumlah_terbesar }}</td>
<td>{{ $item->aktivitas_pertama?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '-' }}</td>
<td>{{ $item->aktivitas_terakhir?->timezone('Asia/Jakarta')->format('d-m-Y H:i') ?? '-' }}</td>
<td>{{ $item->input_oleh }}</td>
<td>{{ $item->keterangan }}</td>
</tr>
@empty
<tr>
<td colspan="3">Tidak ada data rekap penjualan.</td>
<td colspan="13">Tidak ada data rekap penjualan.</td>
</tr>
@endforelse
</tbody>

View File

@ -4,16 +4,53 @@
@section('active_menu', 'reports')
@section('content')
<!-- <style>
.report-table {
table-layout: fixed;
}
</style> -->
<style>
.report-table {
table-layout: fixed;
}
.summary-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.summary-card {
border: 1px solid #dbe2ea;
border-radius: 12px;
background: #fff;
padding: 16px;
min-width: 0;
}
.summary-card .summary-label {
font-size: 14px;
color: #64748b;
}
.summary-card .summary-value {
font-size: 30px;
color: #14532d;
font-weight: 800;
overflow-wrap: anywhere;
}
@media (max-width: 600px) {
.summary-cards {
grid-template-columns: 1fr;
}
}
</style>
<div class="table-card">
<h2>Filter Laporan</h2>
<form method="GET" action="{{ route('reports.stock') }}"
style="display:grid; grid-template-columns: 1fr 1fr 1fr auto auto; gap:10px; align-items:end;">
<form method="GET" action="{{ route('reports.stock') }}" class="report-filter-form">
<div>
<label for="jenis_laporan">Jenis Laporan</label>
<select id="jenis_laporan" name="jenis_laporan">
@ -33,197 +70,210 @@
<input id="tanggal_selesai" type="date" name="tanggal_selesai" value="{{ $tanggalSelesai }}">
</div>
<button type="submit">Terapkan</button>
<a href="{{ route('reports.stock') }}"
style="display:inline-block; padding:9px 10px; border-radius:7px; border:1px solid #cbd5e1; text-decoration:none; color:#1f2937; background:#fff;">
Reset
</a>
<a href="{{ route('reports.stock') }}" class="btn-link">Reset</a>
</form>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px;">
<div style="border:1px solid #dbe2ea; border-radius:12px; background:#fff; padding:16px;">
<div style="font-size:14px; color:#64748b;">Total Pembelian</div>
<div style="font-size:30px; color:#14532d; font-weight:800;">{{ $totalPembelian }}</div>
<!-- <div style="display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px;">
<div style="border:1px solid #dbe2ea; border-radius:12px; background:#fff; padding:16px;">
<div style="font-size:14px; color:#64748b;">Total Pembelian</div>
<div style="font-size:30px; color:#14532d; font-weight:800;">{{ $totalPembelian }}</div>
</div>
<div style="border:1px solid #dbe2ea; border-radius:12px; background:#fff; padding:16px;">
<div style="font-size:14px; color:#64748b;">Total Penjualan</div>
<div style="font-size:30px; color:#14532d; font-weight:800;">{{ $totalPenjualan }}</div>
</div>
</div> -->
<div class="summary-cards">
<div class="summary-card">
<div class="summary-label">Total Pembelian</div>
<div class="summary-value">{{ $totalPembelian }}</div>
</div>
<div style="border:1px solid #dbe2ea; border-radius:12px; background:#fff; padding:16px;">
<div style="font-size:14px; color:#64748b;">Total Penjualan</div>
<div style="font-size:30px; color:#14532d; font-weight:800;">{{ $totalPenjualan }}</div>
<div class="summary-card">
<div class="summary-label">Total Penjualan</div>
<div class="summary-value">{{ $totalPenjualan }}</div>
</div>
</div>
@if ($jenisLaporan === 'pembelian')
<div class="table-card">
<h2>Laporan Pembelian</h2>
<table class="app-table report-table">
<colgroup>
<col style="width: 18%;">
<col style="width: 24%;">
<col style="width: 12%;">
<col style="width: 20%;">
<col style="width: 26%;">
</colgroup>
<thead>
<tr>
<th>Tanggal</th>
<th>Produk</th>
<th>Jumlah</th>
<th>User</th>
<th>Keterangan</th>
</tr>
</thead>
<tbody>
@forelse ($pembelian as $item)
<div class="table-card">
<h2>Laporan Pembelian</h2>
<table class="app-table report-table">
<colgroup>
<col style="width: 18%;">
<col style="width: 24%;">
<col style="width: 12%;">
<col style="width: 20%;">
<col style="width: 26%;">
</colgroup>
<thead>
<tr>
<td>{{ $item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '—' }}</td>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ $item->jumlah }}</td>
<td>{{ $item->user->name ?? '-' }}</td>
<td>{{ $item->keterangan ?? '-' }}</td>
<th>Tanggal</th>
<th>Produk</th>
<th>Jumlah</th>
<th>User</th>
<th>Keterangan</th>
</tr>
@empty
<tr>
<td colspan="5">Tidak ada data pembelian.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</thead>
<tbody>
@forelse ($pembelian as $item)
<tr>
<td>{{ $item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '—' }}</td>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ $item->jumlah }}</td>
<td>{{ $item->user->name ?? '-' }}</td>
<td>{{ $item->keterangan ?? '-' }}</td>
</tr>
@empty
<tr>
<td colspan="5">Tidak ada data pembelian.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
@if ($jenisLaporan === 'penjualan')
<div class="table-card">
<h2>Laporan Penjualan</h2>
<table class="app-table report-table">
<colgroup>
<col style="width: 18%;">
<col style="width: 24%;">
<col style="width: 12%;">
<col style="width: 20%;">
<col style="width: 26%;">
</colgroup>
<thead>
<tr>
<th>Tanggal</th>
<th>Produk</th>
<th>Jumlah</th>
<th>User</th>
<th>Keterangan</th>
</tr>
</thead>
<tbody>
@forelse ($penjualan as $item)
<div class="table-card">
<h2>Laporan Penjualan</h2>
<table class="app-table report-table">
<colgroup>
<col style="width: 18%;">
<col style="width: 24%;">
<col style="width: 12%;">
<col style="width: 20%;">
<col style="width: 26%;">
</colgroup>
<thead>
<tr>
<td>{{ $item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '—' }}</td>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ $item->jumlah }}</td>
<td>{{ $item->user->name ?? '-' }}</td>
<td>{{ $item->keterangan ?? '-' }}</td>
<th>Tanggal</th>
<th>Produk</th>
<th>Jumlah</th>
<th>User</th>
<th>Keterangan</th>
</tr>
@empty
<tr>
<td colspan="5">Tidak ada data penjualan.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</thead>
<tbody>
@forelse ($penjualan as $item)
<tr>
<td>{{ $item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '—' }}</td>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ $item->jumlah }}</td>
<td>{{ $item->user->name ?? '-' }}</td>
<td>{{ $item->keterangan ?? '-' }}</td>
</tr>
@empty
<tr>
<td colspan="5">Tidak ada data penjualan.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
@if ($jenisLaporan === 'rekap_pembelian')
<div class="table-card">
<h2>Rekap Pembelian per Produk</h2>
<table class="app-table report-table">
<thead>
<tr>
<th>Produk</th>
<th>Total Qty</th>
<th>Total Transaksi</th>
</tr>
</thead>
<tbody>
@forelse ($rekapPembelian as $item)
<div class="table-card">
<h2>Rekap Pembelian per Produk</h2>
<table class="app-table report-table">
<thead>
<tr>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ (int) $item->total_jumlah }}</td>
<td>{{ (int) $item->total_transaksi }}</td>
<th>Produk</th>
<th>Total Qty</th>
<th>Total Transaksi</th>
</tr>
@empty
<tr>
<td colspan="3">Tidak ada data rekap pembelian.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</thead>
<tbody>
@forelse ($rekapPembelian as $item)
<tr>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ (int) $item->total_jumlah }}</td>
<td>{{ (int) $item->total_transaksi }}</td>
</tr>
@empty
<tr>
<td colspan="3">Tidak ada data rekap pembelian.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
@if ($jenisLaporan === 'rekap_penjualan')
<div class="table-card">
<h2>Rekap Penjualan per Produk</h2>
<table class="app-table report-table">
<thead>
<tr>
<th>Produk</th>
<th>Total Qty</th>
<th>Total Transaksi</th>
</tr>
</thead>
<tbody>
@forelse ($rekapPenjualan as $item)
<div class="table-card">
<h2>Rekap Penjualan per Produk</h2>
<table class="app-table report-table">
<thead>
<tr>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ (int) $item->total_jumlah }}</td>
<td>{{ (int) $item->total_transaksi }}</td>
<th>Produk</th>
<th>Total Qty</th>
<th>Total Transaksi</th>
</tr>
@empty
<tr>
<td colspan="3">Tidak ada data rekap penjualan.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</thead>
<tbody>
@forelse ($rekapPenjualan as $item)
<tr>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ (int) $item->total_jumlah }}</td>
<td>{{ (int) $item->total_transaksi }}</td>
</tr>
@empty
<tr>
<td colspan="3">Tidak ada data rekap penjualan.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
@if ($jenisLaporan === 'barang_hilang')
<div style="display:grid; grid-template-columns:1fr; gap:16px; margin-bottom:16px;">
<div style="border:1px solid #dbe2ea; border-radius:12px; background:#fff; padding:16px;">
<div style="font-size:14px; color:#64748b;">Total Barang Hilang</div>
<div style="font-size:30px; color:#b91c1c; font-weight:800;">{{ $totalHilang }}</div>
<!-- <div style="display:grid; grid-template-columns:1fr; gap:16px; margin-bottom:16px;">
<div style="border:1px solid #dbe2ea; border-radius:12px; background:#fff; padding:16px;">
<div style="font-size:14px; color:#64748b;">Total Barang Hilang</div>
<div style="font-size:30px; color:#b91c1c; font-weight:800;">{{ $totalHilang }}</div>
</div>
</div> -->
<div class="summary-cards" style="grid-template-columns: 1fr;">
<div class="summary-card">
<div class="summary-label">Total Barang Hilang</div>
<div class="summary-value" style="color:#b91c1c;">{{ $totalHilang }}</div>
</div>
</div>
</div>
<div class="table-card">
<h2>Laporan Barang Hilang</h2>
<div style="font-size:13px; color:#64748b; margin-bottom:10px;">
Data diambil dari transaksi barang keluar dengan keterangan mengandung kata "hilang" atau "kehilangan".
<div class="table-card">
<h2>Laporan Barang Hilang</h2>
<div style="font-size:13px; color:#64748b; margin-bottom:10px;">
Data diambil dari input barang hilang dengan keterangan mengandung kata "hilang" atau "kehilangan".
</div>
<table class="app-table report-table">
<thead>
<tr>
<th>Tanggal</th>
<th>Produk</th>
<th>Jumlah</th>
<th>User</th>
<th>Keterangan</th>
</tr>
</thead>
<tbody>
@forelse ($barangHilang as $item)
<tr>
<td>{{ $item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '—' }}</td>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ $item->jumlah }}</td>
<td>{{ $item->user->name ?? '-' }}</td>
<td>{{ $item->keterangan ?? '-' }}</td>
</tr>
@empty
<tr>
<td colspan="5">Tidak ada data barang hilang.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<table class="app-table report-table">
<thead>
<tr>
<th>Tanggal</th>
<th>Produk</th>
<th>Jumlah</th>
<th>User</th>
<th>Keterangan</th>
</tr>
</thead>
<tbody>
@forelse ($barangHilang as $item)
<tr>
<td>{{ $item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '—' }}</td>
<td>{{ $item->product->nama ?? '-' }}</td>
<td>{{ $item->jumlah }}</td>
<td>{{ $item->user->name ?? '-' }}</td>
<td>{{ $item->keterangan ?? '-' }}</td>
</tr>
@empty
<tr>
<td colspan="5">Tidak ada data barang hilang.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
@endsection
@endsection

View File

@ -47,7 +47,7 @@
Route::get('/admin/laporan/rekap-pembelian/export', [ReportController::class, 'exportRekapPembelian'])->name('reports.rekap-pembelian.export');
Route::get('/admin/laporan/rekap-penjualan/export', [ReportController::class, 'exportRekapPenjualan'])->name('reports.rekap-penjualan.export');
Route::get('/admin/laporan/barang-hilang/export', [ReportController::class, 'exportBarangHilang'])->name('reports.barang-hilang.export');
// Backward compatibility: route lama laporan barang
Route::get('/admin/laporan-barang', function () {
return redirect()->route('reports.pembelian');