first commit
This commit is contained in:
commit
6fcb4b445e
|
|
@ -0,0 +1,18 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[{compose,docker-compose}.{yml,yaml}]
|
||||
indent_size = 4
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_TIMEZONE=Asia/Jakarta
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.cursor/
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||
|
||||
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
||||
|
||||
## Agentic Development
|
||||
|
||||
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
||||
|
||||
```bash
|
||||
composer require laravel/boost --dev
|
||||
|
||||
php artisan boost:install
|
||||
```
|
||||
|
||||
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function index(): View|RedirectResponse
|
||||
{
|
||||
if (Auth::check()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if (!Auth::attempt($credentials)) {
|
||||
return back()
|
||||
->withErrors(['email' => 'Email atau password tidak valid.'])
|
||||
->onlyInput('email');
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\StockMovement;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$totalProduk = Product::count();
|
||||
$daftarNamaProduk = Product::orderBy('nama')->pluck('nama');
|
||||
$stokMenipis = Product::whereColumn('stok', '<=', 'stok_minimum')->count();
|
||||
$jumlahBarangMasuk = (int) StockMovement::kategori(StockMovement::KATEGORI_MASUK)->sum('jumlah');
|
||||
$jumlahBarangKeluar = (int) StockMovement::kategori(StockMovement::KATEGORI_KELUAR)->sum('jumlah');
|
||||
$tz = 'Asia/Jakarta';
|
||||
$kolomWaktuMutasi = StockMovement::columnDibuat();
|
||||
$awalBulanIni = Carbon::now($tz)->startOfMonth();
|
||||
$akhirBulanIni = Carbon::now($tz)->endOfMonth();
|
||||
$awalBulanLalu = Carbon::now($tz)->subMonthNoOverflow()->startOfMonth();
|
||||
$akhirBulanLalu = Carbon::now($tz)->subMonthNoOverflow()->endOfMonth();
|
||||
$jumlahBarangMasukBulanIni = (int) StockMovement::kategori(StockMovement::KATEGORI_MASUK)
|
||||
->whereBetween($kolomWaktuMutasi, [$awalBulanIni, $akhirBulanIni])
|
||||
->sum('jumlah');
|
||||
$jumlahBarangMasukBulanLalu = (int) StockMovement::kategori(StockMovement::KATEGORI_MASUK)
|
||||
->whereBetween($kolomWaktuMutasi, [$awalBulanLalu, $akhirBulanLalu])
|
||||
->sum('jumlah');
|
||||
$jumlahBarangKeluarBulanIni = (int) StockMovement::kategori(StockMovement::KATEGORI_KELUAR)
|
||||
->whereBetween($kolomWaktuMutasi, [$awalBulanIni, $akhirBulanIni])
|
||||
->sum('jumlah');
|
||||
$jumlahBarangKeluarBulanLalu = (int) StockMovement::kategori(StockMovement::KATEGORI_KELUAR)
|
||||
->whereBetween($kolomWaktuMutasi, [$awalBulanLalu, $akhirBulanLalu])
|
||||
->sum('jumlah');
|
||||
$perubahanBarangMasuk = $jumlahBarangMasukBulanIni - $jumlahBarangMasukBulanLalu;
|
||||
$perubahanBarangKeluar = $jumlahBarangKeluarBulanIni - $jumlahBarangKeluarBulanLalu;
|
||||
$labelBulanPerbandinganPenjualan = $awalBulanIni->copy()->translatedFormat('F Y');
|
||||
$produkStokMenipis = Product::whereColumn('stok', '<=', 'stok_minimum')
|
||||
->orderBy('stok')
|
||||
->get();
|
||||
$aktivitasTerbaru = StockMovement::with('product')
|
||||
->latest(StockMovement::columnDibuat())
|
||||
->limit(6)
|
||||
->get();
|
||||
$stokAman = max(0, $totalProduk - $stokMenipis);
|
||||
$persentaseStokAman = $totalProduk > 0 ? (int) round(($stokAman / $totalProduk) * 100) : 100;
|
||||
$topPergerakanProduk = StockMovement::with('product')
|
||||
->selectRaw('id_barang, SUM(jumlah) as total')
|
||||
->groupBy('id_barang')
|
||||
->orderByDesc('total')
|
||||
->limit(5)
|
||||
->get();
|
||||
$perbandinganProdukTerjual = StockMovement::with('product')
|
||||
->selectRaw('id_barang, SUM(jumlah) as total_terjual')
|
||||
->kategori(StockMovement::KATEGORI_KELUAR)
|
||||
->whereBetween($kolomWaktuMutasi, [$awalBulanIni, $akhirBulanIni])
|
||||
->groupBy('id_barang')
|
||||
->havingRaw('SUM(jumlah) > 0')
|
||||
->orderByDesc('total_terjual')
|
||||
->get();
|
||||
$produkTercepatTerjual = $perbandinganProdukTerjual->first();
|
||||
$produkTerlambatTerjual = $perbandinganProdukTerjual->sortBy('total_terjual')->first();
|
||||
$selisihProdukTerjual = ($produkTercepatTerjual && $produkTerlambatTerjual)
|
||||
? (int) $produkTercepatTerjual->total_terjual - (int) $produkTerlambatTerjual->total_terjual
|
||||
: 0;
|
||||
$batasSelisihProdukMirip = 5;
|
||||
$produkCepatTerjualLainnya = collect();
|
||||
$produkLambatTerjualLainnya = collect();
|
||||
if ($produkTercepatTerjual) {
|
||||
$totalTercepat = (int) $produkTercepatTerjual->total_terjual;
|
||||
$produkCepatTerjualLainnya = $perbandinganProdukTerjual
|
||||
->filter(function ($item) use ($produkTercepatTerjual, $totalTercepat, $batasSelisihProdukMirip): bool {
|
||||
return $item->id_barang !== $produkTercepatTerjual->id_barang
|
||||
&& ((int) $item->total_terjual >= $totalTercepat - $batasSelisihProdukMirip);
|
||||
})
|
||||
->take(4)
|
||||
->values();
|
||||
}
|
||||
if ($produkTerlambatTerjual) {
|
||||
$totalTerlambat = (int) $produkTerlambatTerjual->total_terjual;
|
||||
$produkLambatTerjualLainnya = $perbandinganProdukTerjual
|
||||
->sortBy('total_terjual')
|
||||
->filter(function ($item) use ($produkTerlambatTerjual, $totalTerlambat, $batasSelisihProdukMirip): bool {
|
||||
return $item->id_barang !== $produkTerlambatTerjual->id_barang
|
||||
&& ((int) $item->total_terjual <= $totalTerlambat + $batasSelisihProdukMirip);
|
||||
})
|
||||
->take(4)
|
||||
->values();
|
||||
}
|
||||
$tahunDipilih = (int) $request->input('tahun', Carbon::now('Asia/Jakarta')->year);
|
||||
$jenisData = $request->input('jenis_data', 'semua');
|
||||
if (!in_array($jenisData, ['semua', 'masuk', 'keluar'], true)) {
|
||||
$jenisData = 'semua';
|
||||
}
|
||||
$dataPertama = StockMovement::oldest($kolomWaktuMutasi)->value($kolomWaktuMutasi);
|
||||
$dataTerakhir = StockMovement::latest($kolomWaktuMutasi)->value($kolomWaktuMutasi);
|
||||
$tahunAwal = $dataPertama ? Carbon::parse($dataPertama)->timezone('Asia/Jakarta')->year : $tahunDipilih;
|
||||
$tahunAkhir = $dataTerakhir ? Carbon::parse($dataTerakhir)->timezone('Asia/Jakarta')->year : $tahunDipilih;
|
||||
$daftarTahun = collect(range($tahunAkhir, $tahunAwal));
|
||||
|
||||
if ($daftarTahun->isEmpty()) {
|
||||
$daftarTahun = collect([$tahunDipilih]);
|
||||
} elseif (!$daftarTahun->contains($tahunDipilih)) {
|
||||
$daftarTahun = $daftarTahun->push($tahunDipilih)->sortDesc()->values();
|
||||
}
|
||||
|
||||
$labelBulan = [1 => 'Jan', 2 => 'Feb', 3 => 'Mar', 4 => 'Apr', 5 => 'Mei', 6 => 'Jun', 7 => 'Jul', 8 => 'Agu', 9 => 'Sep', 10 => 'Okt', 11 => 'Nov', 12 => 'Des'];
|
||||
$trenTahunan = collect(range(1, 12))->map(function (int $bulan) use ($tahunDipilih, $labelBulan, $tz, $kolomWaktuMutasi) {
|
||||
$mulaiBulan = Carbon::create($tahunDipilih, $bulan, 1, 0, 0, 0, $tz)->startOfMonth();
|
||||
$akhirBulan = Carbon::create($tahunDipilih, $bulan, 1, 0, 0, 0, $tz)->endOfMonth();
|
||||
|
||||
return [
|
||||
'label' => $labelBulan[$bulan],
|
||||
'masuk' => (int) StockMovement::kategori(StockMovement::KATEGORI_MASUK)
|
||||
->whereBetween($kolomWaktuMutasi, [$mulaiBulan, $akhirBulan])
|
||||
->sum('jumlah'),
|
||||
'keluar' => (int) StockMovement::kategori(StockMovement::KATEGORI_KELUAR)
|
||||
->whereBetween($kolomWaktuMutasi, [$mulaiBulan, $akhirBulan])
|
||||
->sum('jumlah'),
|
||||
];
|
||||
});
|
||||
$totalMasukTahunan = (int) $trenTahunan->sum('masuk');
|
||||
$totalKeluarTahunan = (int) $trenTahunan->sum('keluar');
|
||||
$totalArusTahunan = $totalMasukTahunan + $totalKeluarTahunan;
|
||||
if ($totalArusTahunan === 0) {
|
||||
$persenMasukTahunan = 0;
|
||||
$persenKeluarTahunan = 0;
|
||||
} else {
|
||||
$persenMasukTahunan = (int) round(($totalMasukTahunan / $totalArusTahunan) * 100);
|
||||
$persenKeluarTahunan = 100 - $persenMasukTahunan;
|
||||
}
|
||||
|
||||
$nilaiMasukPerBulan = $trenTahunan->pluck('masuk')->all();
|
||||
$nilaiKeluarPerBulan = $trenTahunan->pluck('keluar')->all();
|
||||
|
||||
|
||||
$pctMasukKontribTahunan = $totalMasukTahunan > 0
|
||||
? self::distribusiPersenProporsionalTerhadapTarget($nilaiMasukPerBulan, $totalMasukTahunan, $persenMasukTahunan)
|
||||
: array_fill(0, 12, null);
|
||||
$pctKeluarKontribTahunan = $totalKeluarTahunan > 0
|
||||
? self::distribusiPersenProporsionalTerhadapTarget($nilaiKeluarPerBulan, $totalKeluarTahunan, $persenKeluarTahunan)
|
||||
: array_fill(0, 12, null);
|
||||
|
||||
$maksTrenTahunan = match ($jenisData) {
|
||||
'masuk' => max(1, (int) $trenTahunan->max('masuk')),
|
||||
'keluar' => max(1, (int) $trenTahunan->max('keluar')),
|
||||
default => max(1, (int) $trenTahunan->max(fn($item) => max($item['masuk'], $item['keluar']))),
|
||||
};
|
||||
|
||||
return view('dashboard', [
|
||||
'user' => Auth::user(),
|
||||
'totalProduk' => $totalProduk,
|
||||
'daftarNamaProduk' => $daftarNamaProduk,
|
||||
'stokMenipis' => $stokMenipis,
|
||||
'jumlahBarangMasuk' => $jumlahBarangMasuk,
|
||||
'jumlahBarangKeluar' => $jumlahBarangKeluar,
|
||||
'jumlahBarangMasukBulanLalu' => $jumlahBarangMasukBulanLalu,
|
||||
'jumlahBarangKeluarBulanLalu' => $jumlahBarangKeluarBulanLalu,
|
||||
'perubahanBarangMasuk' => $perubahanBarangMasuk,
|
||||
'perubahanBarangKeluar' => $perubahanBarangKeluar,
|
||||
'produkStokMenipis' => $produkStokMenipis,
|
||||
'aktivitasTerbaru' => $aktivitasTerbaru,
|
||||
'persentaseStokAman' => $persentaseStokAman,
|
||||
'topPergerakanProduk' => $topPergerakanProduk,
|
||||
'produkTercepatTerjual' => $produkTercepatTerjual,
|
||||
'produkTerlambatTerjual' => $produkTerlambatTerjual,
|
||||
'produkCepatTerjualLainnya' => $produkCepatTerjualLainnya,
|
||||
'produkLambatTerjualLainnya' => $produkLambatTerjualLainnya,
|
||||
'batasSelisihProdukMirip' => $batasSelisihProdukMirip,
|
||||
'selisihProdukTerjual' => $selisihProdukTerjual,
|
||||
'labelBulanPerbandinganPenjualan' => $labelBulanPerbandinganPenjualan,
|
||||
'tahunDipilih' => $tahunDipilih,
|
||||
'daftarTahun' => $daftarTahun,
|
||||
'trenTahunan' => $trenTahunan,
|
||||
'maksTrenTahunan' => $maksTrenTahunan,
|
||||
'totalMasukTahunan' => $totalMasukTahunan,
|
||||
'totalKeluarTahunan' => $totalKeluarTahunan,
|
||||
'jenisData' => $jenisData,
|
||||
'persenMasukTahunan' => $persenMasukTahunan,
|
||||
'persenKeluarTahunan' => $persenKeluarTahunan,
|
||||
'pctMasukKontribTahunan' => $pctMasukKontribTahunan,
|
||||
'pctKeluarKontribTahunan' => $pctKeluarKontribTahunan,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bagi angka pembulatan $targetPersen ke tiap kolom secara proporsional terhadap
|
||||
* total jenis datanya ($totalJenis) agar jumlah hasil tepat sama dengan $targetPersen.
|
||||
*
|
||||
* @param array<int, int|float> $nilaiPerBulan
|
||||
* @return array<int, int|null>|array<int, int>
|
||||
*/
|
||||
private static function distribusiPersenProporsionalTerhadapTarget(array $nilaiPerBulan, int $totalJenis, int $targetPersen): array
|
||||
{
|
||||
if ($totalJenis <= 0) {
|
||||
return array_map(fn() => null, $nilaiPerBulan);
|
||||
}
|
||||
|
||||
if ($targetPersen <= 0) {
|
||||
return array_map(fn() => 0, $nilaiPerBulan);
|
||||
}
|
||||
|
||||
$raw = array_map(fn($v) => ((float) $v / $totalJenis) * $targetPersen, $nilaiPerBulan);
|
||||
$out = array_map(fn($v) => (int) floor($v), $raw);
|
||||
$sisa = $targetPersen - array_sum($out);
|
||||
$urutan = array_keys($nilaiPerBulan);
|
||||
usort($urutan, function (int $a, int $b) use ($raw): int {
|
||||
$fa = $raw[$a] - floor($raw[$a]);
|
||||
$fb = $raw[$b] - floor($raw[$b]);
|
||||
|
||||
return $fb <=> $fa;
|
||||
});
|
||||
$jumlahKolom = count($urutan);
|
||||
for ($i = 0; $i < $sisa; $i++) {
|
||||
$out[$urutan[$i % $jumlahKolom]]++;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$opsiUrut = $request->input('urut', 'nama_asc');
|
||||
$query = Product::query();
|
||||
|
||||
if ($opsiUrut === 'lama_baru') {
|
||||
$query->orderBy(Product::columnDibuat(), 'asc')->orderBy('id');
|
||||
} else {
|
||||
$opsiUrut = 'nama_asc';
|
||||
$query->orderBy('nama')->orderBy('id');
|
||||
}
|
||||
|
||||
return view('products.index', [
|
||||
'products' => $query->get(),
|
||||
'opsiUrut' => $opsiUrut,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$validated = $request->validate([
|
||||
'kode' => ['required', 'string', 'max:50', 'unique:barang,kode'],
|
||||
'nama' => ['required', 'string', 'max:255'],
|
||||
'kategori' => ['nullable', 'string', 'max:100'],
|
||||
'stok' => ['required', 'integer', 'min:0'],
|
||||
'stok_minimum' => ['required', 'integer', 'min:0'],
|
||||
'deskripsi' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
Product::create($validated);
|
||||
|
||||
return redirect()->route('products.index')->with('success', 'Barang berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
public function update(Request $request, Product $product): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$validated = $request->validate([
|
||||
'kode' => ['required', 'string', 'max:50', 'unique:barang,kode,' . $product->id],
|
||||
'nama' => ['required', 'string', 'max:255'],
|
||||
'kategori' => ['nullable', 'string', 'max:100'],
|
||||
'stok' => ['required', 'integer', 'min:0'],
|
||||
'stok_minimum' => ['required', 'integer', 'min:0'],
|
||||
'deskripsi' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$product->update($validated);
|
||||
|
||||
return redirect()->route('products.index')->with('success', 'Barang berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Product $product): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$product->delete();
|
||||
|
||||
return redirect()->route('products.index')->with('success', 'Barang berhasil dihapus.');
|
||||
}
|
||||
|
||||
private function ensureAdmin(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->role === 'admin', 403);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,531 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\StockMovement;
|
||||
use Carbon\Carbon;
|
||||
use Dompdf\Dompdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xls;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
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();
|
||||
|
||||
return view('reports.pembelian', [
|
||||
'tanggalMulai' => $tanggalMulai,
|
||||
'tanggalSelesai' => $tanggalSelesai,
|
||||
'data' => $data,
|
||||
'total' => (int) $data->sum('jumlah'),
|
||||
]);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return view('reports.penjualan', [
|
||||
'tanggalMulai' => $tanggalMulai,
|
||||
'tanggalSelesai' => $tanggalSelesai,
|
||||
'data' => $data,
|
||||
'total' => (int) $data->sum('jumlah'),
|
||||
]);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return view('reports.rekap-pembelian', [
|
||||
'tanggalMulai' => $tanggalMulai,
|
||||
'tanggalSelesai' => $tanggalSelesai,
|
||||
'data' => $data,
|
||||
'total' => (int) $data->sum('total_jumlah'),
|
||||
]);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return view('reports.rekap-penjualan', [
|
||||
'tanggalMulai' => $tanggalMulai,
|
||||
'tanggalSelesai' => $tanggalSelesai,
|
||||
'data' => $data,
|
||||
'total' => (int) $data->sum('total_jumlah'),
|
||||
]);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return view('reports.barang-hilang', [
|
||||
'tanggalMulai' => $tanggalMulai,
|
||||
'tanggalSelesai' => $tanggalSelesai,
|
||||
'data' => $data,
|
||||
'total' => (int) $data->sum('jumlah'),
|
||||
]);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return $this->exportReport(
|
||||
$request,
|
||||
'Laporan Pembelian',
|
||||
$data,
|
||||
['Tanggal', 'Produk', 'Jumlah', 'User', 'Keterangan'],
|
||||
fn ($item) => [
|
||||
$item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '-',
|
||||
$item->product->nama ?? '-',
|
||||
$item->jumlah,
|
||||
$item->user->name ?? '-',
|
||||
$item->keterangan ?? '-',
|
||||
],
|
||||
'laporan-pembelian-' . now()->format('YmdHis')
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return $this->exportReport(
|
||||
$request,
|
||||
'Laporan Penjualan',
|
||||
$data,
|
||||
['Tanggal', 'Produk', 'Jumlah', 'User', 'Keterangan'],
|
||||
fn ($item) => [
|
||||
$item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '-',
|
||||
$item->product->nama ?? '-',
|
||||
$item->jumlah,
|
||||
$item->user->name ?? '-',
|
||||
$item->keterangan ?? '-',
|
||||
],
|
||||
'laporan-penjualan-' . now()->format('YmdHis')
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return $this->exportReport(
|
||||
$request,
|
||||
'Rekap Pembelian',
|
||||
$data,
|
||||
['Produk', 'Total Qty', 'Total Transaksi'],
|
||||
fn ($item) => [
|
||||
$item->product->nama ?? '-',
|
||||
(int) $item->total_jumlah,
|
||||
(int) $item->total_transaksi,
|
||||
],
|
||||
'rekap-pembelian-' . now()->format('YmdHis')
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return $this->exportReport(
|
||||
$request,
|
||||
'Rekap Penjualan',
|
||||
$data,
|
||||
['Produk', 'Total Qty', 'Total Transaksi'],
|
||||
fn ($item) => [
|
||||
$item->product->nama ?? '-',
|
||||
(int) $item->total_jumlah,
|
||||
(int) $item->total_transaksi,
|
||||
],
|
||||
'rekap-penjualan-' . now()->format('YmdHis')
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return $this->exportReport(
|
||||
$request,
|
||||
'Laporan Barang Hilang',
|
||||
$data,
|
||||
['Tanggal', 'Produk', 'Jumlah', 'User', 'Keterangan'],
|
||||
fn ($item) => [
|
||||
$item->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '-',
|
||||
$item->product->nama ?? '-',
|
||||
$item->jumlah,
|
||||
$item->user->name ?? '-',
|
||||
$item->keterangan ?? '-',
|
||||
],
|
||||
'barang-hilang-' . now()->format('YmdHis')
|
||||
);
|
||||
}
|
||||
|
||||
private function exportReport(Request $request, string $title, iterable $data, array $columns, callable $rowMapper, string $filenameBase)
|
||||
{
|
||||
$rows = collect($data)->map($rowMapper)->toArray();
|
||||
|
||||
if ($request->input('format') === 'pdf') {
|
||||
return $this->exportPdf($title, $filenameBase . '.pdf', $columns, $rows, [
|
||||
'tanggalMulai' => $request->input('tanggal_mulai'),
|
||||
'tanggalSelesai' => $request->input('tanggal_selesai'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->exportExcel($title, $filenameBase . '.xls', $columns, $rows);
|
||||
}
|
||||
|
||||
private function exportExcel(string $title, string $filename, array $columns, array $rows)
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Laporan');
|
||||
|
||||
$lastColumn = Coordinate::stringFromColumnIndex(max(1, count($columns)));
|
||||
$sheet->mergeCells("A1:{$lastColumn}1");
|
||||
$sheet->setCellValue('A1', $title);
|
||||
$sheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
||||
$sheet->getStyle('A1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
|
||||
foreach ($columns as $index => $column) {
|
||||
$sheet->setCellValueByColumnAndRow($index + 1, 3, $column);
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$sheet->mergeCells("A4:{$lastColumn}4");
|
||||
$sheet->setCellValue('A4', 'Tidak ada data.');
|
||||
} else {
|
||||
foreach ($rows as $rowIndex => $row) {
|
||||
foreach ($columns as $columnIndex => $column) {
|
||||
$sheet->setCellValueByColumnAndRow(
|
||||
$columnIndex + 1,
|
||||
$rowIndex + 4,
|
||||
$row[$columnIndex] ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$lastRow = max(4, count($rows) + 3);
|
||||
$tableRange = "A3:{$lastColumn}{$lastRow}";
|
||||
|
||||
$sheet->getStyle("A3:{$lastColumn}3")->applyFromArray([
|
||||
'font' => ['bold' => true],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => 'E8EEF6'],
|
||||
],
|
||||
'alignment' => [
|
||||
'horizontal' => Alignment::HORIZONTAL_CENTER,
|
||||
'vertical' => Alignment::VERTICAL_CENTER,
|
||||
],
|
||||
]);
|
||||
|
||||
$sheet->getStyle($tableRange)->applyFromArray([
|
||||
'borders' => [
|
||||
'allBorders' => [
|
||||
'borderStyle' => Border::BORDER_THIN,
|
||||
'color' => ['rgb' => 'B9C0C9'],
|
||||
],
|
||||
],
|
||||
'alignment' => [
|
||||
'vertical' => Alignment::VERTICAL_TOP,
|
||||
'wrapText' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
foreach (range(1, count($columns)) as $columnIndex) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($columnIndex))->setAutoSize(true);
|
||||
}
|
||||
|
||||
$sheet->freezePane('A4');
|
||||
|
||||
ob_start();
|
||||
(new Xls($spreadsheet))->save('php://output');
|
||||
$content = ob_get_clean();
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'application/vnd.ms-excel',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
'Cache-Control' => 'max-age=0',
|
||||
]);
|
||||
}
|
||||
|
||||
private function exportPdf(string $title, string $filename, array $columns, array $rows, array $filters)
|
||||
{
|
||||
$period = ($filters['tanggalMulai'] || $filters['tanggalSelesai'])
|
||||
? ($filters['tanggalMulai'] ?: '-') . ' s/d ' . ($filters['tanggalSelesai'] ?: '-')
|
||||
: 'Semua tanggal';
|
||||
|
||||
$html = view('reports.exports.table', [
|
||||
'title' => $title,
|
||||
'period' => $period,
|
||||
'tanggalMulai' => $filters['tanggalMulai'],
|
||||
'tanggalSelesai' => $filters['tanggalSelesai'],
|
||||
'printedAt' => now('Asia/Jakarta')->format('d/m/Y H:i'),
|
||||
'columns' => $columns,
|
||||
'rows' => $rows,
|
||||
])->render();
|
||||
|
||||
try {
|
||||
$pdf = new Dompdf();
|
||||
$pdf->set_option('defaultFont', 'Arial');
|
||||
$pdf->set_option('isHtml5ParserEnabled', true);
|
||||
$pdf->set_option('isRemoteEnabled', true);
|
||||
$pdf->loadHtml($html);
|
||||
$pdf->setPaper('A4', 'landscape');
|
||||
$pdf->render();
|
||||
|
||||
$output = $pdf->output();
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
$output = $this->buildFallbackPdf($title, $period, $columns, $rows);
|
||||
}
|
||||
|
||||
return response($output, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildFallbackPdf(string $title, string $period, array $columns, array $rows): string
|
||||
{
|
||||
if ($rows === []) {
|
||||
$rows = [['Tidak ada data.']];
|
||||
}
|
||||
|
||||
$pageWidth = 842;
|
||||
$pageHeight = 595;
|
||||
$margin = 45;
|
||||
$tableTop = 470;
|
||||
$rowHeight = 28;
|
||||
$headerHeight = 30;
|
||||
$rowsPerPage = 14;
|
||||
$columnWidths = match (count($columns)) {
|
||||
3 => [360, 170, 222],
|
||||
5 => [135, 180, 75, 110, 252],
|
||||
default => array_fill(0, max(1, count($columns)), floor(($pageWidth - ($margin * 2)) / max(1, count($columns)))),
|
||||
};
|
||||
$pages = array_chunk($rows, $rowsPerPage);
|
||||
$objects = [];
|
||||
$pageObjectIds = [];
|
||||
$nextObjectId = 5;
|
||||
|
||||
foreach ($pages as $pageIndex => $pageRows) {
|
||||
$content = '';
|
||||
$content .= $this->fallbackPdfText(strtoupper($title), $margin, 545, 15, true);
|
||||
$content .= $this->fallbackPdfText('Periode: ' . $period, $margin, 524, 10);
|
||||
$content .= $this->fallbackPdfText('Dicetak: ' . now('Asia/Jakarta')->format('d/m/Y H:i'), $margin, 508, 9);
|
||||
|
||||
$x = $margin;
|
||||
foreach ($columns as $index => $column) {
|
||||
$width = $columnWidths[$index] ?? end($columnWidths);
|
||||
$content .= $this->fallbackPdfCell($x, $tableTop, $width, $headerHeight, (string) $column, true);
|
||||
$x += $width;
|
||||
}
|
||||
|
||||
$y = $tableTop - $rowHeight;
|
||||
foreach ($pageRows as $row) {
|
||||
$x = $margin;
|
||||
foreach ($columns as $index => $column) {
|
||||
$width = $columnWidths[$index] ?? end($columnWidths);
|
||||
$content .= $this->fallbackPdfCell($x, $y, $width, $rowHeight, (string) ($row[$index] ?? ''));
|
||||
$x += $width;
|
||||
}
|
||||
$y -= $rowHeight;
|
||||
}
|
||||
|
||||
$content .= $this->fallbackPdfText('Halaman ' . ($pageIndex + 1) . ' dari ' . count($pages), $pageWidth - 125, 32, 8);
|
||||
|
||||
$contentObjectId = $nextObjectId++;
|
||||
$objects[$contentObjectId] = "<< /Length " . strlen($content) . " >>\nstream\n" . $content . "\nendstream";
|
||||
|
||||
$pageObjectId = $nextObjectId++;
|
||||
$pageObjectIds[] = $pageObjectId;
|
||||
$objects[$pageObjectId] = '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' . $pageWidth . ' ' . $pageHeight . '] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ' . $contentObjectId . ' 0 R >>';
|
||||
}
|
||||
|
||||
$kids = implode(' ', array_map(fn ($id) => $id . ' 0 R', $pageObjectIds));
|
||||
$objects = [
|
||||
1 => '<< /Type /Catalog /Pages 2 0 R >>',
|
||||
2 => '<< /Type /Pages /Kids [' . $kids . '] /Count ' . count($pageObjectIds) . ' >>',
|
||||
3 => '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
4 => '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>',
|
||||
] + $objects;
|
||||
ksort($objects);
|
||||
|
||||
$pdf = "%PDF-1.4\n";
|
||||
$offsets = [0];
|
||||
|
||||
foreach ($objects as $id => $object) {
|
||||
$offsets[$id] = strlen($pdf);
|
||||
$pdf .= $id . " 0 obj\n" . $object . "\nendobj\n";
|
||||
}
|
||||
|
||||
$xrefOffset = strlen($pdf);
|
||||
$pdf .= "xref\n0 " . (count($objects) + 1) . "\n0000000000 65535 f \n";
|
||||
|
||||
for ($i = 1; $i <= count($objects); $i++) {
|
||||
$pdf .= sprintf("%010d 00000 n \n", $offsets[$i]);
|
||||
}
|
||||
|
||||
return $pdf . "trailer\n<< /Size " . (count($objects) + 1) . " /Root 1 0 R >>\nstartxref\n" . $xrefOffset . "\n%%EOF";
|
||||
}
|
||||
|
||||
private function fallbackPdfCell(float $x, float $y, float $width, float $height, string $text, bool $header = false): string
|
||||
{
|
||||
$fontSize = $header ? 9 : 8.5;
|
||||
$maxChars = max(6, (int) floor(($width - 12) / ($fontSize * 0.55)));
|
||||
$text = strlen($text) > $maxChars ? substr($text, 0, $maxChars - 3) . '...' : $text;
|
||||
|
||||
$content = $header ? "0.90 0.94 0.98 rg\n{$x} {$y} {$width} {$height} re f\n" : '';
|
||||
$content .= "0.25 0.30 0.36 RG\n0.6 w\n{$x} {$y} {$width} {$height} re S\n";
|
||||
$content .= $this->fallbackPdfText($text, $x + 6, $y + (($height - $fontSize) / 2), $fontSize, $header);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function fallbackPdfText(string $text, float $x, float $y, float $fontSize = 10, bool $bold = false): string
|
||||
{
|
||||
$font = $bold ? 'F2' : 'F1';
|
||||
$text = str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $text);
|
||||
|
||||
return "0 0 0 rg\nBT\n/{$font} {$fontSize} Tf\n{$x} {$y} Td\n({$text}) Tj\nET\n";
|
||||
}
|
||||
|
||||
private function resolveTanggal(Request $request): array
|
||||
{
|
||||
$tanggalMulai = $request->input('tanggal_mulai');
|
||||
$tanggalSelesai = $request->input('tanggal_selesai');
|
||||
|
||||
if ($tanggalMulai && !$tanggalSelesai) {
|
||||
$tanggalSelesai = $tanggalMulai;
|
||||
}
|
||||
|
||||
return [$tanggalMulai, $tanggalSelesai];
|
||||
}
|
||||
|
||||
/**
|
||||
* Batas tanggal cocok dengan jam di web (hari penuh WIB), tanpa menyimpan sebagai UTC lain.
|
||||
*/
|
||||
private function mulaiHariDiJakarta(string $tanggal): Carbon
|
||||
{
|
||||
return Carbon::parse($tanggal, 'Asia/Jakarta')->startOfDay();
|
||||
}
|
||||
|
||||
private function akhirHariDiJakarta(string $tanggal): Carbon
|
||||
{
|
||||
return Carbon::parse($tanggal, 'Asia/Jakarta')->endOfDay();
|
||||
}
|
||||
|
||||
private function kolomWaktuMutasi(): string
|
||||
{
|
||||
return StockMovement::columnDibuat();
|
||||
}
|
||||
|
||||
private function ensureReportAccess(): void
|
||||
{
|
||||
abort_unless(in_array(auth()->user()?->role, ['admin', 'owner'], true), 403);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\StockMovement;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class StockMovementController extends Controller
|
||||
{
|
||||
// public function incoming(): View
|
||||
// {
|
||||
// $this->ensureAdmin();
|
||||
|
||||
// return view('mutasi-stok.barang-masuk', [
|
||||
// 'products' => Product::orderBy('nama')->get(),
|
||||
// 'movements' => StockMovement::with(['product', 'user'])
|
||||
// ->where('tipe', 'masuk')
|
||||
// ->latest(StockMovement::columnDibuat())
|
||||
// ->limit(50)
|
||||
// ->get(),
|
||||
// ]);
|
||||
// }
|
||||
|
||||
public function incoming(Request $request): View
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$query = StockMovement::with(['product', 'user'])
|
||||
->kategori(StockMovement::KATEGORI_MASUK)
|
||||
->latest(StockMovement::columnDibuat());
|
||||
|
||||
if ($request->filled('tanggal_mulai')) {
|
||||
$query->whereDate(StockMovement::columnDibuat(), '>=', $request->tanggal_mulai);
|
||||
}
|
||||
|
||||
if ($request->filled('tanggal_selesai')) {
|
||||
$query->whereDate(StockMovement::columnDibuat(), '<=', $request->tanggal_selesai);
|
||||
}
|
||||
|
||||
if (!$request->filled('tanggal_mulai') && !$request->filled('tanggal_selesai')) {
|
||||
$query->limit(50);
|
||||
}
|
||||
|
||||
return view('mutasi-stok.barang-masuk', [
|
||||
'products' => Product::orderBy('nama')->get(),
|
||||
'movements' => $query->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
// public function outgoing(): View
|
||||
// {
|
||||
// $this->ensureAdmin();
|
||||
|
||||
// return view('mutasi-stok.barang-keluar', [
|
||||
// 'products' => Product::orderBy('nama')->get(),
|
||||
// 'movements' => StockMovement::with(['product', 'user'])
|
||||
// ->where('tipe', 'keluar')
|
||||
// ->latest(StockMovement::columnDibuat())
|
||||
// ->limit(50)
|
||||
// ->get(),
|
||||
// ]);
|
||||
// }
|
||||
|
||||
public function outgoing(Request $request): View
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$query = StockMovement::with(['product', 'user'])
|
||||
->kategori(StockMovement::KATEGORI_KELUAR)
|
||||
->latest(StockMovement::columnDibuat());
|
||||
|
||||
if ($request->filled('tanggal_mulai')) {
|
||||
$query->whereDate(StockMovement::columnDibuat(), '>=', $request->tanggal_mulai);
|
||||
}
|
||||
|
||||
if ($request->filled('tanggal_selesai')) {
|
||||
$query->whereDate(StockMovement::columnDibuat(), '<=', $request->tanggal_selesai);
|
||||
}
|
||||
|
||||
if (!$request->filled('tanggal_mulai') && !$request->filled('tanggal_selesai')) {
|
||||
$query->limit(50);
|
||||
}
|
||||
|
||||
return view('mutasi-stok.barang-keluar', [
|
||||
'products' => Product::orderBy('nama')->get(),
|
||||
'movements' => $query->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function lost(): View
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
return view('mutasi-stok.barang-hilang', [
|
||||
'products' => Product::orderBy('nama')->get(),
|
||||
'movements' => StockMovement::with(['product', 'user'])
|
||||
->kategori(StockMovement::KATEGORI_HILANG)
|
||||
->latest(StockMovement::columnDibuat())
|
||||
->limit(50)
|
||||
->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeIncoming(Request $request): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$this->saveMovement($request, 'masuk');
|
||||
|
||||
return redirect()->route('stock-movements.incoming')->with('success', 'Barang masuk berhasil disimpan.');
|
||||
}
|
||||
|
||||
public function storeOutgoing(Request $request): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$this->saveMovement($request, 'keluar');
|
||||
|
||||
return redirect()->route('stock-movements.outgoing')->with('success', 'Barang keluar berhasil disimpan.');
|
||||
}
|
||||
|
||||
public function storeLost(Request $request): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$this->saveMovement($request, 'keluar', true);
|
||||
|
||||
return redirect()->route('stock-movements.lost')->with('success', 'Input barang hilang berhasil disimpan.');
|
||||
}
|
||||
|
||||
private function saveMovement(Request $request, string $type, bool $isLost = false): void
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'id_barang' => ['required', 'exists:barang,id'],
|
||||
'jumlah' => ['required', 'integer', 'min:1'],
|
||||
'keterangan' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated, $type, $isLost): void {
|
||||
$product = Product::lockForUpdate()->findOrFail($validated['id_barang']);
|
||||
$newStock = $type === 'masuk'
|
||||
? $product->stok + $validated['jumlah']
|
||||
: $product->stok - $validated['jumlah'];
|
||||
|
||||
if ($newStock < 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'jumlah' => 'Stok tidak mencukupi untuk transaksi keluar.',
|
||||
]);
|
||||
}
|
||||
|
||||
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),
|
||||
'id_pengguna' => Auth::id(),
|
||||
]);
|
||||
|
||||
$product->update(['stok' => $newStock]);
|
||||
});
|
||||
}
|
||||
|
||||
private function ensureAdmin(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->role === 'admin', 403);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserManagementController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
return view('users.index', [
|
||||
'users' => User::orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'string', 'min:6'],
|
||||
'role' => ['required', 'in:admin,owner'],
|
||||
]);
|
||||
|
||||
User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make($validated['password']),
|
||||
'role' => $validated['role'],
|
||||
]);
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'User baru berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email,' . $user->id],
|
||||
'role' => ['required', 'in:admin,owner'],
|
||||
'password' => ['nullable', 'string', 'min:6'],
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'role' => $validated['role'],
|
||||
];
|
||||
|
||||
if (!empty($validated['password'])) {
|
||||
$payload['password'] = Hash::make($validated['password']);
|
||||
}
|
||||
|
||||
$user->update($payload);
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'Data user berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(User $user): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
if ((int) $user->id === (int) auth()->id()) {
|
||||
return redirect()->route('users.index')->withErrors(['error' => 'Akun yang sedang login tidak bisa dihapus.']);
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'User berhasil dihapus.');
|
||||
}
|
||||
|
||||
private function ensureAdmin(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->role === 'admin', 403);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class KategoriBarang extends Model
|
||||
{
|
||||
protected $table = 'kategori_barang';
|
||||
|
||||
protected $fillable = [
|
||||
'nama',
|
||||
'keterangan_ringkas',
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'barang';
|
||||
|
||||
public const CREATED_AT = 'dibuat_pada';
|
||||
|
||||
public const UPDATED_AT = 'diperbarui_pada';
|
||||
|
||||
protected $fillable = [
|
||||
'kode',
|
||||
'nama',
|
||||
'kategori',
|
||||
'stok',
|
||||
'stok_minimum',
|
||||
'deskripsi',
|
||||
];
|
||||
|
||||
public function stockMovements(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockMovement::class, 'id_barang');
|
||||
}
|
||||
|
||||
public static function columnDibuat(): string
|
||||
{
|
||||
$table = (new static)->getTable();
|
||||
|
||||
return Schema::hasColumn($table, self::CREATED_AT) ? self::CREATED_AT : 'created_at';
|
||||
}
|
||||
|
||||
public static function columnDiperbarui(): string
|
||||
{
|
||||
$table = (new static)->getTable();
|
||||
|
||||
return Schema::hasColumn($table, self::UPDATED_AT) ? self::UPDATED_AT : 'updated_at';
|
||||
}
|
||||
|
||||
public function getCreatedAtColumn(): ?string
|
||||
{
|
||||
return static::columnDibuat();
|
||||
}
|
||||
|
||||
public function getUpdatedAtColumn(): ?string
|
||||
{
|
||||
return static::columnDiperbarui();
|
||||
}
|
||||
|
||||
protected function createdAt(): Attribute
|
||||
{
|
||||
return Attribute::get(function () {
|
||||
$col = static::columnDibuat();
|
||||
$raw = $this->attributes[$col] ?? null;
|
||||
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->asDateTime($raw);
|
||||
});
|
||||
}
|
||||
|
||||
protected function updatedAt(): Attribute
|
||||
{
|
||||
return Attribute::get(function () {
|
||||
$col = static::columnDiperbarui();
|
||||
$raw = $this->attributes[$col] ?? null;
|
||||
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->asDateTime($raw);
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'dibuat_pada' => 'datetime',
|
||||
'diperbarui_pada' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class StockMovement extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'mutasi_stok';
|
||||
|
||||
public const CREATED_AT = 'dibuat_pada';
|
||||
|
||||
public const UPDATED_AT = 'diperbarui_pada';
|
||||
|
||||
public const KATEGORI_MASUK = 'masuk';
|
||||
|
||||
public const KATEGORI_KELUAR = 'keluar';
|
||||
|
||||
public const KATEGORI_HILANG = 'hilang';
|
||||
|
||||
protected $fillable = [
|
||||
'id_barang',
|
||||
'tipe',
|
||||
'kategori',
|
||||
'jumlah',
|
||||
'keterangan',
|
||||
'id_pengguna',
|
||||
];
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'id_barang');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'id_pengguna');
|
||||
}
|
||||
|
||||
/** Nama kolom waktu rekaman sesuai tabel aktual di database. */
|
||||
public static function columnDibuat(): string
|
||||
{
|
||||
$table = (new static)->getTable();
|
||||
|
||||
return Schema::hasColumn($table, self::CREATED_AT) ? self::CREATED_AT : 'created_at';
|
||||
}
|
||||
|
||||
public static function columnDiperbarui(): string
|
||||
{
|
||||
$table = (new static)->getTable();
|
||||
|
||||
return Schema::hasColumn($table, self::UPDATED_AT) ? self::UPDATED_AT : 'updated_at';
|
||||
}
|
||||
|
||||
public static function hasKategoriColumn(): bool
|
||||
{
|
||||
return Schema::hasColumn((new static)->getTable(), 'kategori');
|
||||
}
|
||||
|
||||
public function scopeKategori(Builder $query, string $kategori): Builder
|
||||
{
|
||||
if (static::hasKategoriColumn()) {
|
||||
return $query->where('kategori', $kategori);
|
||||
}
|
||||
|
||||
return match ($kategori) {
|
||||
self::KATEGORI_MASUK => $query->where('tipe', 'masuk'),
|
||||
self::KATEGORI_HILANG => $query->where('tipe', 'keluar')->where(function ($query) {
|
||||
$query->whereRaw("LOWER(COALESCE(keterangan, '')) LIKE ?", ['%[hilang]%'])
|
||||
->orWhereRaw("LOWER(COALESCE(keterangan, '')) LIKE ?", ['%hilang%'])
|
||||
->orWhereRaw("LOWER(COALESCE(keterangan, '')) LIKE ?", ['%kehilangan%']);
|
||||
}),
|
||||
default => $query->where('tipe', 'keluar')->where(function ($query) {
|
||||
$query->whereNull('keterangan')
|
||||
->orWhereRaw("LOWER(COALESCE(keterangan, '')) NOT LIKE ?", ['%hilang%'])
|
||||
->whereRaw("LOWER(COALESCE(keterangan, '')) NOT LIKE ?", ['%kehilangan%']);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
public function getCreatedAtColumn(): ?string
|
||||
{
|
||||
return static::columnDibuat();
|
||||
}
|
||||
|
||||
public function getUpdatedAtColumn(): ?string
|
||||
{
|
||||
return static::columnDiperbarui();
|
||||
}
|
||||
|
||||
/**
|
||||
* Blade memakai $model->created_at; kolom DB adalah dibuat_pada.
|
||||
* Tanpa accessor ini, getAttributeFromArray('created_at') selalu null.
|
||||
*/
|
||||
protected function createdAt(): Attribute
|
||||
{
|
||||
return Attribute::get(function () {
|
||||
$col = static::columnDibuat();
|
||||
$raw = $this->attributes[$col] ?? null;
|
||||
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->asDateTime($raw);
|
||||
});
|
||||
}
|
||||
|
||||
protected function updatedAt(): Attribute
|
||||
{
|
||||
return Attribute::get(function () {
|
||||
$col = static::columnDiperbarui();
|
||||
$raw = $this->attributes[$col] ?? null;
|
||||
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->asDateTime($raw);
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'dibuat_pada' => 'datetime',
|
||||
'diperbarui_pada' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'role'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^13.0",
|
||||
"dompdf/dompdf": "^2.0",
|
||||
"phpoffice/phpspreadsheet": "^1.30",
|
||||
"laravel/tinker": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pint": "^1.27",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^12.5.12"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install --ignore-scripts",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => env('APP_TIMEZONE', 'Asia/Jakarta'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "octane",
|
||||
| "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Serializable Classes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the classes that can be unserialized from cache
|
||||
| storage. By default, no PHP classes will be unserialized from your
|
||||
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||
|
|
||||
*/
|
||||
|
||||
'serializable_classes' => false,
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Serialization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the serialization strategy for session data, which
|
||||
| is JSON by default. Setting this to "php" allows the storage of PHP
|
||||
| objects in the session but can make an application vulnerable to
|
||||
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
||||
|
|
||||
| Supported: "json", "php"
|
||||
|
|
||||
*/
|
||||
|
||||
'serialization' => 'json',
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1 @@
|
|||
*.sqlite*
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('role')->default('admin')->after('password');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('role');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('products', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('kode')->unique();
|
||||
$table->string('nama');
|
||||
$table->string('kategori')->nullable();
|
||||
$table->unsignedInteger('stok')->default(0);
|
||||
$table->unsignedInteger('stok_minimum')->default(0);
|
||||
$table->text('deskripsi')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('products');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('stock_movements', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('tipe', ['masuk', 'keluar']);
|
||||
$table->unsignedInteger('jumlah');
|
||||
$table->string('keterangan')->nullable();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('stock_movements');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Menyamakan skema domain ke penamaan bahasa Indonesia + tabel tambahan.
|
||||
* Urutan: hapus FK mutasi → rename tabel/kolom barang → kolom mutasi → nama tabel mutasi → FK baru → tabel referensi.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('stock_movements', function (Blueprint $table) {
|
||||
$table->dropForeign(['product_id']);
|
||||
$table->dropForeign(['user_id']);
|
||||
});
|
||||
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->renameColumn('created_at', 'dibuat_pada');
|
||||
$table->renameColumn('updated_at', 'diperbarui_pada');
|
||||
});
|
||||
|
||||
Schema::rename('products', 'barang');
|
||||
|
||||
Schema::table('stock_movements', function (Blueprint $table) {
|
||||
$table->renameColumn('product_id', 'id_barang');
|
||||
$table->renameColumn('user_id', 'id_pengguna');
|
||||
$table->renameColumn('created_at', 'dibuat_pada');
|
||||
$table->renameColumn('updated_at', 'diperbarui_pada');
|
||||
});
|
||||
|
||||
Schema::rename('stock_movements', 'mutasi_stok');
|
||||
|
||||
Schema::table('mutasi_stok', function (Blueprint $table) {
|
||||
$table->foreign('id_barang')->references('id')->on('barang')->cascadeOnDelete();
|
||||
$table->foreign('id_pengguna')->references('id')->on('users')->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::create('kategori_barang', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('nama', 150)->unique();
|
||||
$table->string('keterangan_ringkas', 255)->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('kategori_barang');
|
||||
|
||||
Schema::table('mutasi_stok', function (Blueprint $table) {
|
||||
$table->dropForeign(['id_barang']);
|
||||
$table->dropForeign(['id_pengguna']);
|
||||
});
|
||||
|
||||
Schema::rename('mutasi_stok', 'stock_movements');
|
||||
|
||||
Schema::table('stock_movements', function (Blueprint $table) {
|
||||
$table->renameColumn('id_barang', 'product_id');
|
||||
$table->renameColumn('id_pengguna', 'user_id');
|
||||
$table->renameColumn('dibuat_pada', 'created_at');
|
||||
$table->renameColumn('diperbarui_pada', 'updated_at');
|
||||
});
|
||||
|
||||
Schema::rename('barang', 'products');
|
||||
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->renameColumn('dibuat_pada', 'created_at');
|
||||
$table->renameColumn('diperbarui_pada', 'updated_at');
|
||||
});
|
||||
|
||||
Schema::table('stock_movements', function (Blueprint $table) {
|
||||
$table->foreign('product_id')->references('id')->on('products')->cascadeOnDelete();
|
||||
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Menyelaraskan nama kolom waktu dengan model (dibuat_pada / diperbarui_pada)
|
||||
* bila database masih memakai created_at / updated_at pada tabel mutasi_stok atau barang.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('mutasi_stok')) {
|
||||
if (Schema::hasColumn('mutasi_stok', 'created_at')
|
||||
&& ! Schema::hasColumn('mutasi_stok', 'dibuat_pada')) {
|
||||
Schema::table('mutasi_stok', function (Blueprint $table) {
|
||||
$table->renameColumn('created_at', 'dibuat_pada');
|
||||
$table->renameColumn('updated_at', 'diperbarui_pada');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Schema::hasTable('barang')) {
|
||||
if (Schema::hasColumn('barang', 'created_at')
|
||||
&& ! Schema::hasColumn('barang', 'dibuat_pada')) {
|
||||
Schema::table('barang', function (Blueprint $table) {
|
||||
$table->renameColumn('created_at', 'dibuat_pada');
|
||||
$table->renameColumn('updated_at', 'diperbarui_pada');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('mutasi_stok')) {
|
||||
if (Schema::hasColumn('mutasi_stok', 'dibuat_pada')
|
||||
&& ! Schema::hasColumn('mutasi_stok', 'created_at')) {
|
||||
Schema::table('mutasi_stok', function (Blueprint $table) {
|
||||
$table->renameColumn('dibuat_pada', 'created_at');
|
||||
$table->renameColumn('diperbarui_pada', 'updated_at');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Schema::hasTable('barang')) {
|
||||
if (Schema::hasColumn('barang', 'dibuat_pada')
|
||||
&& ! Schema::hasColumn('barang', 'created_at')) {
|
||||
Schema::table('barang', function (Blueprint $table) {
|
||||
$table->renameColumn('dibuat_pada', 'created_at');
|
||||
$table->renameColumn('diperbarui_pada', 'updated_at');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Isi kolom timestamp yang NULL tanpa menghapus data,
|
||||
* supaya tanggal di web tampil normal lagi (sumber kebenaran: mutasi_stok & barang).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$fallbackBase = Carbon::now();
|
||||
|
||||
if (Schema::hasTable('mutasi_stok') && Schema::hasColumn('mutasi_stok', 'dibuat_pada')) {
|
||||
foreach (DB::table('mutasi_stok')->select('id', 'dibuat_pada', 'diperbarui_pada')->lazyById() as $row) {
|
||||
$rowDibuat = $row->dibuat_pada;
|
||||
$rowDiperbarui = $row->diperbarui_pada;
|
||||
|
||||
if ($rowDibuat !== null && $rowDiperbarui !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fallback = Carbon::parse($fallbackBase)->format('Y-m-d H:i:s');
|
||||
$dibuatResolved = $rowDibuat ?? $rowDiperbarui ?? $fallback;
|
||||
$diperbaruiResolved = $rowDiperbarui ?? $rowDibuat ?? $fallback;
|
||||
|
||||
DB::table('mutasi_stok')->where('id', $row->id)->update([
|
||||
'dibuat_pada' => $dibuatResolved,
|
||||
'diperbarui_pada' => $diperbaruiResolved,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (Schema::hasTable('barang') && Schema::hasColumn('barang', 'dibuat_pada')) {
|
||||
foreach (DB::table('barang')->select('id', 'dibuat_pada', 'diperbarui_pada')->lazyById() as $row) {
|
||||
$rowDibuat = $row->dibuat_pada;
|
||||
$rowDiperbarui = $row->diperbarui_pada;
|
||||
|
||||
if ($rowDibuat !== null && $rowDiperbarui !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fallback = Carbon::parse($fallbackBase)->format('Y-m-d H:i:s');
|
||||
$dibuatResolved = $rowDibuat ?? $rowDiperbarui ?? $fallback;
|
||||
$diperbaruiResolved = $rowDiperbarui ?? $rowDibuat ?? $fallback;
|
||||
|
||||
DB::table('barang')->where('id', $row->id)->update([
|
||||
'dibuat_pada' => $dibuatResolved,
|
||||
'diperbarui_pada' => $diperbaruiResolved,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Tidak dikembalikan: mengosongkan timestamp berisiko rusak konsistensi.
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Samakan struktur kolom waktu antara database dan model Laravel:
|
||||
* - Jika kedua bahasa (created_at & dibuat_pada) ikut ada, salin dari Inggris ke Indonesia lalu hapus kolom Inggris.
|
||||
* - Jika hanya Inggris, rename ke Indonesia.
|
||||
* - Isi NULL terakhir (aman untuk MySQL maupun SQLite saat tes).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['mutasi_stok', 'barang'] as $table) {
|
||||
if (! Schema::hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasId = Schema::hasColumn($table, 'dibuat_pada');
|
||||
$hasEn = Schema::hasColumn($table, 'created_at');
|
||||
|
||||
if ($hasEn && $hasId) {
|
||||
foreach (DB::table($table)->select('id', 'dibuat_pada', 'diperbarui_pada', 'created_at', 'updated_at')->lazyById() as $row) {
|
||||
$fallback = Carbon::now()->format('Y-m-d H:i:s');
|
||||
|
||||
$dibuatResolved = $row->dibuat_pada ?? $row->created_at ?? $fallback;
|
||||
$diperbaruiResolved = $row->diperbarui_pada ?? $row->updated_at ?? $row->created_at ?? $fallback;
|
||||
|
||||
DB::table($table)->where('id', $row->id)->update([
|
||||
'dibuat_pada' => $dibuatResolved,
|
||||
'diperbarui_pada' => $diperbaruiResolved,
|
||||
]);
|
||||
}
|
||||
|
||||
Schema::table($table, function (Blueprint $bp): void {
|
||||
$bp->dropColumn(['created_at', 'updated_at']);
|
||||
});
|
||||
}
|
||||
|
||||
$hasId = Schema::hasColumn($table, 'dibuat_pada');
|
||||
$hasEn = Schema::hasColumn($table, 'created_at');
|
||||
|
||||
if ($hasEn && ! $hasId) {
|
||||
Schema::table($table, function (Blueprint $bp): void {
|
||||
$bp->renameColumn('created_at', 'dibuat_pada');
|
||||
$bp->renameColumn('updated_at', 'diperbarui_pada');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn($table, 'dibuat_pada')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fallbackBase = Carbon::now();
|
||||
foreach (DB::table($table)->select('id', 'dibuat_pada', 'diperbarui_pada')->lazyById() as $row) {
|
||||
if ($row->dibuat_pada !== null && $row->diperbarui_pada !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fallback = Carbon::parse($fallbackBase)->format('Y-m-d H:i:s');
|
||||
|
||||
DB::table($table)->where('id', $row->id)->update([
|
||||
'dibuat_pada' => $row->dibuat_pada ?? $row->diperbarui_pada ?? $fallback,
|
||||
'diperbarui_pada' => $row->diperbarui_pada ?? $row->dibuat_pada ?? $fallback,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Tidak dibalik untuk menjaga konsistensi data.
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Idempoten: boleh dijalankan meski migrasi serupa sudah pernah jalan.
|
||||
* Berguna bila "php artisan migrate" mengatakan nothing to migrate tetapi
|
||||
* kolom waktu di DB belum selaras dengan model (dibuat_pada vs created_at).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['mutasi_stok', 'barang'] as $table) {
|
||||
if (! Schema::hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasId = Schema::hasColumn($table, 'dibuat_pada');
|
||||
$hasEn = Schema::hasColumn($table, 'created_at');
|
||||
|
||||
if ($hasEn && $hasId) {
|
||||
foreach (DB::table($table)->select('id', 'dibuat_pada', 'diperbarui_pada', 'created_at', 'updated_at')->lazyById() as $row) {
|
||||
$fallback = Carbon::now()->format('Y-m-d H:i:s');
|
||||
|
||||
$dibuatResolved = $row->dibuat_pada ?? $row->created_at ?? $fallback;
|
||||
$diperbaruiResolved = $row->diperbarui_pada ?? $row->updated_at ?? $row->created_at ?? $fallback;
|
||||
|
||||
DB::table($table)->where('id', $row->id)->update([
|
||||
'dibuat_pada' => $dibuatResolved,
|
||||
'diperbarui_pada' => $diperbaruiResolved,
|
||||
]);
|
||||
}
|
||||
|
||||
Schema::table($table, function (Blueprint $bp): void {
|
||||
$bp->dropColumn(['created_at', 'updated_at']);
|
||||
});
|
||||
}
|
||||
|
||||
$hasId = Schema::hasColumn($table, 'dibuat_pada');
|
||||
$hasEn = Schema::hasColumn($table, 'created_at');
|
||||
|
||||
if ($hasEn && ! $hasId) {
|
||||
Schema::table($table, function (Blueprint $bp): void {
|
||||
$bp->renameColumn('created_at', 'dibuat_pada');
|
||||
$bp->renameColumn('updated_at', 'diperbarui_pada');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn($table, 'dibuat_pada')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fallbackBase = Carbon::now();
|
||||
foreach (DB::table($table)->select('id', 'dibuat_pada', 'diperbarui_pada')->lazyById() as $row) {
|
||||
if ($row->dibuat_pada !== null && $row->diperbarui_pada !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fallback = Carbon::parse($fallbackBase)->format('Y-m-d H:i:s');
|
||||
|
||||
DB::table($table)->where('id', $row->id)->update([
|
||||
'dibuat_pada' => $row->dibuat_pada ?? $row->diperbarui_pada ?? $fallback,
|
||||
'diperbarui_pada' => $row->diperbarui_pada ?? $row->dibuat_pada ?? $fallback,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
|
|
@ -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('mutasi_stok') || Schema::hasColumn('mutasi_stok', 'kategori')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('mutasi_stok', function (Blueprint $table) {
|
||||
$table->string('kategori', 30)->nullable()->after('tipe')->index();
|
||||
});
|
||||
|
||||
DB::table('mutasi_stok')
|
||||
->where('tipe', 'masuk')
|
||||
->update(['kategori' => 'masuk']);
|
||||
|
||||
DB::table('mutasi_stok')
|
||||
->where('tipe', 'keluar')
|
||||
->where(function ($query) {
|
||||
$query->whereRaw("LOWER(COALESCE(keterangan, '')) LIKE ?", ['%[hilang]%'])
|
||||
->orWhereRaw("LOWER(COALESCE(keterangan, '')) LIKE ?", ['%hilang%'])
|
||||
->orWhereRaw("LOWER(COALESCE(keterangan, '')) LIKE ?", ['%kehilangan%']);
|
||||
})
|
||||
->update(['kategori' => 'hilang']);
|
||||
|
||||
DB::table('mutasi_stok')
|
||||
->where('tipe', 'keluar')
|
||||
->whereNull('kategori')
|
||||
->update(['kategori' => 'keluar']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('mutasi_stok') || ! Schema::hasColumn('mutasi_stok', 'kategori')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('mutasi_stok', function (Blueprint $table) {
|
||||
$table->dropIndex(['kategori']);
|
||||
$table->dropColumn('kategori');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->createCategoryView('barang_masuk', 'masuk');
|
||||
$this->createCategoryView('barang_keluar', 'keluar');
|
||||
$this->createCategoryView('barang_hilang', 'hilang');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP VIEW IF EXISTS barang_hilang');
|
||||
DB::statement('DROP VIEW IF EXISTS barang_keluar');
|
||||
DB::statement('DROP VIEW IF EXISTS barang_masuk');
|
||||
}
|
||||
|
||||
private function createCategoryView(string $viewName, string $category): void
|
||||
{
|
||||
DB::statement("DROP VIEW IF EXISTS {$viewName}");
|
||||
|
||||
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}'
|
||||
");
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
if (Schema::hasTable('kategori_barang')) {
|
||||
$now = now();
|
||||
foreach (
|
||||
['Kursi', 'Meja', 'Lemari', 'Rak'] as $nama
|
||||
) {
|
||||
DB::table('kategori_barang')->updateOrInsert(
|
||||
['nama' => $nama],
|
||||
[
|
||||
'keterangan_ringkas' => null,
|
||||
'updated_at' => $now,
|
||||
'created_at' => $now,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
User::updateOrCreate(
|
||||
['email' => 'admin@gmail.com'],
|
||||
[
|
||||
'name' => 'Admin',
|
||||
'password' => 'admin123',
|
||||
'role' => 'admin',
|
||||
]
|
||||
);
|
||||
|
||||
User::updateOrCreate(
|
||||
['email' => 'owner@gmail.com'],
|
||||
[
|
||||
'name' => 'Owner',
|
||||
'password' => 'owner123',
|
||||
'role' => 'owner',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^3.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Handle X-XSRF-Token Header
|
||||
RewriteCond %{HTTP:x-xsrf-token} .
|
||||
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->handleRequest(Request::capture());
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
@source '../**/*.js';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
//
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - SIM Stok Mebel</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Arial, sans-serif;
|
||||
background: radial-gradient(circle at top right, #dcfce7 0%, #f1f5f9 40%, #f8fafc 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
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;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
font-size: 28px;
|
||||
color: #14532d;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
p.subtitle {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 10px;
|
||||
padding: 11px 12px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 14px;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #86efac;
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.16);
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
background-color: forestgreen;
|
||||
color: #fff;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
}
|
||||
button:hover {
|
||||
filter: brightness(0.96);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<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>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="error">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
|
||||
<form action="{{ route('login.store') }}" method="POST">
|
||||
@csrf
|
||||
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" value="{{ old('email') }}" required>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required>
|
||||
|
||||
<button type="submit">Masuk</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,730 @@
|
|||
@extends('layouts.sidebar')
|
||||
|
||||
@section('title', 'Dashboard - SIM Stok')
|
||||
@section('active_menu', '')
|
||||
|
||||
@section('content')
|
||||
<style>
|
||||
.hero {
|
||||
border: 1px solid #dbe2ea;
|
||||
background: linear-gradient(120deg, #f1fff3 0%, #ffffff 70%);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.hero h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
font-family: var(--font-heading);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
position: relative;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left-width: 4px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.stat-card.stat-total {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-card.stat-low {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.stat-card.stat-in {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.stat-card.stat-out {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.stat-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: #64748b;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 4px;
|
||||
font-size: 34px;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
font-family: var(--font-heading);
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
color: #cbd5e1;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-trend-value {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel h3 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--font-heading);
|
||||
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;
|
||||
color: #334155;
|
||||
line-height: 1.8;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.compare-grid {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.compare-card {
|
||||
position: relative;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left-width: 4px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.compare-card.fast {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.compare-card.slow {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.compare-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.compare-item-title {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: #64748b;
|
||||
font-family: var(--font-heading);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.compare-item-name {
|
||||
margin: 5px 0 0;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-family: var(--font-heading);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.compare-item-value {
|
||||
margin: 5px 0 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.1;
|
||||
color: #0f172a;
|
||||
font-weight: 800;
|
||||
font-family: var(--font-heading);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.compare-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
color: #cbd5e1;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.compare-item-sublist {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.compare-more {
|
||||
margin-top: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.compare-more summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.compare-more ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.yearly-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.yearly-chart {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
min-height: 310px;
|
||||
}
|
||||
|
||||
.month-col {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.month-bars {
|
||||
height: 240px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.month-bar {
|
||||
width: 22px;
|
||||
border-radius: 5px 5px 0 0;
|
||||
}
|
||||
|
||||
.month-bar.in {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.month-bar.out {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.month-pct {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.month-pct.month-pct-baris {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.month-pct .pct-in {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.month-pct .pct-sep {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.month-pct .pct-out {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.month-pct .pct-none {
|
||||
color: #cbd5e1;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stock-alert {
|
||||
border: 1px solid #facc15;
|
||||
background: #fffbeb;
|
||||
color: #854d0e;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat-muted {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-details {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.stat-details summary {
|
||||
cursor: pointer;
|
||||
color: #166534;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.stat-details summary:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.stat-details-body {
|
||||
margin-top: 8px;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.stat-details-body ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.stat-details-body li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stock-alert details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.stock-alert summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: #713f12;
|
||||
}
|
||||
</style>
|
||||
|
||||
<section class="hero">
|
||||
<div>
|
||||
<h2>Dashboard</h2>
|
||||
<p>Selamat datang, {{ $user->name }}.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="stats">
|
||||
<article class="stat-card stat-total">
|
||||
<div class="stat-main">
|
||||
<div class="stat-title">Total Produk</div>
|
||||
<div class="stat-value">{{ $totalProduk }}</div>
|
||||
@if ($daftarNamaProduk->isNotEmpty())
|
||||
<details class="stat-details">
|
||||
<summary>Lihat daftar nama produk ({{ $daftarNamaProduk->count() }})</summary>
|
||||
<div class="stat-details-body">{{ $daftarNamaProduk->implode(', ') }}</div>
|
||||
</details>
|
||||
@else
|
||||
<p class="stat-muted">Belum ada produk terdaftar.</p>
|
||||
@endif
|
||||
</div>
|
||||
<svg class="stat-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor"
|
||||
d="M6 2h8l4 4v2h1a2 2 0 0 1 2 2v9.5A2.5 2.5 0 0 1 18.5 22h-13A2.5 2.5 0 0 1 3 19.5V10a2 2 0 0 1 2-2h1V4a2 2 0 0 1 2-2m1 6h10V7h-3a1 1 0 0 1-1-1V3H8a1 1 0 0 0-1 1zm-2 2v9.5a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V10z" />
|
||||
</svg>
|
||||
</article>
|
||||
<article class="stat-card stat-low">
|
||||
<div class="stat-main">
|
||||
<div class="stat-title">Stok Menipis</div>
|
||||
<div class="stat-value">{{ $stokMenipis }}</div>
|
||||
@if ($produkStokMenipis->isNotEmpty())
|
||||
<details class="stat-details">
|
||||
<summary>Lihat barang menipis ({{ $produkStokMenipis->count() }})</summary>
|
||||
<div class="stat-details-body">
|
||||
<ul>
|
||||
@foreach ($produkStokMenipis as $p)
|
||||
<li>{{ $p->nama }} (stok {{ $p->stok }}, min. {{ $p->stok_minimum }})</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
@else
|
||||
<p class="stat-muted">Tidak ada.</p>
|
||||
@endif
|
||||
</div>
|
||||
<svg class="stat-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor"
|
||||
d="M13 2a1 1 0 0 0-2 0v10.59l-1.3-1.3a1 1 0 1 0-1.4 1.42l3 2.98a1 1 0 0 0 1.4 0l3-2.98a1 1 0 1 0-1.4-1.42l-1.3 1.3zM5 18a1 1 0 0 1 1 1v1h12v-1a1 1 0 1 1 2 0v1a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-1a1 1 0 0 1 1-1" />
|
||||
</svg>
|
||||
</article>
|
||||
<article class="stat-card stat-in">
|
||||
<div class="stat-main">
|
||||
<div class="stat-title">Barang Masuk</div>
|
||||
<div class="stat-value">{{ number_format($jumlahBarangMasuk) }}</div>
|
||||
</div>
|
||||
<svg class="stat-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor"
|
||||
d="M4 11a1 1 0 0 1 1-1h8.59L11.3 7.7a1 1 0 0 1 1.4-1.4l4 4a1 1 0 0 1 0 1.4l-4 4a1 1 0 1 1-1.4-1.4L13.59 12H5a1 1 0 0 1-1-1m1 7h14a1 1 0 1 1 0 2H5a1 1 0 1 1 0-2" />
|
||||
</svg>
|
||||
</article>
|
||||
<article class="stat-card stat-out">
|
||||
<div class="stat-main">
|
||||
<div class="stat-title">Barang Keluar</div>
|
||||
<div class="stat-value">{{ number_format($jumlahBarangKeluar) }}</div>
|
||||
</div>
|
||||
<svg class="stat-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor"
|
||||
d="M20 11a1 1 0 0 0-1-1h-8.59l2.29-2.3a1 1 0 0 0-1.4-1.4l-4 4a1 1 0 0 0 0 1.4l4 4a1 1 0 0 0 1.4-1.4L10.41 12H19a1 1 0 0 0 1-1m-1 7H5a1 1 0 1 0 0 2h14a1 1 0 1 0 0-2" />
|
||||
</svg>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- <div class="stock-alert">
|
||||
Stok Menipis: <strong>{{ $stokMenipis }}</strong> produk perlu perhatian.
|
||||
Persentase stok aman saat ini <strong>{{ $persentaseStokAman }}%</strong>.
|
||||
@if ($produkStokMenipis->isNotEmpty())
|
||||
<details>
|
||||
<summary>Detail barang menipis ({{ $produkStokMenipis->count() }})</summary>
|
||||
<div class="stat-details-body" style="margin-top:8px;">
|
||||
{{ $produkStokMenipis->map(fn($p) => $p->nama . ' (stok ' . $p->stok . ')')->implode(', ') }}
|
||||
</div>
|
||||
</details>
|
||||
@else
|
||||
<div style="margin-top:8px;">
|
||||
<strong>Barang menipis:</strong> Tidak ada.
|
||||
</div>
|
||||
@endif
|
||||
</div> -->
|
||||
|
||||
<section class="panel" style="margin-top:14px;">
|
||||
<div class="yearly-header">
|
||||
<div>
|
||||
<h3 style="margin:0;">Grafik Tahunan Arus Barang</h3>
|
||||
<div style="font-size:13px; color:#64748b; margin-top:4px;">
|
||||
Barang masuk: <strong>{{ $totalMasukTahunan }}</strong> unit —
|
||||
Barang keluar: <strong>{{ $totalKeluarTahunan }}</strong> unit —
|
||||
Presentase masuk: <strong>{{ $persenMasukTahunan }}%</strong>, keluar:
|
||||
<strong>{{ $persenKeluarTahunan }}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <form method="GET" action="{{ route('dashboard') }}" style="display:flex; gap:8px; align-items:center;">
|
||||
<select name="tahun">
|
||||
@foreach ($daftarTahun as $tahun)
|
||||
<option value="{{ $tahun }}" @selected((int) $tahunDipilih === (int) $tahun)>{{ $tahun }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select name="jenis_data">
|
||||
<option value="semua" @selected($jenisData === 'semua')>Masuk dan Keluar</option>
|
||||
<option value="masuk" @selected($jenisData === 'masuk')>Masuk</option>
|
||||
<option value="keluar" @selected($jenisData === 'keluar')>Keluar</option>
|
||||
</select>
|
||||
<button type="submit">Tampilkan</button>
|
||||
</form> -->
|
||||
</div>
|
||||
|
||||
@php
|
||||
$tinggiAreaBatangPx = 228;
|
||||
@endphp
|
||||
|
||||
<div class="yearly-chart">
|
||||
@foreach ($trenTahunan as $bulan)
|
||||
@php
|
||||
$idxBulan = $loop->index;
|
||||
$tinggiMasuk = max(2, (int) round(($bulan['masuk'] / $maksTrenTahunan) * $tinggiAreaBatangPx));
|
||||
$tinggiKeluar = max(2, (int) round(($bulan['keluar'] / $maksTrenTahunan) * $tinggiAreaBatangPx));
|
||||
$kontribMasukPct = $pctMasukKontribTahunan[$idxBulan] ?? null;
|
||||
$kontribKeluarPct = $pctKeluarKontribTahunan[$idxBulan] ?? null;
|
||||
@endphp
|
||||
<div class="month-col">
|
||||
<div class="month-bars">
|
||||
@if ($jenisData !== 'keluar')
|
||||
<div class="month-bar in" style="height: {{ $tinggiMasuk }}px;"
|
||||
title="{{ $bulan['label'] }} | Masuk: {{ $bulan['masuk'] }} unit @if ($kontribMasukPct !== null) | Kontribusi ke presentase masuk tahun {{ $persenMasukTahunan }}%: {{ $kontribMasukPct }} poin persen @endif">
|
||||
</div>
|
||||
@endif
|
||||
@if ($jenisData !== 'masuk')
|
||||
<div class="month-bar out" style="height: {{ $tinggiKeluar }}px;"
|
||||
title="{{ $bulan['label'] }} | Keluar: {{ $bulan['keluar'] }} unit @if ($kontribKeluarPct !== null) | Kontribusi ke presentase keluar tahun {{ $persenKeluarTahunan }}%: {{ $kontribKeluarPct }} poin persen @endif">
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="month-label">{{ $bulan['label'] }}</div>
|
||||
<div class="month-pct month-pct-baris">
|
||||
@if ($kontribMasukPct !== null && $kontribKeluarPct !== null)
|
||||
Presentase masuk: <span class="pct-in">{{ $kontribMasukPct }}%</span>,
|
||||
keluar: <span class="pct-out">{{ $kontribKeluarPct }}%</span>
|
||||
@else
|
||||
<span class="pct-none">—</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="legend">
|
||||
@if ($jenisData !== 'keluar')
|
||||
<span><strong style="color:#16a34a;">Hijau</strong> = Barang Masuk</span>
|
||||
@endif
|
||||
@if ($jenisData !== 'masuk')
|
||||
<span><strong style="color:#ef4444;">Merah</strong> = Barang Keluar</span>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- <section class="panel" style="margin-top:14px;">
|
||||
<h3>Top Produk Paling Aktif</h3>
|
||||
<ul class="list">
|
||||
@forelse ($topPergerakanProduk as $item)
|
||||
<li>{{ $item->product->nama ?? 'Produk' }} - {{ $item->total }} unit transaksi</li>
|
||||
@empty
|
||||
<li>Belum ada data pergerakan produk.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</section> -->
|
||||
|
||||
<section class="panel" style="margin-top:14px;">
|
||||
<h3>Perbandingan Kecepatan Penjualan Produk</h3>
|
||||
<!-- <p class="stat-muted" style="margin-top:-2px; margin-bottom:10px;">
|
||||
Periode otomatis: {{ $labelBulanPerbandinganPenjualan }} (berdasarkan transaksi barang keluar bulan berjalan).
|
||||
</p> -->
|
||||
@if ($produkTercepatTerjual && $produkTerlambatTerjual)
|
||||
<div class="compare-grid">
|
||||
<article class="compare-card fast">
|
||||
<div class="compare-main">
|
||||
<p class="compare-item-title">Produk Paling Aktif Terjual</p>
|
||||
<p class="compare-item-name">{{ $produkTercepatTerjual->product->nama ?? 'Produk' }}</p>
|
||||
<p class="compare-item-value">{{ number_format((int) $produkTercepatTerjual->total_terjual) }} unit</p>
|
||||
<details class="compare-more">
|
||||
<summary>More info (produk mendekati tercepat)</summary>
|
||||
@if ($produkCepatTerjualLainnya->isNotEmpty())
|
||||
<ul>
|
||||
@foreach ($produkCepatTerjualLainnya as $item)
|
||||
<li>
|
||||
{{ $item->product->nama ?? 'Produk' }} -
|
||||
{{ number_format((int) $item->total_terjual) }} unit
|
||||
(selisih
|
||||
{{ number_format(abs((int) $produkTercepatTerjual->total_terjual - (int) $item->total_terjual)) }}
|
||||
unit)
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@else
|
||||
<p class="stat-muted" style="margin:8px 0 0;">Belum ada produk lain yang mendekati.</p>
|
||||
@endif
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
<article class="compare-card slow">
|
||||
<div class="compare-main">
|
||||
<p class="compare-item-title">Produk Paling Lambat Terjual</p>
|
||||
<p class="compare-item-name">{{ $produkTerlambatTerjual->product->nama ?? 'Produk' }}</p>
|
||||
<p class="compare-item-value">{{ number_format((int) $produkTerlambatTerjual->total_terjual) }} unit</p>
|
||||
<details class="compare-more">
|
||||
<summary>More info (produk mendekati terlambat)</summary>
|
||||
@if ($produkLambatTerjualLainnya->isNotEmpty())
|
||||
<ul>
|
||||
@foreach ($produkLambatTerjualLainnya as $item)
|
||||
<li>
|
||||
{{ $item->product->nama ?? 'Produk' }} -
|
||||
{{ number_format((int) $item->total_terjual) }} unit
|
||||
(selisih
|
||||
{{ number_format(abs((int) $item->total_terjual - (int) $produkTerlambatTerjual->total_terjual)) }}
|
||||
unit)
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@else
|
||||
<p class="stat-muted" style="margin:8px 0 0;">Belum ada produk lain yang mendekati.</p>
|
||||
@endif
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p class="stat-trend">
|
||||
Selisih performa penjualan:
|
||||
<span class="stat-trend-value">{{ number_format($selisihProdukTerjual) }} unit</span>
|
||||
</p>
|
||||
<p class="stat-muted">Produk tambahan ditampilkan jika selisih penjualan maksimal
|
||||
{{ $batasSelisihProdukMirip }} unit dari produk utama.
|
||||
</p>
|
||||
@else
|
||||
<p class="stat-muted">Belum ada data barang keluar untuk menghitung kecepatan penjualan produk.</p>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
|
||||
<!-- <section class="panel" style="margin-top:14px;">
|
||||
<h3>Prioritas Restock</h3>
|
||||
<div class="bars">
|
||||
@forelse ($produkStokMenipis as $produk)
|
||||
@php
|
||||
$ratio = $produk->stok_minimum > 0 ? min(100, (int) (($produk->stok / $produk->stok_minimum) * 100)) : 0;
|
||||
@endphp
|
||||
<div class="bar-row">
|
||||
<strong>{{ $produk->nama }}</strong>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" style="width: {{ $ratio }}%"></div>
|
||||
</div>
|
||||
<span>{{ $produk->stok }}</span>
|
||||
</div>
|
||||
@empty
|
||||
<p>Tidak ada produk yang perlu restock.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</section> -->
|
||||
|
||||
<section class="panel" style="margin-top:14px;">
|
||||
<h3>Aktivitas Terbaru</h3>
|
||||
<table class="app-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Waktu</th>
|
||||
<th>Tipe</th>
|
||||
<th>Jumlah</th>
|
||||
<th>Barang</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($aktivitasTerbaru as $aktivitas)
|
||||
<tr>
|
||||
<td>{{ $aktivitas->created_at?->timezone('Asia/Jakarta')->format('d/m/Y H:i') ?? '—' }}</td>
|
||||
<td>{{ strtoupper($aktivitas->tipe) }}</td>
|
||||
<td>{{ $aktivitas->jumlah }} unit</td>
|
||||
<td>{{ $aktivitas->product->nama ?? 'Produk' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4">Belum ada aktivitas transaksi.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
@endsection
|
||||
|
|
@ -0,0 +1,654 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>@yield('title', 'SIM Stok')</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg-app: #f1f5f9;
|
||||
--bg-card: #ffffff;
|
||||
--bg-soft: #f8fafc;
|
||||
--text-main: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--line: #e2e8f0;
|
||||
--primary: #166534;
|
||||
--primary-soft: #22c55e;
|
||||
--danger: #b91c1c;
|
||||
--radius-md: 12px;
|
||||
--radius-sm: 10px;
|
||||
--shadow-soft: 0 10px 26px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Arial, sans-serif;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-main);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 74px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(6px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #166534;
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: #166534;
|
||||
font-weight: 600;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cde9d2;
|
||||
background: #f3fff4;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.nav a.active,
|
||||
.nav a:hover {
|
||||
background: forestgreen;
|
||||
color: #fff;
|
||||
border-color: forestgreen;
|
||||
}
|
||||
|
||||
.right-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.flash {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flash.success {
|
||||
background: #e8ffe8;
|
||||
border-color: #399339;
|
||||
color: #105b10;
|
||||
}
|
||||
|
||||
.flash.error {
|
||||
background: #ffe9e9;
|
||||
border-color: #a93f3f;
|
||||
color: #7d1111;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 9px 10px;
|
||||
transition: all 0.18s ease;
|
||||
background: #fff;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #86efac;
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.16);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
font-weight: 600;
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
filter: brightness(0.96);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button[style*="b91c1c"] {
|
||||
background: var(--danger) !important;
|
||||
border-color: var(--danger) !important;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.app-table {
|
||||
width: 100%;
|
||||
min-width: 680px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border: 1px solid var(--line);
|
||||
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-weight: 700;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.app-table th,
|
||||
.app-table td {
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.app-table tbody tr:nth-child(even) {
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.app-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.app-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 8px 0 12px;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.logout-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
aside a {
|
||||
display: block;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
padding: 12px 10px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
aside a:hover {
|
||||
background: rgba(22, 101, 52, 0.95);
|
||||
border-color: rgba(187, 247, 208, 0.3);
|
||||
}
|
||||
|
||||
aside a.active {
|
||||
background: #22c55e;
|
||||
color: #064e3b;
|
||||
border-color: rgba(187, 247, 208, 0.6);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 17px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-icon i {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.layout-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
width: 280px;
|
||||
background: linear-gradient(180deg, #14532d 0%, #166534 100%);
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
form {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.grid-form {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)) !important;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)) !important;
|
||||
}
|
||||
|
||||
.inline {
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(120px, 220px) minmax(180px, 1fr) auto !important;
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.report-filter-form,
|
||||
.export-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.report-filter-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr)) auto auto;
|
||||
}
|
||||
|
||||
.report-filter-form label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-filter-form input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-table td>div[style*="display:flex"] {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-table td>div[style*="display:flex"] form {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
text-decoration: none;
|
||||
color: #1f2937;
|
||||
background: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.export-actions {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.export-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 8px 16px;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.export-link.excel {
|
||||
background: #14532d;
|
||||
}
|
||||
|
||||
.export-link.pdf {
|
||||
background: #1e529b;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.layout-sidebar {
|
||||
width: 236px;
|
||||
}
|
||||
|
||||
aside a {
|
||||
font-size: 14px;
|
||||
padding: 10px 9px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 21px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout-shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
width: 100%;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: 42vh;
|
||||
overflow: auto;
|
||||
z-index: 20;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.layout-sidebar h2 {
|
||||
font-size: 20px !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
aside a {
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
margin: 0 6px 8px 0;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: auto;
|
||||
min-height: 64px;
|
||||
padding: 12px 14px;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.right-tools {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.app-table {
|
||||
min-width: 620px;
|
||||
}
|
||||
|
||||
.report-filter-form {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.inline {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.report-filter-form button,
|
||||
.report-filter-form .btn-link,
|
||||
.export-actions a,
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
justify-content: stretch !important;
|
||||
}
|
||||
|
||||
.table-toolbar select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-table td>div[style*="display:flex"],
|
||||
.app-table td>div[style*="display:flex"] form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.right-tools,
|
||||
.logout-form,
|
||||
.logout-form button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-table {
|
||||
min-width: 560px;
|
||||
}
|
||||
|
||||
.app-table th,
|
||||
.app-table td {
|
||||
padding: 8px 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@php($activeMenu = trim($__env->yieldContent('active_menu')))
|
||||
|
||||
<div class="layout-shell">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<a href="{{ route('dashboard') }}" class="{{ $activeMenu === 'dashboard' ? 'active' : '' }}">
|
||||
<span class="menu-icon"><i class="bi bi-speedometer2" aria-hidden="true"></i></span>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
|
||||
@if (auth()->user()?->role === 'admin')
|
||||
<a href="{{ route('users.index') }}" class="{{ $activeMenu === 'users' ? 'active' : '' }}">
|
||||
<span class="menu-icon"><i class="bi bi-people" aria-hidden="true"></i></span>
|
||||
<span>User Management</span>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if (auth()->user()?->role === 'admin')
|
||||
<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')
|
||||
<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>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('stock-movements.outgoing') }}" class="{{ $activeMenu === 'keluar' ? 'active' : '' }}">
|
||||
<span class="menu-icon"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i></span>
|
||||
<span>Barang Keluar</span>
|
||||
</a>
|
||||
|
||||
<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>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('reports.barang-hilang') }}"
|
||||
class="{{ $activeMenu === 'reports-barang-hilang' ? 'active' : '' }}">
|
||||
<span class="menu-icon"><i class="bi bi-file-earmark-x" aria-hidden="true"></i></span>
|
||||
<span>Laporan Barang Hilang</span>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if (in_array(auth()->user()?->role, ['admin', 'owner'], true))
|
||||
<a href="{{ route('reports.pembelian') }}"
|
||||
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.rekap-penjualan') }}"
|
||||
class="{{ $activeMenu === 'reports-rekap-penjualan' ? 'active' : '' }}">
|
||||
<span class="menu-icon"><i class="bi bi-graph-up-arrow" aria-hidden="true"></i></span>
|
||||
<span>Rekap Penjualan</span>
|
||||
</a>
|
||||
@endif
|
||||
</aside>
|
||||
|
||||
<!-- CONTENT -->
|
||||
<div class="layout-content">
|
||||
|
||||
<!-- TOPBAR -->
|
||||
<header class="topbar">
|
||||
<!-- <h1 class="brand">Dashboard</h1> -->
|
||||
<h1 class="brand">@yield('title')</h1>
|
||||
|
||||
<div class="right-tools">
|
||||
<form class="logout-form" action="{{ route('logout') }}" method="POST">
|
||||
@csrf
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
@if (session('success'))
|
||||
<div class="flash success">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="flash error">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
|
||||
@yield('content')
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
@extends('layouts.sidebar')
|
||||
|
||||
@section('title', 'Barang Hilang - SIM Stok')
|
||||
@section('active_menu', 'hilang')
|
||||
|
||||
@section('content')
|
||||
<style>
|
||||
.panel { margin-bottom: 14px; }
|
||||
.inline { display: grid; grid-template-columns: 1fr 220px 1fr auto; gap: 10px; }
|
||||
</style>
|
||||
|
||||
<div class="table-card panel">
|
||||
<h2>Input Barang Hilang</h2>
|
||||
<form action="{{ route('stock-movements.lost.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 hilang" required>
|
||||
<input type="text" name="keterangan" placeholder="Keterangan kehilangan">
|
||||
<button type="submit" style="background:#b91c1c; border-color:#b91c1c;">Simpan</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-card panel">
|
||||
<h3>Riwayat Barang Hilang</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>
|
||||
<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 hilang.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
@extends('layouts.sidebar')
|
||||
|
||||
@section('title', 'Barang Keluar - SIM Stok')
|
||||
@section('active_menu', 'keluar')
|
||||
|
||||
@section('content')
|
||||
<style>
|
||||
.panel {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 220px 1fr auto;
|
||||
gap: 10px;
|
||||
}
|
||||
</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>
|
||||
|
||||
<div class="table-card panel">
|
||||
<h2>Filter Barang Keluar</h2>
|
||||
<form method="GET" action="{{ route('stock-movements.outgoing') }}" 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.outgoing') }}" class="btn-link">
|
||||
Reset
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-card panel">
|
||||
<h3>Riwayat Barang Keluar</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>
|
||||
<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 keluar.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
@extends('layouts.sidebar')
|
||||
|
||||
@section('title', 'Barang Masuk - SIM Stok')
|
||||
@section('active_menu', 'masuk')
|
||||
|
||||
@section('content')
|
||||
<style>
|
||||
.panel {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 220px 1fr auto;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<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>
|
||||
<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 masuk" required>
|
||||
<input type="text" name="keterangan" placeholder="Keterangan">
|
||||
<button type="submit">Simpan</button>
|
||||
</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>
|
||||
<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>
|
||||
<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
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Transaksi Stok - SIM Stok Mebel</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; margin: 24px;">
|
||||
<h1>Transaksi Stok Mebel</h1>
|
||||
<p><a href="{{ route('dashboard') }}">Kembali ke dashboard</a> | <a href="{{ route('products.index') }}">Master Barang</a></p>
|
||||
|
||||
@if (session('success'))
|
||||
<p style="color: green;">{{ session('success') }}</p>
|
||||
@endif
|
||||
|
||||
@if ($errors->any())
|
||||
<p style="color: red;">{{ $errors->first() }}</p>
|
||||
@endif
|
||||
|
||||
<h2>Input Transaksi</h2>
|
||||
<form action="{{ route('stock-movements.store') }}" method="POST">
|
||||
@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>
|
||||
<select name="tipe" required>
|
||||
<option value="">Pilih tipe</option>
|
||||
<option value="masuk">Stok Masuk</option>
|
||||
<option value="keluar">Stok Keluar</option>
|
||||
</select>
|
||||
<input type="number" name="jumlah" min="1" placeholder="Jumlah" required>
|
||||
<input type="text" name="keterangan" placeholder="Keterangan">
|
||||
<button type="submit">Simpan Transaksi</button>
|
||||
</form>
|
||||
|
||||
<h2>Riwayat Terakhir</h2>
|
||||
<table border="1" cellpadding="8" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Waktu</th>
|
||||
<th>Barang</th>
|
||||
<th>Tipe</th>
|
||||
<th>Jumlah</th>
|
||||
<th>Keterangan</th>
|
||||
<th>Input Oleh</th>
|
||||
</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>{{ strtoupper($movement->tipe) }}</td>
|
||||
<td>{{ $movement->jumlah }}</td>
|
||||
<td>{{ $movement->keterangan ?? '-' }}</td>
|
||||
<td>{{ $movement->user?->name ?? '-' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="6">Belum ada transaksi.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
@extends('layouts.sidebar')
|
||||
|
||||
@section('title', 'Data Barang - SIM Stok')
|
||||
@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>
|
||||
|
||||
<div class="table-card panel">
|
||||
<h2>Data Barang</h2>
|
||||
<form action="{{ route('products.store') }}" method="POST">
|
||||
@csrf
|
||||
<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') }}">
|
||||
</div>
|
||||
<button type="submit">Simpan Data Barang</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<th colspan="2">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($products as $product)
|
||||
<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>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="8">Belum ada data barang.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<div class="table-card">
|
||||
<h2>Filter Laporan</h2>
|
||||
<form method="GET" action="{{ $action }}" class="report-filter-form">
|
||||
<div>
|
||||
<label for="tanggal_mulai">Tanggal Mulai</label>
|
||||
<input id="tanggal_mulai" type="date" name="tanggal_mulai" value="{{ $tanggalMulai }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="tanggal_selesai">Tanggal Selesai</label>
|
||||
<input id="tanggal_selesai" type="date" name="tanggal_selesai" value="{{ $tanggalSelesai }}">
|
||||
</div>
|
||||
<button type="submit">Terapkan</button>
|
||||
<a href="{{ $action }}" class="btn-link">
|
||||
Reset
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{-- Partial laporan dikosongkan sesuai permintaan. --}}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
@extends('layouts.sidebar')
|
||||
|
||||
@section('title', 'Laporan Barang Hilang')
|
||||
@section('active_menu', 'reports-barang-hilang')
|
||||
|
||||
@section('content')
|
||||
<style>
|
||||
.report-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
</style>
|
||||
@include('reports._nav', ['activeReport' => 'barang-hilang'])
|
||||
@include('reports._filter', ['action' => route('reports.barang-hilang')])
|
||||
|
||||
<div class="table-card">
|
||||
<div style="font-size:14px; color:#64748b;">Total Barang Hilang</div>
|
||||
<div style="font-size:30px; color:#b91c1c; font-weight:800;">{{ $total }}</div>
|
||||
<div style="margin-top:8px; font-size:13px; color:#64748b;">
|
||||
Data berdasarkan transaksi kehilangan dari input "Barang Hilang".
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card export-actions">
|
||||
<a href="{{ route('reports.barang-hilang.export', array_merge(request()->query(), ['format' => 'excel'])) }}"
|
||||
class="export-link excel">
|
||||
Export Excel
|
||||
</a>
|
||||
<a href="{{ route('reports.barang-hilang.export', array_merge(request()->query(), ['format' => 'pdf'])) }}"
|
||||
class="export-link pdf">
|
||||
Export PDF
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<h2>Detail Barang Hilang</h2>
|
||||
<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 ($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->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>
|
||||
@endsection
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 22px 26px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #222;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
margin-bottom: 14px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #b9c0c9;
|
||||
padding: 6px 7px;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #e8eef6;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@php
|
||||
$columnCount = count($columns);
|
||||
$columnWidths = match ($columnCount) {
|
||||
3 => ['54%', '22%', '24%'],
|
||||
5 => ['17%', '26%', '9%', '16%', '32%'],
|
||||
default => array_fill(0, max(1, $columnCount), (100 / max(1, $columnCount)) . '%'),
|
||||
};
|
||||
@endphp
|
||||
|
||||
<h1>{{ $title }}</h1>
|
||||
<div class="meta">
|
||||
Periode: {{ $period ?? (($tanggalMulai ?? '-') . ' sampai ' . ($tanggalSelesai ?? '-')) }}
|
||||
|
|
||||
Dicetak: {{ $printedAt ?? now('Asia/Jakarta')->format('d/m/Y H:i') }}
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<colgroup>
|
||||
@foreach ($columnWidths as $width)
|
||||
<col style="width: {{ $width }};">
|
||||
@endforeach
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
@foreach ($columns as $column)
|
||||
<th>{{ $column }}</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($rows as $row)
|
||||
<tr>
|
||||
@foreach ($columns as $index => $column)
|
||||
<td>{{ $row[$index] ?? '' }}</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td class="empty" colspan="{{ count($columns) }}">Tidak ada data.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
@extends('layouts.sidebar')
|
||||
|
||||
@section('title', 'Laporan Pembelian')
|
||||
@section('active_menu', 'reports-pembelian')
|
||||
|
||||
@section('content')
|
||||
<style>
|
||||
.report-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
</style>
|
||||
@include('reports._nav', ['activeReport' => 'pembelian'])
|
||||
@include('reports._filter', ['action' => route('reports.pembelian')])
|
||||
|
||||
<div class="table-card">
|
||||
<div style="font-size:14px; color:#64748b;">Total Pembelian</div>
|
||||
<div style="font-size:30px; color:#14532d; font-weight:800;">{{ $total }}</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card export-actions">
|
||||
<a href="{{ route('reports.pembelian.export', array_merge(request()->query(), ['format' => 'excel'])) }}"
|
||||
class="export-link excel">
|
||||
Export Excel
|
||||
</a>
|
||||
<a href="{{ route('reports.pembelian.export', array_merge(request()->query(), ['format' => 'pdf'])) }}"
|
||||
class="export-link pdf">
|
||||
Export PDF
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<h2>Detail Pembelian</h2>
|
||||
<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 ($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->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>
|
||||
@endsection
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
@extends('layouts.sidebar')
|
||||
|
||||
@section('title', 'Laporan Penjualan')
|
||||
@section('active_menu', 'reports-penjualan')
|
||||
|
||||
@section('content')
|
||||
<style>
|
||||
.report-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
</style>
|
||||
@include('reports._nav', ['activeReport' => 'penjualan'])
|
||||
@include('reports._filter', ['action' => route('reports.penjualan')])
|
||||
|
||||
<div class="table-card">
|
||||
<div style="font-size:14px; color:#64748b;">Total Penjualan</div>
|
||||
<div style="font-size:30px; color:#14532d; font-weight:800;">{{ $total }}</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card export-actions">
|
||||
<a href="{{ route('reports.penjualan.export', array_merge(request()->query(), ['format' => 'excel'])) }}"
|
||||
class="export-link excel">
|
||||
Export Excel
|
||||
</a>
|
||||
<a href="{{ route('reports.penjualan.export', array_merge(request()->query(), ['format' => 'pdf'])) }}"
|
||||
class="export-link pdf">
|
||||
Export PDF
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<h2>Detail Penjualan</h2>
|
||||
<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 ($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->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>
|
||||
@endsection
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
@extends('layouts.sidebar')
|
||||
|
||||
@section('title', 'Rekap Pembelian')
|
||||
@section('active_menu', 'reports-rekap-pembelian')
|
||||
|
||||
@section('content')
|
||||
@include('reports._nav', ['activeReport' => 'rekap-pembelian'])
|
||||
@include('reports._filter', ['action' => route('reports.rekap-pembelian')])
|
||||
|
||||
<div class="table-card">
|
||||
<div style="font-size:14px; color:#64748b;">Total Qty Pembelian</div>
|
||||
<div style="font-size:30px; color:#14532d; font-weight:800;">{{ $total }}</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card export-actions">
|
||||
<a href="{{ route('reports.rekap-pembelian.export', array_merge(request()->query(), ['format' => 'excel'])) }}"
|
||||
class="export-link excel">
|
||||
Export Excel
|
||||
</a>
|
||||
<a href="{{ route('reports.rekap-pembelian.export', array_merge(request()->query(), ['format' => 'pdf'])) }}"
|
||||
class="export-link pdf">
|
||||
Export PDF
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<h2>Rekap Pembelian per Produk</h2>
|
||||
<table class="app-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Produk</th>
|
||||
<th>Total Qty</th>
|
||||
<th>Total Transaksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($data 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>
|
||||
@endsection
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
@extends('layouts.sidebar')
|
||||
|
||||
@section('title', 'Rekap Penjualan')
|
||||
@section('active_menu', 'reports-rekap-penjualan')
|
||||
|
||||
@section('content')
|
||||
@include('reports._nav', ['activeReport' => 'rekap-penjualan'])
|
||||
@include('reports._filter', ['action' => route('reports.rekap-penjualan')])
|
||||
|
||||
<div class="table-card">
|
||||
<div style="font-size:14px; color:#64748b;">Total Qty Penjualan</div>
|
||||
<div style="font-size:30px; color:#14532d; font-weight:800;">{{ $total }}</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card export-actions">
|
||||
<a href="{{ route('reports.rekap-penjualan.export', array_merge(request()->query(), ['format' => 'excel'])) }}"
|
||||
class="export-link excel">
|
||||
Export Excel
|
||||
</a>
|
||||
<a href="{{ route('reports.rekap-penjualan.export', array_merge(request()->query(), ['format' => 'pdf'])) }}"
|
||||
class="export-link pdf">
|
||||
Export PDF
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<h2>Rekap Penjualan per Produk</h2>
|
||||
<table class="app-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Produk</th>
|
||||
<th>Total Qty</th>
|
||||
<th>Total Transaksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($data 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>
|
||||
@endsection
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
@extends('layouts.sidebar')
|
||||
|
||||
@section('title', 'Laporan Stok')
|
||||
@section('active_menu', 'reports')
|
||||
|
||||
@section('content')
|
||||
<style>
|
||||
.report-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
</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;">
|
||||
<div>
|
||||
<label for="jenis_laporan">Jenis Laporan</label>
|
||||
<select id="jenis_laporan" name="jenis_laporan">
|
||||
<option value="pembelian" @selected($jenisLaporan === 'pembelian')>Laporan Pembelian</option>
|
||||
<option value="penjualan" @selected($jenisLaporan === 'penjualan')>Laporan Penjualan</option>
|
||||
<option value="rekap_pembelian" @selected($jenisLaporan === 'rekap_pembelian')>Rekap Pembelian</option>
|
||||
<option value="rekap_penjualan" @selected($jenisLaporan === 'rekap_penjualan')>Rekap Penjualan</option>
|
||||
<option value="barang_hilang" @selected($jenisLaporan === 'barang_hilang')>Laporan Barang Hilang</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tanggal_mulai">Tanggal Mulai</label>
|
||||
<input id="tanggal_mulai" type="date" name="tanggal_mulai" value="{{ $tanggalMulai }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="tanggal_selesai">Tanggal Selesai</label>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@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)
|
||||
<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)
|
||||
<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)
|
||||
<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)
|
||||
<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>
|
||||
</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>
|
||||
<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
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
@extends('layouts.sidebar')
|
||||
|
||||
@section('title', 'User Management')
|
||||
@section('active_menu', 'users')
|
||||
|
||||
@section('content')
|
||||
<style>
|
||||
.inline-user-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 120px 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="table-card">
|
||||
<h2>Tambah User</h2>
|
||||
<form action="{{ route('users.store') }}" method="POST" style="display:grid; gap:10px;">
|
||||
@csrf
|
||||
<input type="text" name="name" placeholder="Nama" value="{{ old('name') }}" required>
|
||||
<input type="email" name="email" placeholder="Email" value="{{ old('email') }}" required>
|
||||
<input type="password" name="password" placeholder="Password (min. 6 karakter)" required>
|
||||
<select name="role" required>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
<div>
|
||||
<button type="submit">Simpan</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<h2>Daftar User</h2>
|
||||
<table class="app-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nama</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($users as $item)
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div style="display:flex; align-items:start; gap:8px;">
|
||||
<form action="{{ route('users.update', $item) }}" method="POST" class="inline-user-form" style="flex:1;">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="text" name="name" value="{{ $item->name }}" required>
|
||||
<input type="email" name="email" value="{{ $item->email }}" required>
|
||||
<select name="role" required>
|
||||
<option value="admin" @selected($item->role === 'admin')>Admin</option>
|
||||
<option value="owner" @selected($item->role === 'owner')>Owner</option>
|
||||
</select>
|
||||
<input type="password" name="password" placeholder="Password baru (opsional)">
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
|
||||
<form action="{{ route('users.destroy', $item) }}" method="POST" onsubmit="return confirm('Yakin hapus user ini?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" style="background:#b91c1c; border-color:#b91c1c;">Hapus</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4">Belum ada user.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endsection
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Artisan::command('user:reset-password {email} {password}', function (string $email, string $password) {
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
$this->error("Tidak ada user dengan email: {$email}");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$user->update(['password' => Hash::make($password)]);
|
||||
$this->info("Password untuk [{$email}] berhasil diatur ulang.");
|
||||
|
||||
return 0;
|
||||
})->purpose('Atur ulang password user lewat CLI (pemulihan jika admin tidak bisa login ke web)');
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\ProductController;
|
||||
use App\Http\Controllers\ReportController;
|
||||
use App\Http\Controllers\StockMovementController;
|
||||
use App\Http\Controllers\UserManagementController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('/login', [LoginController::class, 'index'])->name('login');
|
||||
Route::post('/login', [LoginController::class, 'store'])->name('login.store');
|
||||
Route::get('/', function () {
|
||||
return redirect()->route('login');
|
||||
});
|
||||
});
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||
|
||||
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
|
||||
Route::post('/products', [ProductController::class, 'store'])->name('products.store');
|
||||
Route::put('/products/{product}', [ProductController::class, 'update'])->name('products.update');
|
||||
|
||||
Route::delete('/products/{product}', [ProductController::class, 'destroy'])->name('products.destroy');
|
||||
Route::get('/barang-masuk', [StockMovementController::class, 'incoming'])->name('stock-movements.incoming');
|
||||
Route::post('/barang-masuk', [StockMovementController::class, 'storeIncoming'])->name('stock-movements.incoming.store');
|
||||
Route::get('/barang-keluar', [StockMovementController::class, 'outgoing'])->name('stock-movements.outgoing');
|
||||
Route::post('/barang-keluar', [StockMovementController::class, 'storeOutgoing'])->name('stock-movements.outgoing.store');
|
||||
Route::get('/barang-hilang', [StockMovementController::class, 'lost'])->name('stock-movements.lost');
|
||||
Route::post('/barang-hilang', [StockMovementController::class, 'storeLost'])->name('stock-movements.lost.store');
|
||||
|
||||
Route::get('/admin/users', [UserManagementController::class, 'index'])->name('users.index');
|
||||
Route::post('/admin/users', [UserManagementController::class, 'store'])->name('users.store');
|
||||
Route::put('/admin/users/{user}', [UserManagementController::class, 'update'])->name('users.update');
|
||||
Route::delete('/admin/users/{user}', [UserManagementController::class, 'destroy'])->name('users.destroy');
|
||||
|
||||
Route::get('/admin/laporan/pembelian', [ReportController::class, 'pembelian'])->name('reports.pembelian');
|
||||
Route::get('/admin/laporan/penjualan', [ReportController::class, 'penjualan'])->name('reports.penjualan');
|
||||
Route::get('/admin/laporan/rekap-pembelian', [ReportController::class, 'rekapPembelian'])->name('reports.rekap-pembelian');
|
||||
Route::get('/admin/laporan/rekap-penjualan', [ReportController::class, 'rekapPenjualan'])->name('reports.rekap-penjualan');
|
||||
Route::get('/admin/laporan/barang-hilang', [ReportController::class, 'barangHilang'])->name('reports.barang-hilang');
|
||||
|
||||
Route::get('/admin/laporan/pembelian/export', [ReportController::class, 'exportPembelian'])->name('reports.pembelian.export');
|
||||
Route::get('/admin/laporan/penjualan/export', [ReportController::class, 'exportPenjualan'])->name('reports.penjualan.export');
|
||||
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');
|
||||
})->name('reports.stock');
|
||||
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
|
||||
});
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
*
|
||||
!private/
|
||||
!public/
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
compiled.php
|
||||
config.php
|
||||
down
|
||||
events.scanned.php
|
||||
maintenance.php
|
||||
routes.php
|
||||
routes.scanned.php
|
||||
schedule-*
|
||||
services.json
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
*
|
||||
!data/
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
refresh: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ['**/storage/framework/views/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue