Initial commit: Struktur aplikasi ilycats
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\ActivityLog;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class CleanActivityLogs extends Command
|
||||
{
|
||||
// Nama perintah yang akan dipanggil di terminal
|
||||
protected $signature = 'activity-log:clean';
|
||||
|
||||
// Deskripsi perintah
|
||||
protected $description = 'Menghapus log aktivitas yang sudah lebih dari 30 hari';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$days = 30;
|
||||
$date = Carbon::now()->subDays($days);
|
||||
|
||||
// Menghitung jumlah log yang akan dihapus untuk laporan di terminal
|
||||
$count = ActivityLog::where('created_at', '<', $date)->count();
|
||||
|
||||
if ($count > 0) {
|
||||
ActivityLog::where('created_at', '<', $date)->delete();
|
||||
$this->info("Berhasil menghapus {$count} log lama.");
|
||||
} else {
|
||||
$this->info("Tidak ada log lama yang perlu dihapus.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
|
||||
class ActivityLogExport implements FromCollection, WithHeadings, WithMapping
|
||||
{
|
||||
public function collection()
|
||||
{
|
||||
return ActivityLog::with('user')->latest()->get();
|
||||
}
|
||||
|
||||
// Header Excel
|
||||
public function headings(): array
|
||||
{
|
||||
return ['Waktu', 'Pelaksana', 'Aksi', 'Deskripsi', 'IP Address'];
|
||||
}
|
||||
|
||||
// Mapping Data per Baris
|
||||
public function map($log): array
|
||||
{
|
||||
return [
|
||||
$log->created_at->format('d/m/Y H:i:s'),
|
||||
$log->user->name ?? 'System',
|
||||
$log->action,
|
||||
$log->description,
|
||||
$log->ip_address,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Cat;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
|
||||
class CatsExport implements FromCollection
|
||||
{
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
return Cat::all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
|
||||
class FoodTemplateExport implements WithHeadings
|
||||
{
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'nama_makanan',
|
||||
'qty',
|
||||
'unit',
|
||||
'expired_date',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\{FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles, WithEvents, WithCustomStartCell};
|
||||
use Maatwebsite\Excel\Events\AfterSheet;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class MattingExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles, WithEvents, WithCustomStartCell
|
||||
{
|
||||
protected $data;
|
||||
|
||||
public function __construct($data) {
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function collection() {
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function startCell(): string {
|
||||
return 'A7';
|
||||
}
|
||||
|
||||
public function headings(): array {
|
||||
return [
|
||||
'ID',
|
||||
'NAMA KUCING (♂/♀)',
|
||||
'PARENT (SIRE x DAM)',
|
||||
'REGISTRY',
|
||||
'KODE EMS',
|
||||
'NO. PEDIGREE',
|
||||
'NO. MICROCHIP',
|
||||
'TGL KAWIN',
|
||||
'ESTIMASI LAHIR (EDD)',
|
||||
'STATUS'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @var \App\Models\Matting $item
|
||||
*/
|
||||
public function map($item): array {
|
||||
// Menggunakan Accessor edd dari Model Matting
|
||||
$eddDate = $item->edd ? $item->edd->format('d/m/Y') : '-';
|
||||
$tglKawin = $item->tgl_kawin_awal ? $item->tgl_kawin_awal->format('d/m/Y') : '-';
|
||||
|
||||
// Kita buat mapping yang mencakup data Sire & Dam dalam baris yang informatif
|
||||
return [
|
||||
$item->id,
|
||||
($item->sire?->nama_kucing ?? 'N/A') . ' & ' . ($item->dam?->nama_kucing ?? 'N/A'),
|
||||
($item->sire?->nama_kucing ?? '-') . ' x ' . ($item->dam?->nama_kucing ?? '-'),
|
||||
($item->dam?->pedigree ?? '-'),
|
||||
'S: ' . ($item->ems_bapak ?? '-') . ' / D: ' . ($item->ems_ibu ?? '-'),
|
||||
'S: ' . ($item->no_pedigree_bapak ?? 'Non-Ped') . ' / D: ' . ($item->no_pedigree_ibu ?? 'Non-Ped'),
|
||||
'S: ' . ($item->no_microchip_bapak ?? '-') . ' / D: ' . ($item->no_microchip_ibu ?? '-'),
|
||||
$tglKawin,
|
||||
$eddDate,
|
||||
strtoupper($item->status ?? 'PROSES')
|
||||
];
|
||||
}
|
||||
|
||||
public function registerEvents(): array {
|
||||
return [
|
||||
AfterSheet::class => function(AfterSheet $event) {
|
||||
$sheet = $event->sheet;
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - STRATEGIC BREEDING REPORT');
|
||||
$sheet->setCellValue('A2', 'Dicetak Oleh: ' . Auth::user()->name);
|
||||
$sheet->setCellValue('A3', 'Tanggal Cetak: ' . now()->translatedFormat('d F Y H:i'));
|
||||
$sheet->setCellValue('A4', 'Total Data: ' . $this->data->count() . ' Records');
|
||||
$sheet->setCellValue('A5', 'Sektor: PARENTAL INTELLIGENCE UNIT');
|
||||
|
||||
$sheet->getStyle('A1')->getFont()->setSize(16)->setBold(true);
|
||||
$sheet->getStyle('A2:A5')->getFont()->setBold(true);
|
||||
|
||||
// Memberikan border ke seluruh tabel data
|
||||
$lastRow = $this->data->count() + 7;
|
||||
$sheet->getStyle('A7:J' . $lastRow)->getBorders()->getAllBorders()->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet) {
|
||||
return [
|
||||
7 => [
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => 'solid', 'startColor' => ['rgb' => '4F46E5']],
|
||||
'alignment' => ['horizontal' => 'center'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
|
||||
class MedicineTemplateExport implements FromCollection, WithHeadings, WithTitle
|
||||
{
|
||||
public function collection()
|
||||
{
|
||||
// Contoh data untuk user
|
||||
return collect([
|
||||
[
|
||||
'nama_obat' => 'Amoxicillin Syrup',
|
||||
'stok' => 10.5,
|
||||
'satuan' => 'ML',
|
||||
'kadaluarsa' => '2026-12-31'
|
||||
],
|
||||
[
|
||||
'nama_obat' => 'Drontal Cat',
|
||||
'stok' => 20,
|
||||
'satuan' => 'Tablet',
|
||||
'kadaluarsa' => '2027-05-20'
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['nama_obat', 'stok', 'satuan', 'kadaluarsa'];
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Template Import Obat';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
|
||||
class SupplyTemplateExport implements WithHeadings, ShouldAutoSize
|
||||
{
|
||||
/**
|
||||
* Mendefinisikan judul kolom untuk template.
|
||||
* * @return array
|
||||
*/
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'nama_perlengkapan',
|
||||
'qty',
|
||||
'unit',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\{FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles, WithEvents, WithCustomStartCell};
|
||||
use Maatwebsite\Excel\Events\AfterSheet;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class UnifiedReportExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles, WithEvents, WithCustomStartCell
|
||||
{
|
||||
protected $data;
|
||||
protected $module;
|
||||
|
||||
public function __construct($data, $module)
|
||||
{
|
||||
$this->data = $data;
|
||||
$this->module = $module;
|
||||
}
|
||||
|
||||
public function collection()
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data tabel dimulai dari baris ke-7 (setelah metadata)
|
||||
*/
|
||||
public function startCell(): string
|
||||
{
|
||||
return 'A7';
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return match ($this->module) {
|
||||
'cats' => ['ID', 'NAMA KUCING', 'RAS', 'GENDER', 'TGL LAHIR', 'STATUS', 'PIC STAFF'],
|
||||
'vaccine', 'deworming', 'flea', 'grooming' => ['ID', 'NAMA KUCING', 'TINDAKAN', 'JADWAL', 'STATUS', 'PIC STAFF'],
|
||||
'medicine', 'food', 'supplies' => ['ID', 'NAMA PRODUK', 'STOK', 'SATUAN', 'TGL EXPIRED', 'PIC STAFF'],
|
||||
default => ['ID', 'NAMA', 'DETAIL', 'TANGGAL', 'PIC']
|
||||
};
|
||||
}
|
||||
|
||||
public function map($item): array
|
||||
{
|
||||
$dateCol = match($this->module) {
|
||||
'cats' => 'tanggal_lahir',
|
||||
'medicine', 'food', 'supplies' => 'expired_date',
|
||||
'vaccine' => 'jadwal_vaksin',
|
||||
'deworming' => 'jadwal_obat_cacing',
|
||||
'flea' => 'jadwal_obat_kutu',
|
||||
'grooming' => 'jadwal_grooming',
|
||||
default => 'created_at'
|
||||
};
|
||||
|
||||
$dateValue = $item->{$dateCol} ? Carbon::parse($item->{$dateCol})->format('d/m/Y') : 'Not Set';
|
||||
|
||||
return match ($this->module) {
|
||||
'cats' => [
|
||||
$item->id, $item->nama_kucing, $item->ras, $item->jenis_kelamin,
|
||||
$dateValue, $item->riwayat_sakit_terakhir ? 'IN TREATMENT' : 'HEALTHY',
|
||||
$item->pic->name ?? 'System'
|
||||
],
|
||||
'vaccine', 'deworming', 'flea', 'grooming' => [
|
||||
$item->id, $item->nama_kucing, strtoupper($this->module),
|
||||
$dateValue, $this->getScheduleStatus($item->{$dateCol}),
|
||||
$item->pic->name ?? 'System'
|
||||
],
|
||||
'medicine', 'food', 'supplies' => [
|
||||
$item->id, $item->nama_obat ?? $item->nama_makanan ?? $item->nama_perlengkapan,
|
||||
$item->qty, $item->unit, $dateValue, $item->pic->name ?? 'System'
|
||||
],
|
||||
default => [
|
||||
$item->id, $item->nama_kucing ?? 'Data', $item->ras ?? '-', $dateValue, $item->pic->name ?? 'System'
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private function getScheduleStatus($date) {
|
||||
if (!$date) return 'NOT SET';
|
||||
return Carbon::parse($date)->isPast() ? 'OVERDUE' : 'SCHEDULED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Menulis Metadata di bagian atas Sheet
|
||||
*/
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [
|
||||
AfterSheet::class => function(AfterSheet $event) {
|
||||
$sheet = $event->sheet;
|
||||
|
||||
// Menulis Metadata
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - STRATEGIC REPORT');
|
||||
$sheet->setCellValue('A2', 'Dicetak Oleh: ' . Auth::user()->name);
|
||||
$sheet->setCellValue('A3', 'Tanggal Cetak: ' . now()->translatedFormat('d F Y H:i'));
|
||||
$sheet->setCellValue('A4', 'Total Data: ' . $this->data->count() . ' Records');
|
||||
$sheet->setCellValue('A5', 'Status Laporan: LAPORAN ' . strtoupper($this->module));
|
||||
|
||||
// Styling Metadata
|
||||
$sheet->getStyle('A1')->getFont()->setSize(14)->setBold(true);
|
||||
$sheet->getStyle('A2:A5')->getFont()->setBold(true);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
// Style untuk Header Tabel (Sekarang di baris 7)
|
||||
7 => [
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => [
|
||||
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => '4F46E5']
|
||||
],
|
||||
'alignment' => ['horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER]
|
||||
],
|
||||
// Border untuk seluruh tabel
|
||||
'A7:G' . (7 + $this->data->count()) => [
|
||||
'borders' => [
|
||||
'allBorders' => [
|
||||
'borderStyle' => \PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN,
|
||||
'color' => ['rgb' => 'E2E8F0'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
class WhatsappHelper
|
||||
{
|
||||
public static function send($target, $message)
|
||||
{
|
||||
$token = "YOUR_API_TOKEN";
|
||||
$curl = curl_init();
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_URL => 'https://api.fonnte.com/send',
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => ['target' => $target, 'message' => $message],
|
||||
CURLOPT_HTTPHEADER => ["Authorization: $token"],
|
||||
]);
|
||||
curl_exec($curl);
|
||||
curl_close($curl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Breeding;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\{Matting, Cat, Kitten};
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\{Request, RedirectResponse, JsonResponse};
|
||||
use Illuminate\Support\Facades\{DB, Storage};
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MattingController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$mattings = Matting::with(['sire', 'dam'])
|
||||
->withCount('kittens')
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
return view('pages.matting.index', compact('mattings'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$sires = Cat::where('jenis_kelamin', 'Jantan')->get(['id', 'nama_kucing', 'ras']);
|
||||
$dams = Cat::where('jenis_kelamin', 'Betina')->get(['id', 'nama_kucing', 'ras']);
|
||||
return view('pages.matting.create', compact('sires', 'dams'));
|
||||
}
|
||||
|
||||
public function getCatData($id): JsonResponse
|
||||
{
|
||||
$cat = Cat::find($id);
|
||||
if (!$cat) return response()->json(['error' => 'Kucing tidak ditemukan'], 404);
|
||||
|
||||
return response()->json([
|
||||
'tgl_lahir' => $cat->tanggal_lahir ? $cat->tanggal_lahir->format('Y-m-d') : '',
|
||||
'pedigree' => $cat->pedigree ?? '',
|
||||
'microchip' => $cat->microchip ?? '',
|
||||
'kode_ems' => $cat->kode_ems ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* STORE: Menyimpan Data Breeding Baru
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->validateMatting($request);
|
||||
|
||||
return DB::transaction(function () use ($request) {
|
||||
// 1. Sinkronisasi Data Induk (Update profil bapak/ibu di tabel cats)
|
||||
$this->syncParentData($request);
|
||||
|
||||
// 2. Simpan Transaksi Matting
|
||||
$matting = Matting::create(array_merge($request->except('kittens'), [
|
||||
'tanggal_kawin' => $request->tgl_kawin_awal,
|
||||
'user_id' => auth()->id(),
|
||||
'ems_bapak' => $request->ems_bapak,
|
||||
'ems_ibu' => $request->ems_ibu,
|
||||
]));
|
||||
|
||||
// 3. Simpan Kittens
|
||||
if ($request->has('kittens')) {
|
||||
foreach ($request->kittens as $index => $data) {
|
||||
$this->saveKittenData($matting, $data, $index, $request);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logAction('MATTING_CREATED', $matting, "Registrasi Breeding Baru");
|
||||
return redirect()->route('matting.index')->with('success', 'Data Breeding & Anak berhasil disimpan!');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EDIT: Menampilkan Form Edit (Metode yang tadi hilang)
|
||||
*/
|
||||
public function edit(Matting $matting): View
|
||||
{
|
||||
// Load relasi agar data anak muncul di form edit
|
||||
$matting->load('kittens.cat');
|
||||
|
||||
$sires = Cat::where('jenis_kelamin', 'Jantan')->get(['id', 'nama_kucing', 'ras']);
|
||||
$dams = Cat::where('jenis_kelamin', 'Betina')->get(['id', 'nama_kucing', 'ras']);
|
||||
|
||||
return view('pages.matting.edit', compact('matting', 'sires', 'dams'));
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE: Memperbarui Data Breeding & Sinkronisasi Ulang Kittens
|
||||
*/
|
||||
public function update(Request $request, Matting $matting): RedirectResponse
|
||||
{
|
||||
$this->validateMatting($request);
|
||||
|
||||
return DB::transaction(function () use ($request, $matting) {
|
||||
// 1. Update profil Bapak/Ibu di tabel 'cats'
|
||||
$this->syncParentData($request);
|
||||
|
||||
// 2. Update data di tabel 'mattings'
|
||||
$matting->update(array_merge($request->except('kittens'), [
|
||||
'tanggal_kawin' => $request->tgl_kawin_awal,
|
||||
'ems_bapak' => $request->ems_bapak,
|
||||
'ems_ibu' => $request->ems_ibu,
|
||||
]));
|
||||
|
||||
// 3. Sinkronisasi Kittens (CRUD Logic)
|
||||
$requestKittenIds = collect($request->kittens)->pluck('id')->filter()->toArray();
|
||||
|
||||
// Hapus Kitten yang tidak ada di form lagi
|
||||
$kittensToDelete = $matting->kittens()->whereNotIn('id', $requestKittenIds)->get();
|
||||
foreach ($kittensToDelete as $kDelete) {
|
||||
if ($kDelete->cat_id) Cat::find($kDelete->cat_id)?->delete();
|
||||
$kDelete->delete();
|
||||
}
|
||||
|
||||
if ($request->has('kittens')) {
|
||||
foreach ($request->kittens as $index => $data) {
|
||||
if (isset($data['id']) && !empty($data['id'])) {
|
||||
$kitten = Kitten::find($data['id']);
|
||||
if ($kitten) {
|
||||
// Update profil master anak di tabel 'cats'
|
||||
Cat::where('id', $kitten->cat_id)->update([
|
||||
'nama_kucing' => $data['nama'],
|
||||
'jenis_kelamin' => $data['jenis_kelamin'],
|
||||
'tanggal_lahir' => $data['tgl_lahir'],
|
||||
'kode_ems' => $data['kode_ems'] ?? null,
|
||||
'registry' => $data['registry'] ?? [],
|
||||
]);
|
||||
// Update record silsilah di tabel 'kittens'
|
||||
$kitten->update([
|
||||
'nama' => $data['nama'],
|
||||
'tgl_lahir' => $data['tgl_lahir'],
|
||||
'jenis_kelamin' => $data['jenis_kelamin'],
|
||||
'kode_ems' => $data['kode_ems'] ?? null,
|
||||
'is_pedigree' => isset($data['is_pedigree']),
|
||||
'registry' => $data['registry'] ?? [],
|
||||
]);
|
||||
$this->handlePedigreeUploads($kitten, $index, $request);
|
||||
}
|
||||
} else {
|
||||
$this->saveKittenData($matting, $data, $index, $request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->logAction('MATTING_UPDATED', $matting, "Update Breeding Record");
|
||||
return redirect()->route('matting.index')->with('success', 'Perubahan berhasil disimpan!');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SINKRONISASI DATA INDUK (EMS Fix)
|
||||
*/
|
||||
private function syncParentData(Request $request)
|
||||
{
|
||||
Cat::where('id', $request->sire_id)->update([
|
||||
'pedigree' => $request->no_pedigree_bapak,
|
||||
'microchip' => $request->no_microchip_bapak,
|
||||
'tanggal_lahir' => $request->tgl_lahir_sire,
|
||||
'kode_ems' => $request->ems_bapak // Sinkron EMS Bapak
|
||||
]);
|
||||
|
||||
Cat::where('id', $request->dam_id)->update([
|
||||
'pedigree' => $request->no_pedigree_ibu,
|
||||
'microchip' => $request->no_microchip_ibu,
|
||||
'tanggal_lahir' => $request->tgl_lahir_dam,
|
||||
'kode_ems' => $request->ems_ibu // Sinkron EMS Ibu
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* SIMPAN DATA KITTEN (EMS & Registry Fix)
|
||||
*/
|
||||
private function saveKittenData($matting, $data, $index, $request)
|
||||
{
|
||||
$newCat = Cat::create([
|
||||
'nama_kucing' => $data['nama'],
|
||||
'ras' => $matting->dam->ras ?? 'Persian',
|
||||
'jenis_kelamin' => $data['jenis_kelamin'],
|
||||
'induk_jantan_id' => $matting->sire_id,
|
||||
'induk_betina_id' => $matting->dam_id,
|
||||
'status' => 'Kitten',
|
||||
'tanggal_lahir' => $data['tgl_lahir'],
|
||||
'kode_ems' => $data['kode_ems'] ?? null,
|
||||
'registry' => $data['registry'] ?? [],
|
||||
'pic_id' => auth()->id(),
|
||||
'lokasi_kandang' => $matting->dam->lokasi_kandang ?? 'Lorong',
|
||||
]);
|
||||
|
||||
$kitten = new Kitten([
|
||||
'cat_id' => $newCat->id,
|
||||
'matting_id' => $matting->id,
|
||||
'nama' => $data['nama'],
|
||||
'tgl_lahir' => $data['tgl_lahir'],
|
||||
'jenis_kelamin' => $data['jenis_kelamin'],
|
||||
'kode_ems' => $data['kode_ems'] ?? null,
|
||||
'is_pedigree' => isset($data['is_pedigree']),
|
||||
'registry' => $data['registry'] ?? [],
|
||||
]);
|
||||
|
||||
$this->handlePedigreeUploads($kitten, $index, $request);
|
||||
$kitten->save();
|
||||
}
|
||||
|
||||
private function handlePedigreeUploads($kitten, $index, $request)
|
||||
{
|
||||
for ($i = 1; $i <= 4; $i++) {
|
||||
$field = "foto_ped_$i";
|
||||
if ($request->hasFile("kittens.$index.$field")) {
|
||||
if ($kitten->$field) Storage::disk('public')->delete($kitten->$field);
|
||||
$kitten->$field = $request->file("kittens.$index.$field")->store('kittens/pedigrees', 'public');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(Matting $matting): RedirectResponse
|
||||
{
|
||||
return DB::transaction(function () use ($matting) {
|
||||
try {
|
||||
$pairName = ($matting->sire->nama_kucing ?? 'Sire') . ' x ' . ($matting->dam->nama_kucing ?? 'Dam');
|
||||
|
||||
// 1. Loop setiap data anak yang terhubung
|
||||
foreach ($matting->kittens as $kitten) {
|
||||
if ($kitten->cat_id) {
|
||||
$cat = Cat::find($kitten->cat_id);
|
||||
|
||||
if ($cat) {
|
||||
// Cek apakah anak ini punya data medis atau riwayat berat
|
||||
$hasMedical = $cat->treatments()->exists();
|
||||
$hasWeight = $cat->weightHistories()->exists();
|
||||
|
||||
if ($hasMedical || $hasWeight) {
|
||||
// JIKA ADA DATA TERKAIT, BATALKAN DAN BERI INFO
|
||||
return back()->with('error', "Gagal menghapus! Anak ({$cat->nama_kucing}) sudah memiliki data Rekam Medis/Berat Badan. Hapus data medis anak tersebut terlebih dahulu.");
|
||||
}
|
||||
|
||||
$cat->delete();
|
||||
}
|
||||
}
|
||||
$kitten->delete();
|
||||
}
|
||||
|
||||
// 2. Hapus data Matting utama
|
||||
$matting->delete();
|
||||
|
||||
$this->logAction('MATTING_DELETED', $matting, "Menghapus riwayat breeding: $pairName");
|
||||
|
||||
return redirect()->route('matting.index')->with('success', "Riwayat $pairName berhasil dihapus.");
|
||||
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
// Menangkap error database (Foreign Key Constraint)
|
||||
return back()->with('error', "Sistem tidak dapat menghapus data ini karena integritas database terganggu. Silakan periksa data anak yang terkait.");
|
||||
}
|
||||
});
|
||||
}
|
||||
private function validateMatting(Request $request)
|
||||
{
|
||||
return $request->validate([
|
||||
'sire_id' => 'required|exists:cats,id',
|
||||
'dam_id' => 'required|exists:cats,id',
|
||||
'tgl_kawin_awal' => 'required|date',
|
||||
'status' => 'required|in:proses,berhasil,gagal',
|
||||
'ems_bapak' => 'nullable|string',
|
||||
'ems_ibu' => 'nullable|string',
|
||||
'no_pedigree_bapak' => 'nullable|string',
|
||||
'no_pedigree_ibu' => 'nullable|string',
|
||||
'no_microchip_bapak'=> 'nullable|string',
|
||||
'no_microchip_ibu' => 'nullable|string',
|
||||
'kittens' => 'nullable|array',
|
||||
'kittens.*.nama' => 'required_with:kittens|string',
|
||||
'kittens.*.kode_ems'=> 'nullable|string',
|
||||
'kittens.*.registry'=> 'nullable|array',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\{Cat, User, Matting};
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\{Storage, DB, Gate}; // Tambahkan Gate
|
||||
use Illuminate\Support\Carbon;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
use Intervention\Image\Laravel\Facades\Image;
|
||||
|
||||
class CatController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
/**
|
||||
* Menampilkan daftar kucing dengan filter cerdas.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// Proteksi Akses: Read
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$query = Cat::query()->with(['pic', 'sire', 'dam']);
|
||||
|
||||
// 1. Pencarian Global
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_kucing', 'like', "%{$search}%")
|
||||
->orWhere('ras', 'like', "%{$search}%")
|
||||
->orWhere('microchip', 'like', "%{$search}%")
|
||||
->orWhere('pedigree', 'like', "%{$search}%")
|
||||
->orWhere('kode_ems', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Filter Spesifik
|
||||
if ($request->filled('status')) $query->where('status', $request->status);
|
||||
if ($request->filled('ras')) $query->where('ras', $request->ras);
|
||||
if ($request->filled('gender')) $query->where('jenis_kelamin', $request->gender);
|
||||
|
||||
$cats = $query->latest()->paginate(15)->withQueryString();
|
||||
|
||||
// 3. Statistik Dashboard (Tetap Utuh)
|
||||
$totalCats = Cat::count();
|
||||
$safeWords = ['sehat', 'normal', 'aktif', 'sembuh', '-', 'tidak ada', 'clean'];
|
||||
$sickCats = Cat::whereNotNull('riwayat_sakit_terakhir')
|
||||
->where(function($q) use ($safeWords) {
|
||||
foreach ($safeWords as $word) {
|
||||
$q->where('riwayat_sakit_terakhir', 'NOT LIKE', "%{$word}%");
|
||||
}
|
||||
})->count();
|
||||
|
||||
$breeds = Cat::distinct()->pluck('ras');
|
||||
|
||||
return view('pages.cats.index', compact('cats', 'totalCats', 'sickCats', 'breeds'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
// Proteksi Akses: Create
|
||||
Gate::authorize('check-access', ['cats', 'create']);
|
||||
|
||||
$sires = Cat::where('jenis_kelamin', 'Jantan')->get(['id', 'nama_kucing']);
|
||||
$dams = Cat::where('jenis_kelamin', 'Betina')->get(['id', 'nama_kucing']);
|
||||
$users = User::all(['id', 'name']);
|
||||
|
||||
return view('pages.cats.create', compact('sires', 'dams', 'users'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'create']);
|
||||
|
||||
$validated = $this->validateCat($request);
|
||||
|
||||
return DB::transaction(function () use ($request, $validated) {
|
||||
if ($request->hasFile('foto')) {
|
||||
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
|
||||
}
|
||||
|
||||
if (isset($validated['berat_badan'])) {
|
||||
$validated['berat_terakhir'] = $validated['berat_badan'];
|
||||
}
|
||||
|
||||
if (empty($validated['status'])) {
|
||||
$validated['status'] = ($request->induk_jantan_id || $request->induk_betina_id) ? 'Kitten' : 'Active';
|
||||
}
|
||||
|
||||
// Memastikan registry disimpan sebagai JSON jika model tidak melakukan casting
|
||||
if (isset($validated['registry'])) {
|
||||
$validated['registry'] = $validated['registry'];
|
||||
}
|
||||
|
||||
$cat = Cat::create($validated);
|
||||
$this->logAction('CREATED', $cat, "Mendaftarkan pasien baru: {$cat->nama_kucing}", 'Cat');
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "{$cat->nama_kucing} berhasil didaftarkan.");
|
||||
});
|
||||
}
|
||||
|
||||
public function show(Cat $cat): View
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$cat->load([
|
||||
'treatments' => fn($q) => $q->latest(),
|
||||
'sire', 'dam', 'pic',
|
||||
'weightHistories' => fn($q) => $q->latest()->limit(12)
|
||||
]);
|
||||
return view('pages.cats.show', compact('cat'));
|
||||
}
|
||||
|
||||
public function edit(Cat $cat): View
|
||||
{
|
||||
// Proteksi: Akses Update
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$sires = Cat::where('jenis_kelamin', 'Jantan')->where('id', '!=', $cat->id)->get(['id', 'nama_kucing']);
|
||||
$dams = Cat::where('jenis_kelamin', 'Betina')->where('id', '!=', $cat->id)->get(['id', 'nama_kucing']);
|
||||
$users = User::all(['id', 'name']);
|
||||
|
||||
return view('pages.cats.edit', compact('cat', 'sires', 'dams', 'users'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Cat $cat): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$validated = $this->validateCat($request, $cat->id);
|
||||
|
||||
return DB::transaction(function () use ($request, $validated, $cat) {
|
||||
if ($request->hasFile('foto')) {
|
||||
if ($cat->foto) Storage::disk('public')->delete($cat->foto);
|
||||
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
|
||||
}
|
||||
|
||||
if ($request->filled('berat_badan')) {
|
||||
$validated['berat_terakhir'] = $request->berat_badan;
|
||||
if ($cat->berat_badan != $request->berat_badan) {
|
||||
$cat->weightHistories()->create(['berat' => $request->berat_badan]);
|
||||
}
|
||||
}
|
||||
|
||||
$cat->update($validated);
|
||||
$this->logAction('UPDATED', $cat, "Update data medis/profil: {$cat->nama_kucing}", 'Cat');
|
||||
|
||||
return redirect()->route('cats.index')->with('success', 'Data berhasil diperbarui.');
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(Cat $cat): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'delete']);
|
||||
|
||||
$isParent = Matting::where('sire_id', $cat->id)->orWhere('dam_id', $cat->id)->exists();
|
||||
|
||||
if ($isParent) {
|
||||
return back()->with('error', "Gagal! {$cat->nama_kucing} tidak bisa dihapus karena sudah menjadi Induk.");
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($cat) {
|
||||
$name = $cat->nama_kucing;
|
||||
if ($cat->foto) Storage::disk('public')->delete($cat->foto);
|
||||
|
||||
$this->logAction('DELETED', $cat, "Menghapus data permanen: {$name}", 'Cat');
|
||||
$cat->delete();
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "Data {$name} telah dihapus.");
|
||||
});
|
||||
}
|
||||
|
||||
public function getCatData($id): JsonResponse
|
||||
{
|
||||
$cat = Cat::find($id);
|
||||
if (!$cat) return response()->json(['error' => 'Not found'], 404);
|
||||
|
||||
return response()->json([
|
||||
'tgl_lahir' => $cat->tanggal_lahir ? $cat->tanggal_lahir->format('Y-m-d') : '',
|
||||
'pedigree' => $cat->pedigree ?? '',
|
||||
'microchip' => $cat->microchip ?? '',
|
||||
'kode_ems' => $cat->kode_ems ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF ID Card / Medical Pass
|
||||
* PENYEMPURNAAN: Perbaikan Barcode (Base64 SVG)
|
||||
*/
|
||||
public function downloadPdf(Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$cat->load(['pic', 'sire', 'dam']);
|
||||
|
||||
// FIX: Konversi SVG ke Base64 agar tampil di domPDF tanpa Imagick
|
||||
$qrCodeRaw = QrCode::format('svg')
|
||||
->size(150)
|
||||
->margin(1)
|
||||
->generate(route('cats.show', $cat->id));
|
||||
|
||||
$qrCode = base64_encode($qrCodeRaw);
|
||||
|
||||
$pdf = Pdf::loadView('pages.cats.pdf-card', [
|
||||
'cat' => $cat,
|
||||
'qrCode' => $qrCode, // Dikirim sebagai Base64
|
||||
'printedAt' => Carbon::now()->translatedFormat('d F Y H:i')
|
||||
]);
|
||||
|
||||
return $pdf->setPaper([0, 0, 300, 500], 'portrait')
|
||||
->stream('MEDICAL_PASS_' . strtoupper($cat->nama_kucing) . '.pdf');
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* HELPERS (TETAP UTUH) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
private function validateCat(Request $request, $id = null)
|
||||
{
|
||||
return $request->validate([
|
||||
'nama_kucing' => 'required|string|max:255',
|
||||
'ras' => 'required|string',
|
||||
'jenis_kelamin' => 'required|in:Jantan,Betina',
|
||||
'tanggal_lahir' => 'nullable|date',
|
||||
'foto' => 'nullable|image|mimes:jpg,jpeg,png|max:3072',
|
||||
'registry' => 'nullable|array',
|
||||
'registry.*' => 'string',
|
||||
'pedigree' => 'nullable|string|max:255',
|
||||
'microchip' => 'nullable|string|max:255',
|
||||
'kode_ems' => 'nullable|string|max:255',
|
||||
'status' => 'nullable|string',
|
||||
'induk_jantan_id' => 'nullable|exists:cats,id',
|
||||
'induk_betina_id' => 'nullable|exists:cats,id',
|
||||
'lokasi_kandang' => 'nullable|string',
|
||||
'pic_id' => 'nullable|exists:users,id',
|
||||
'berat_badan' => 'nullable|numeric|min:0',
|
||||
'riwayat_sakit_terakhir' => 'nullable|string',
|
||||
'jadwal_vaksin' => 'nullable|date',
|
||||
'jadwal_obat_cacing' => 'nullable|date',
|
||||
'jadwal_obat_kutu' => 'nullable|date',
|
||||
'jadwal_grooming' => 'nullable|date',
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleImageUpload($file): string
|
||||
{
|
||||
$filename = 'cat_' . uniqid() . '.png';
|
||||
$path = 'cats/' . $filename;
|
||||
$img = Image::read($file)->cover(500, 500, 'center')->toPng();
|
||||
Storage::disk('public')->put($path, (string) $img);
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Medicine;
|
||||
use App\Models\Food;
|
||||
use App\Models\Supply;
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
/**
|
||||
* Menampilkan daftar kategori.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
// Mengambil kategori beserta hitungan item di dalamnya (optional untuk insight)
|
||||
$categories = Category::latest()->get();
|
||||
return view('pages.inventory.categories.index', compact('categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Menyimpan kategori baru.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:categories,name',
|
||||
'keywords' => 'required|string'
|
||||
]);
|
||||
|
||||
$category = Category::create($validated);
|
||||
|
||||
$this->logAction('CREATED_CATEGORY', $category, "Menambah kategori master: {$category->name}");
|
||||
|
||||
return back()->with('success', 'Kategori baru berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Menampilkan halaman edit (Hanya Admin).
|
||||
*/
|
||||
public function edit(Category $category): View
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Akses Ditolak.');
|
||||
}
|
||||
|
||||
return view('pages.inventory.categories.edit', compact('category'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Memperbarui data kategori.
|
||||
*/
|
||||
public function update(Request $request, Category $category): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:categories,name,' . $category->id,
|
||||
'keywords' => 'required|string',
|
||||
]);
|
||||
|
||||
$category->update($validated);
|
||||
|
||||
$this->logAction('UPDATED_CATEGORY', $category, "Memperbarui data kategori: {$category->name}");
|
||||
|
||||
return redirect()->route('categories.index')
|
||||
->with('success', "Kategori {$category->name} berhasil diperbarui.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sinkronisasi massal seluruh inventori berdasarkan kata kunci.
|
||||
*/
|
||||
public function recategorizeAll(): RedirectResponse
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Hanya Admin yang dapat melakukan sinkronisasi ulang.');
|
||||
}
|
||||
|
||||
try {
|
||||
$categories = Category::all();
|
||||
$updatedCount = 0;
|
||||
|
||||
// 1. Ambil semua data dari 3 modul inventori
|
||||
$inventories = [
|
||||
'Medicine' => Medicine::all(),
|
||||
'Food' => Food::all(),
|
||||
'Supply' => Supply::all(),
|
||||
];
|
||||
|
||||
foreach ($inventories as $type => $items) {
|
||||
foreach ($items as $item) {
|
||||
// Tentukan field nama berdasarkan model (nama_obat, nama_makanan, nama_perlengkapan)
|
||||
$fieldName = $this->getFieldName($type);
|
||||
$name = strtolower($item->$fieldName);
|
||||
$found = false;
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
$keywords = explode(',', strtolower($cat->keywords));
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$trimmed = trim($keyword);
|
||||
if (!empty($trimmed) && str_contains($name, $trimmed)) {
|
||||
$item->category_id = $cat->id;
|
||||
// Sinkronisasi kolom teks kategori jika ada
|
||||
if (isset($item->kategori)) { $item->kategori = $cat->name; }
|
||||
$item->save();
|
||||
$updatedCount++;
|
||||
$found = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Jika tidak cocok, arahkan ke kategori 'Umum'
|
||||
if (!$found) {
|
||||
$umum = Category::where('name', 'Umum')->first();
|
||||
$item->category_id = $umum ? $umum->id : null;
|
||||
if (isset($item->kategori)) { $item->kategori = 'Umum'; }
|
||||
$item->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->logAction('RECATEGORIZE_ALL', null, "Melakukan sinkronisasi massal kategori pada seluruh inventori");
|
||||
|
||||
return back()->with('success', "Sinkronisasi selesai! {$updatedCount} item berhasil diperbarui kategorinya.");
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', 'Gagal sinkronisasi: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper untuk menentukan field nama produk
|
||||
*/
|
||||
private function getFieldName($type) {
|
||||
return match($type) {
|
||||
'Medicine' => 'nama_obat',
|
||||
'Food' => 'nama_makanan',
|
||||
'Supply' => 'nama_perlengkapan',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Menghapus kategori (Hanya Admin).
|
||||
*/
|
||||
public function destroy(Category $category): RedirectResponse
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Akses Ditolak.');
|
||||
}
|
||||
|
||||
$categoryName = $category->name;
|
||||
|
||||
// Cek apakah masih ada item yang menggunakan kategori ini
|
||||
if ($category->medicines()->exists() || $category->foods()->exists() || $category->supplies()->exists()) {
|
||||
return back()->with('error', "Kategori '{$categoryName}' tidak bisa dihapus karena masih digunakan oleh beberapa item.");
|
||||
}
|
||||
|
||||
$this->logAction('DELETED_CATEGORY', null, "Menghapus kategori: {$categoryName}");
|
||||
|
||||
$category->delete();
|
||||
|
||||
return back()->with('success', 'Kategori berhasil dihapus.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\{Cat, Medicine, Food, Supply, Matting, Treatment};
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// 1. Statistik Utama
|
||||
$stats = [
|
||||
'total_cats' => Cat::count(),
|
||||
'total_staff' => \App\Models\User::count(),
|
||||
'sick_cats' => Cat::whereNotNull('riwayat_sakit_terakhir')->count(),
|
||||
'total_mating' => Matting::where('status', 'proses')->count(),
|
||||
];
|
||||
|
||||
// 2. Birth Watch: Persalinan 7 Hari ke Depan (Rumus: H+63)
|
||||
$upcomingBirths = Matting::with(['dam', 'sire', 'pic'])
|
||||
->where('status', 'proses')
|
||||
->whereNotNull('tanggal_kawin')
|
||||
->whereBetween('tanggal_kawin', [
|
||||
now()->subDays(70)->toDateString(),
|
||||
now()->subDays(56)->toDateString()
|
||||
])
|
||||
->get()
|
||||
->map(function ($mating) {
|
||||
$mating->edd = Carbon::parse($mating->tanggal_kawin)->addDays(63);
|
||||
$mating->days_to_go = (int) now()->diffInDays($mating->edd, false);
|
||||
return $mating;
|
||||
})
|
||||
->filter(fn($m) => $m->days_to_go >= -2 && $m->days_to_go <= 7)
|
||||
->sortBy('days_to_go');
|
||||
|
||||
// 3. Medical Alerts
|
||||
$medicalAlerts = [
|
||||
'vaccine' => Cat::whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<=', now()->addDays(3))->count(),
|
||||
'flea' => Cat::whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<=', now()->addDays(3))->count(),
|
||||
'deworming' => Cat::whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<=', now()->addDays(3))->count(),
|
||||
'grooming' => Cat::whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<=', now()->addDays(3))->count(),
|
||||
];
|
||||
|
||||
// 4. Inventory Critical (Stok < 5)
|
||||
$lowStock = [
|
||||
'medicines' => Medicine::where('qty', '<', 5)->get(['nama_obat as name', 'qty', 'unit']),
|
||||
'foods' => Food::where('qty', '<', 5)->get(['nama_makanan as name', 'qty', 'unit']),
|
||||
'supplies' => Supply::where('qty', '<', 5)->get(['nama_perlengkapan as name', 'qty', 'unit']),
|
||||
];
|
||||
$totalLowStock = collect($lowStock)->flatten(1)->count();
|
||||
|
||||
// 5. Chart Data: Sebaran Ras (Persian, British Shorthair, dll)
|
||||
$chartData = Cat::select('ras', DB::raw('count(*) as total'))->groupBy('ras')->get();
|
||||
|
||||
// 6. Weight Trends: Rata-rata Berat (6 Bulan Terakhir)
|
||||
$weightTrends = DB::table('weight_histories')
|
||||
->select(
|
||||
DB::raw('AVG(berat) as avg_weight'),
|
||||
DB::raw("DATE_FORMAT(tanggal_timbang, '%M') as month"),
|
||||
DB::raw('MONTH(tanggal_timbang) as month_num')
|
||||
)
|
||||
->where('tanggal_timbang', '>=', now()->subMonths(6))
|
||||
->groupBy('month', 'month_num')
|
||||
->orderBy('month_num')
|
||||
->get();
|
||||
|
||||
if ($weightTrends->isEmpty()) {
|
||||
$weightTrends = collect([(object)['month' => now()->format('F'), 'avg_weight' => 0]]);
|
||||
}
|
||||
|
||||
// 7. Recent Activity
|
||||
$recentTreatments = Treatment::with(['cat', 'pic'])->latest()->take(5)->get();
|
||||
|
||||
return view('pages.dashboard.index', compact(
|
||||
'stats',
|
||||
'upcomingBirths',
|
||||
'medicalAlerts',
|
||||
'lowStock',
|
||||
'totalLowStock',
|
||||
'chartData',
|
||||
'weightTrends',
|
||||
'recentTreatments'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Food;
|
||||
use App\Models\Category;
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class FoodController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
/**
|
||||
* Tampilkan daftar stok makanan.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Food::with('category');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_makanan', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
|
||||
$foods = $query->latest()->paginate(10);
|
||||
return view('pages.inventory.foods.index', compact('foods'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan form tambah.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('pages.inventory.foods.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan data makanan baru.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama_makanan' => 'required|string|max:255',
|
||||
'qty' => 'required|numeric|min:0',
|
||||
'unit' => 'required|string|max:50',
|
||||
'expired_date' => 'nullable|date|after_or_equal:today',
|
||||
'foto_produk' => 'nullable|image|max:1024',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('foto_produk')) {
|
||||
$validated['foto_produk'] = $request->file('foto_produk')->store('foods', 'public');
|
||||
}
|
||||
|
||||
// Simpan data & tangkap objeknya untuk logging
|
||||
$food = Food::create($validated);
|
||||
|
||||
// Tambahkan Log Aktivitas (Sangat penting untuk audit stok)
|
||||
$this->logAction('CREATED_FOOD', $food, "Menambah stok pakan baru: {$food->nama_makanan}");
|
||||
|
||||
// FIX: Tambahkan .index agar tidak error RouteNotFound
|
||||
return redirect()->route('inventory.foods.index')->with('success', 'Stok pakan berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan form edit.
|
||||
*/
|
||||
public function edit(Food $food): View
|
||||
{
|
||||
$categories = Category::all();
|
||||
return view('pages.inventory.foods.edit', compact('food', 'categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update data makanan.
|
||||
*/
|
||||
public function update(Request $request, Food $food): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama_makanan' => 'required|string|max:255',
|
||||
'qty' => 'required|numeric|min:0',
|
||||
'unit' => 'required|string|max:50',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'expired_date' => 'nullable|date|after_or_equal:today',
|
||||
'foto_produk' => 'nullable|image|max:1024',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('foto_produk')) {
|
||||
if ($food->foto_produk) {
|
||||
Storage::disk('public')->delete($food->foto_produk);
|
||||
}
|
||||
$validated['foto_produk'] = $request->file('foto_produk')->store('foods', 'public');
|
||||
}
|
||||
|
||||
$food->update($validated);
|
||||
|
||||
$this->logAction('UPDATED_FOOD', $food, "Memperbarui stok pakan: {$food->nama_makanan}");
|
||||
|
||||
// FIX: Tambahkan .index
|
||||
return redirect()->route('inventory.foods.index')->with('success', 'Data stok pakan berhasil diperbarui.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus data makanan.
|
||||
*/
|
||||
public function destroy(Food $food): RedirectResponse
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Hanya Admin yang dapat menghapus data stok pakan.');
|
||||
}
|
||||
|
||||
$foodName = $food->nama_makanan;
|
||||
|
||||
if ($food->foto_produk) {
|
||||
Storage::disk('public')->delete($food->foto_produk);
|
||||
}
|
||||
|
||||
// Log dilakukan SEBELUM delete
|
||||
$this->logAction('DELETED_FOOD', $food, "Menghapus data pakan: {$foodName}");
|
||||
|
||||
$food->delete();
|
||||
|
||||
// FIX: Tambahkan .index
|
||||
return redirect()->route('inventory.foods.index')->with('success', 'Stok pakan berhasil dihapus.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Medicine;
|
||||
use App\Models\Food;
|
||||
use App\Models\Supply;
|
||||
use App\Traits\LogsActivity; // Tambahkan Trait Log
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Exception;
|
||||
|
||||
class InventoryController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
/**
|
||||
* Tampilkan Inventori Obat.
|
||||
*/
|
||||
public function medicines(): View
|
||||
{
|
||||
// Model Medicine sudah mendukung casting expired_date & qty float
|
||||
$medicines = Medicine::latest()->paginate(10);
|
||||
return view('pages.inventory.medicines.index', compact('medicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan Inventori Makanan.
|
||||
*/
|
||||
public function foods(): View
|
||||
{
|
||||
$foods = Food::latest()->paginate(10);
|
||||
return view('pages.inventory.foods.index', compact('foods'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan Inventori Perlengkapan.
|
||||
*/
|
||||
public function supplies(): View
|
||||
{
|
||||
$supplies = Supply::latest()->paginate(10);
|
||||
return view('pages.inventory.supplies.index', compact('supplies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stok secara cepat (Fast Update).
|
||||
* Mendukung desimal (ml/butir) dan Audit Trail.
|
||||
*/
|
||||
public function updateStock(Request $request, string $type, int $id): RedirectResponse
|
||||
{
|
||||
// Pastikan validasi tetap mendukung desimal untuk obat cair
|
||||
$request->validate([
|
||||
'qty' => 'required|numeric|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
$item = match($type) {
|
||||
'medicine' => \App\Models\Medicine::findOrFail($id),
|
||||
'food' => \App\Models\Food::findOrFail($id),
|
||||
'supply' => \App\Models\Supply::findOrFail($id),
|
||||
default => abort(404),
|
||||
};
|
||||
|
||||
$oldQty = $item->qty;
|
||||
$item->update(['qty' => $request->qty]);
|
||||
|
||||
// FIX: Evaluasi nama barang di luar string agar tidak menyebabkan ParseError
|
||||
$itemName = $item->nama_obat ?? $item->nama_makanan ?? $item->nama_barang ?? 'Item';
|
||||
|
||||
$this->logAction(
|
||||
'STOCK_ADJUSTMENT',
|
||||
$item,
|
||||
"Penyesuaian stok " . ucfirst($type) . ": {$itemName} (Dari {$oldQty} ke {$request->qty})"
|
||||
);
|
||||
|
||||
return back()->with('success', 'Stok ' . ucfirst($type) . ' berhasil diperbarui.');
|
||||
|
||||
} catch (Exception $e) {
|
||||
return back()->with('error', 'Gagal memperbarui stok: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\{Matting, User, Cat};
|
||||
use App\Exports\MattingExport;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class MattingReportController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// 1. Eager Loading Lengkap (Sire, Dam, PIC, dan Detail Anak)
|
||||
$query = Matting::with(['sire', 'dam', 'pic', 'kittens.cat'])->withCount('kittens');
|
||||
// 2. Filter Nama Indukan (Sire atau Dam)
|
||||
if ($request->filled('parent_name')) {
|
||||
$name = $request->parent_name;
|
||||
$query->where(function($q) use ($name) {
|
||||
$q->whereHas('sire', fn($sq) => $sq->where('nama_kucing', 'like', "%{$name}%"))
|
||||
->orWhereHas('dam', fn($sq) => $sq->where('nama_kucing', 'like', "%{$name}%"));
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Filter Ras Dinamis
|
||||
if ($request->filled('ras')) {
|
||||
$ras = $request->ras;
|
||||
$query->where(function($q) use ($ras) {
|
||||
$q->whereHas('sire', fn($sq) => $sq->where('ras', $ras))
|
||||
->orWhereHas('dam', fn($sq) => $sq->where('ras', $ras));
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Filter Registry (ICA, CFA, WCF, dll)
|
||||
if ($request->filled('registry')) {
|
||||
$reg = $request->registry;
|
||||
$query->where(function($q) use ($reg) {
|
||||
$q->where('registry', $reg)
|
||||
->orWhereHas('sire', fn($sq) => $sq->where('registry', $reg))
|
||||
->orWhereHas('dam', fn($sq) => $sq->where('registry', $reg));
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Filter Periode Perkawinan
|
||||
if ($request->filled('start_date') && $request->filled('end_date')) {
|
||||
$query->whereBetween('tgl_kawin_awal', [
|
||||
Carbon::parse($request->start_date)->startOfDay(),
|
||||
Carbon::parse($request->end_date)->endOfDay()
|
||||
]);
|
||||
}
|
||||
|
||||
// 6. Filter Status Induk Betina
|
||||
if ($request->filled('status')) {
|
||||
$status = $request->status;
|
||||
$query->whereHas('dam', fn($q) => $q->where('status', $status));
|
||||
}
|
||||
|
||||
// 7. Handle Export
|
||||
if ($request->filled('export')) {
|
||||
$data = $query->latest()->get();
|
||||
return $this->processExport($data, $request->export);
|
||||
}
|
||||
|
||||
// 8. Eksekusi dengan Pagination
|
||||
$mattings = $query->latest()->paginate(15)->withQueryString();
|
||||
|
||||
// 9. Data Pendukung UI (Agar Dropdown di Blade berfungsi)
|
||||
$breeds = Cat::distinct()->whereNotNull('ras')->pluck('ras');
|
||||
$registries = ['ICA', 'CFA', 'WCF', 'FFF', 'LOOF'];
|
||||
$statuses = ['Breeding', 'Hamil', 'Teradopsi', 'RIP'];
|
||||
|
||||
return view('pages.reports.matting.index', compact(
|
||||
'mattings', 'breeds', 'registries', 'statuses'
|
||||
));
|
||||
|
||||
}
|
||||
|
||||
protected function processExport($data, $format)
|
||||
{
|
||||
$fileName = "Lineage_Report_" . now()->format('Ymd_His');
|
||||
|
||||
if ($format === 'xlsx') {
|
||||
return Excel::download(new MattingExport($data), "{$fileName}.xlsx");
|
||||
}
|
||||
|
||||
// Penyempurnaan PDF: Tambahkan opsi font dan akses file lokal (untuk logo)
|
||||
$pdf = Pdf::loadView('pages.reports.matting.pdf', [
|
||||
'data' => $data,
|
||||
// Kirim data setting jika ingin menampilkan logo/branding dinamis di PDF
|
||||
'setting' => \App\Models\Setting::pluck('value', 'key')
|
||||
]);
|
||||
|
||||
return $pdf->setPaper('a4', 'landscape')
|
||||
->setOption([
|
||||
'tempDir' => public_path(),
|
||||
'chroot' => public_path(),
|
||||
])
|
||||
->setWarnings(false) // Menghindari PDF korup karena error kecil di blade
|
||||
->download("{$fileName}.pdf");
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
// Eager loading mendalam untuk silsilah dan data anak
|
||||
$matting = Matting::with(['sire', 'dam', 'pic', 'kittens.cat'])->findOrFail($id);
|
||||
|
||||
return view('pages.reports.matting.show', compact('matting'));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Treatment;
|
||||
use App\Models\Medicine;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class MedicalDashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// 1. Tren Penyakit Bulanan (Data 6 Bulan Terakhir)
|
||||
$monthlyTrends = Treatment::select(
|
||||
DB::raw('MONTHNAME(tanggal) as month'),
|
||||
DB::raw('COUNT(*) as total')
|
||||
)
|
||||
->where('tanggal', '>=', now()->subMonths(6))
|
||||
->groupBy('month', DB::raw('MONTH(tanggal)'))
|
||||
->orderBy(DB::raw('MONTH(tanggal)'))
|
||||
->get();
|
||||
|
||||
// 2. Daftar Obat Paling Sering Digunakan (Top 5)
|
||||
$topMedicines = Treatment::select('nama_obat', DB::raw('SUM(qty_used) as total_used'))
|
||||
->groupBy('nama_obat')
|
||||
->orderByDesc('total_used')
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
// 3. Keluhan/Penyakit yang Paling Sering Muncul
|
||||
$topComplaints = Treatment::select('keluhan', DB::raw('COUNT(*) as total'))
|
||||
->groupBy('keluhan')
|
||||
->orderByDesc('total')
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
return view('pages.dashboard.medical', compact('monthlyTrends', 'topMedicines', 'topComplaints'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Medicine;
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Imports\MedicineImport;
|
||||
use App\Exports\MedicineTemplateExport;
|
||||
|
||||
class MedicineController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
/**
|
||||
* Tampilkan daftar obat-obatan (Index).
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Medicine::query();
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_obat', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
|
||||
$medicines = $query->latest()->paginate(10);
|
||||
|
||||
return view('pages.inventory.medicines.index', compact('medicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan form tambah obat.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('pages.inventory.medicines.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan data obat baru.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama_obat' => 'required|string|max:255',
|
||||
'qty' => 'required|numeric|min:0',
|
||||
'unit' => 'required|string|max:50',
|
||||
'expired_date' => 'nullable|date|after_or_equal:today',
|
||||
'foto_produk' => 'nullable|image|max:1024',
|
||||
]);
|
||||
|
||||
// Catatan: Logika pembuatan slug sudah dipindah ke Model Medicine (event saving)
|
||||
// agar Controller lebih ringkas.
|
||||
|
||||
if ($request->hasFile('foto_produk')) {
|
||||
$validated['foto_produk'] = $request->file('foto_produk')->store('medicines', 'public');
|
||||
}
|
||||
|
||||
$medicine = Medicine::create($validated);
|
||||
|
||||
$this->logAction('CREATED_MEDICINE', $medicine, "Menambah obat baru: {$medicine->nama_obat}");
|
||||
|
||||
// FIX: Tambahkan .index agar rute terdeteksi
|
||||
return redirect()->route('inventory.medicines.index')->with('success', 'Obat berhasil ditambahkan ke inventori.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan form edit obat.
|
||||
*/
|
||||
public function edit(Medicine $medicine): View
|
||||
{
|
||||
return view('pages.inventory.medicines.edit', compact('medicine'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update data obat.
|
||||
*/
|
||||
public function update(Request $request, Medicine $medicine): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama_obat' => 'required|string|max:255',
|
||||
'qty' => 'required|numeric|min:0',
|
||||
'unit' => 'required|string|max:50',
|
||||
'expired_date' => 'nullable|date|after_or_equal:today',
|
||||
'foto_produk' => 'nullable|image|max:1024',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('foto_produk')) {
|
||||
if ($medicine->foto_produk) {
|
||||
Storage::disk('public')->delete($medicine->foto_produk);
|
||||
}
|
||||
$validated['foto_produk'] = $request->file('foto_produk')->store('medicines', 'public');
|
||||
}
|
||||
|
||||
$medicine->update($validated);
|
||||
|
||||
$this->logAction('UPDATED_MEDICINE', $medicine, "Memperbarui data obat: {$medicine->nama_obat}");
|
||||
|
||||
// FIX: Tambahkan .index
|
||||
return redirect()->route('inventory.medicines.index')->with('success', 'Data obat berhasil diperbarui.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus obat.
|
||||
*/
|
||||
public function destroy(Medicine $medicine): RedirectResponse
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Hanya Admin yang diizinkan menghapus data.');
|
||||
}
|
||||
|
||||
// Proteksi Integritas rekam medis
|
||||
if ($medicine->treatments()->exists()) {
|
||||
return back()->with('error', "Gagal: Obat '{$medicine->nama_obat}' sudah tercatat di rekam medis kucing.");
|
||||
}
|
||||
|
||||
if ($medicine->foto_produk) {
|
||||
Storage::disk('public')->delete($medicine->foto_produk);
|
||||
}
|
||||
|
||||
$medicineName = $medicine->nama_obat;
|
||||
|
||||
// PENTING: Log dilakukan SEBELUM delete
|
||||
$this->logAction('DELETED_MEDICINE', $medicine, "Menghapus data obat: {$medicineName}");
|
||||
|
||||
$medicine->delete();
|
||||
|
||||
// FIX: Tambahkan .index
|
||||
return redirect()->route('inventory.medicines.index')->with('success', 'Data obat berhasil dihapus.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fitur Impor Excel
|
||||
*/
|
||||
public function batchUpload(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate(['file_excel' => 'required|mimes:xlsx,xls,csv|max:2048']);
|
||||
|
||||
try {
|
||||
Excel::import(new MedicineImport, $request->file('file_excel'));
|
||||
$this->logAction('BATCH_UPLOAD', null, "Melakukan impor massal data obat");
|
||||
|
||||
// FIX: Tambahkan .index
|
||||
return redirect()->route('inventory.medicines.index')->with('success', 'Data obat berhasil diimpor.');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', 'Gagal impor: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download Template Excel
|
||||
*/
|
||||
public function downloadTemplate()
|
||||
{
|
||||
return Excel::download(new MedicineTemplateExport, 'template_obat_earrin_cats.xlsx');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\{Cat, Medicine, Food, Supply, Treatment, Matting, User, ActivityLog};
|
||||
use App\Exports\UnifiedReportExport;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Gate; // WAJIB DIIMPORT untuk sistem permission
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// PERBAIKAN UTAMA: Proteksi menggunakan matriks permission slug 'reports'
|
||||
// Ini menggantikan pengecekan manual 'admin'
|
||||
Gate::authorize('check-access', ['reports', 'read']);
|
||||
|
||||
$module = $request->get('module', 'cats');
|
||||
$query = $this->initializeQuery($module);
|
||||
|
||||
$this->applyGlobalFilters($query, $request, $module);
|
||||
$this->applyModuleFilters($query, $request, $module);
|
||||
|
||||
if ($request->filled('export')) {
|
||||
$filteredData = $query->latest()->get();
|
||||
return $this->processExport($filteredData, $module, $request->export);
|
||||
}
|
||||
|
||||
$results = $query->latest()->paginate(15)->withQueryString();
|
||||
$users = User::orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return view('pages.reports.index', compact('results', 'module', 'users'));
|
||||
}
|
||||
|
||||
protected function initializeQuery($module)
|
||||
{
|
||||
return match ($module) {
|
||||
'cats' => Cat::with(['pic']),
|
||||
'medicine' => Medicine::query(),
|
||||
'food' => Food::query(),
|
||||
'supplies' => Supply::query(),
|
||||
'treatment' => Treatment::with(['cat', 'pic']),
|
||||
'vaccine', 'deworming', 'flea', 'grooming' => Cat::with(['pic']),
|
||||
default => Cat::query(),
|
||||
};
|
||||
}
|
||||
|
||||
protected function applyGlobalFilters($query, $request, $module)
|
||||
{
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$query->where(function($q) use ($module, $search) {
|
||||
$searchCol = $this->getSearchColumn($module);
|
||||
$q->where($searchCol, 'like', "%{$search}%");
|
||||
if ($module === 'treatment') {
|
||||
$q->orWhereHas('cat', fn($query) => $query->where('nama_kucing', 'like', "%{$search}%"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('pic_id')) {
|
||||
$query->where('pic_id', $request->pic_id);
|
||||
}
|
||||
|
||||
if ($request->filled('start_date') && $request->filled('end_date')) {
|
||||
$dateCol = $this->getDateColumn($module);
|
||||
$query->whereBetween($dateCol, [
|
||||
Carbon::parse($request->start_date)->startOfDay(),
|
||||
Carbon::parse($request->end_date)->endOfDay()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyModuleFilters($query, $request, $module)
|
||||
{
|
||||
$status = $request->status;
|
||||
|
||||
// Modul Maintenance
|
||||
if (in_array($module, ['vaccine', 'deworming', 'flea', 'grooming']) && $request->filled('status')) {
|
||||
$dateCol = $this->getDateColumn($module);
|
||||
match($status) {
|
||||
'overdue' => $query->whereNotNull($dateCol)->where($dateCol, '<', now()->toDateString()),
|
||||
'soon' => $query->whereNotNull($dateCol)->whereBetween($dateCol, [now()->toDateString(), now()->addDays(7)->toDateString()]),
|
||||
'not_set' => $query->whereNull($dateCol),
|
||||
default => null
|
||||
};
|
||||
}
|
||||
|
||||
// Modul Inventori
|
||||
if (in_array($module, ['medicine', 'food', 'supplies']) && $request->filled('status')) {
|
||||
match($status) {
|
||||
'critical' => $query->where('qty', '<', 5),
|
||||
'expired' => $query->whereNotNull('expired_date')->where('expired_date', '<', now()->toDateString()),
|
||||
'safe' => $query->where('qty', '>=', 5),
|
||||
default => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private function getSearchColumn($module) {
|
||||
return match($module) {
|
||||
'cats', 'vaccine', 'deworming', 'flea', 'grooming' => 'nama_kucing',
|
||||
'medicine' => 'nama_obat',
|
||||
'food' => 'nama_makanan',
|
||||
'supplies' => 'nama_perlengkapan',
|
||||
default => 'id'
|
||||
};
|
||||
}
|
||||
|
||||
private function getDateColumn($module) {
|
||||
return match($module) {
|
||||
'cats' => 'tanggal_lahir',
|
||||
'medicine', 'food', 'supplies' => 'expired_date',
|
||||
'vaccine' => 'jadwal_vaksin',
|
||||
'deworming' => 'jadwal_obat_cacing',
|
||||
'flea' => 'jadwal_obat_kutu',
|
||||
'grooming' => 'jadwal_grooming',
|
||||
default => 'created_at'
|
||||
};
|
||||
}
|
||||
|
||||
protected function processExport($data, $module, $format)
|
||||
{
|
||||
// Proteksi Tambahan: Pastikan user punya izin Export (opsional, bisa disatukan dengan Read)
|
||||
$fileName = "Report_{$module}_" . now()->format('Ymd_His');
|
||||
if ($format === 'xlsx') {
|
||||
return Excel::download(new UnifiedReportExport($data, $module), "{$fileName}.xlsx");
|
||||
}
|
||||
$pdf = Pdf::loadView('pages.reports.pdf_template', [
|
||||
'data' => $data,
|
||||
'module' => $module,
|
||||
'title' => "Laporan " . ucfirst($module)
|
||||
])->setPaper('a4', 'landscape');
|
||||
return $pdf->download("{$fileName}.pdf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Role;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RoleController extends Controller
|
||||
{
|
||||
/**
|
||||
* Menampilkan daftar Role
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$roles = Role::all();
|
||||
// Menambahkan prefix 'pages.' sesuai struktur folder Anda
|
||||
return view('pages.admin.roles.index', compact('roles'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
// Mengarahkan ke folder pages sesuai standarisasi Anda
|
||||
return view('pages.admin.roles.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'slug' => 'required|string|max:255|unique:roles,slug',
|
||||
]);
|
||||
|
||||
$role = Role::create([
|
||||
'name' => $request->name,
|
||||
'slug' => str()->slug($request->slug), // Memastikan format slug benar
|
||||
]);
|
||||
|
||||
// Audit Trail: Mencatat pembuatan role baru dengan menyertakan field 'model'
|
||||
if (class_exists('\App\Models\ActivityLog')) {
|
||||
\App\Models\ActivityLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'CREATE_ROLE',
|
||||
'model' => 'Role', // Ditambahkan untuk memenuhi constraint database
|
||||
'description' => 'Menambahkan role baru: ' . $role->name,
|
||||
]);
|
||||
}
|
||||
|
||||
// Setelah role dibuat, arahkan ke halaman edit untuk mengatur izin modul
|
||||
return redirect()->route('admin.roles.edit', $role->id)
|
||||
->with('success', 'Role berhasil dibuat. Silakan atur hak akses modul.');
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$role = Role::with('permissions')->findOrFail($id);
|
||||
|
||||
/**
|
||||
* Daftar modul disesuaikan dengan Sidebar (image_8b75a1.png)
|
||||
* Menambahkan modul Logistik & Intelligence yang sebelumnya belum terdaftar
|
||||
*/
|
||||
$modules = [
|
||||
// --- OPERASIONAL ---
|
||||
'cats' => 'Data Kucing',
|
||||
'matting' => 'Matting Cat (Breeding)',
|
||||
|
||||
// --- KESEHATAN ---
|
||||
'medical_insights' => 'Medical Insight',
|
||||
'treatments' => 'Rekam Medis',
|
||||
|
||||
// --- LOGISTIK (LENGKAP) ---
|
||||
'medicines' => 'Obat & Vitamin',
|
||||
'foods' => 'Stok Makanan',
|
||||
'supplies' => 'Perlengkapan',
|
||||
'categories' => 'Master Kategori',
|
||||
|
||||
// --- INTELLIGENCE ---
|
||||
'reports' => 'Pusat Laporan',
|
||||
'matting_reports' => 'Laporan Breeding',
|
||||
|
||||
// --- KONFIGURASI ---
|
||||
'users' => 'Manajemen User',
|
||||
'roles' => 'Hak Akses (Role)',
|
||||
'logs' => 'Audit Trail (Log)',
|
||||
'settings' => 'Pengaturan Sistem'
|
||||
];
|
||||
|
||||
// Menambahkan prefix 'pages.'
|
||||
return view('pages.admin.roles.edit', compact('role', 'modules'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Menyimpan/Sync Matriks Permission
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$role = Role::findOrFail($id);
|
||||
|
||||
DB::transaction(function () use ($request, $role) {
|
||||
// Hapus permission lama (Sync)
|
||||
$role->permissions()->delete();
|
||||
|
||||
// Simpan permission baru jika ada input perms
|
||||
if ($request->has('perms')) {
|
||||
foreach ($request->perms as $moduleSlug => $actions) {
|
||||
$role->permissions()->create([
|
||||
'role_id' => $role->id,
|
||||
'module_slug' => $moduleSlug,
|
||||
'can_read' => isset($actions['read']) ? 1 : 0,
|
||||
'can_create' => isset($actions['create']) ? 1 : 0,
|
||||
'can_update' => isset($actions['update']) ? 1 : 0,
|
||||
'can_delete' => isset($actions['delete']) ? 1 : 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Pencatatan Log Audit (Audit Trail) dengan field 'model' agar tidak error
|
||||
if (class_exists('\App\Models\ActivityLog')) {
|
||||
\App\Models\ActivityLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'UPDATE_PERMISSION',
|
||||
'model' => 'Role', // Ditambahkan untuk sinkronisasi database
|
||||
'description' => 'Mengubah hak akses untuk role: ' . $role->name,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Hak akses ' . $role->name . ' berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Role $role)
|
||||
{
|
||||
// Cek izin di matriks Role Management
|
||||
\Illuminate\Support\Facades\Gate::authorize('check-access', ['roles', 'delete']);
|
||||
|
||||
// Keamanan: Role Admin tidak boleh dihapus
|
||||
if ($role->slug === 'admin') {
|
||||
return back()->with('error', 'Akses Ditolak! Role Administrator utama tidak dapat dihapus.');
|
||||
}
|
||||
|
||||
$roleName = $role->name;
|
||||
|
||||
// Audit Trail
|
||||
if (class_exists('\App\Models\ActivityLog')) {
|
||||
\App\Models\ActivityLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'DELETED_ROLE',
|
||||
'model' => 'Role',
|
||||
'description' => "Menghapus role: $roleName",
|
||||
]);
|
||||
}
|
||||
|
||||
$role->delete();
|
||||
|
||||
return redirect()->route('admin.roles.index')->with('success', "Role $roleName berhasil dihapus.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Laravel\Facades\Image;
|
||||
|
||||
class SettingController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
/**
|
||||
* Daftar kunci pengaturan yang diizinkan untuk di-update secara massal.
|
||||
*/
|
||||
protected $allowedSettings = [
|
||||
'app_name',
|
||||
'app_slogan',
|
||||
'app_version', // Tambahan baru
|
||||
'app_address',
|
||||
'wa_number'
|
||||
];
|
||||
|
||||
public function index()
|
||||
{
|
||||
$settings = Setting::pluck('value', 'key')->all();
|
||||
|
||||
// Tambahkan default jika version belum ada
|
||||
if (!isset($settings['app_version'])) {
|
||||
$settings['app_version'] = '1.0.0';
|
||||
}
|
||||
|
||||
$waStatus = $this->checkWhatsAppService();
|
||||
$emailStatus = $this->checkEmailService();
|
||||
|
||||
$logs = ActivityLog::with('user')
|
||||
->where('action', 'LIKE', '%SETTING%')
|
||||
->latest()
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
return view('pages.admin.settings', compact('settings', 'waStatus', 'emailStatus', 'logs'));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'app_logo' => 'nullable|image|mimes:png,jpg,jpeg|max:2048',
|
||||
'app_favicon' => 'nullable|image|mimes:png,jpg,ico|max:512',
|
||||
'app_name' => 'required|string|max:100',
|
||||
'app_slogan' => 'nullable|string|max:255',
|
||||
'app_version' => 'required|string|max:20', // Validasi versi
|
||||
]);
|
||||
|
||||
// 1. Proses Logo & Favicon
|
||||
if ($request->hasFile('app_logo')) {
|
||||
$this->uploadAndProcessImage($request->file('app_logo'), 'app_logo', 512);
|
||||
}
|
||||
|
||||
if ($request->hasFile('app_favicon')) {
|
||||
$this->uploadAndProcessImage($request->file('app_favicon'), 'app_favicon', 128);
|
||||
}
|
||||
|
||||
// 2. Update Pengaturan Teks (Hanya yang ada di whitelist)
|
||||
foreach ($this->allowedSettings as $key) {
|
||||
if ($request->has($key)) {
|
||||
Setting::updateOrCreate(['key' => $key], ['value' => $request->get($key)]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logAction('UPDATED_SETTINGS', null, "Memperbarui identitas sistem ke versi " . $request->app_version);
|
||||
|
||||
return back()->with('success', 'Konfigurasi ' . $request->app_name . ' berhasil diperbarui!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper Image Processing (Intervention Image v3)
|
||||
*/
|
||||
private function uploadAndProcessImage($file, $key, $size)
|
||||
{
|
||||
$oldFile = Setting::where('key', $key)->value('value');
|
||||
if ($oldFile && Storage::disk('public')->exists($oldFile)) {
|
||||
Storage::disk('public')->delete($oldFile);
|
||||
}
|
||||
|
||||
$filename = $key . '_' . time() . '.png';
|
||||
$path = 'settings/' . $filename;
|
||||
|
||||
// Intervention Image v3 Logic
|
||||
$image = Image::read($file);
|
||||
$image->cover($size, $size);
|
||||
|
||||
Storage::disk('public')->put($path, (string) $image->toPng());
|
||||
Setting::updateOrCreate(['key' => $key], ['value' => $path]);
|
||||
}
|
||||
|
||||
private function checkWhatsAppService()
|
||||
{
|
||||
// Gunakan cache atau timeout pendek agar page load tidak berat
|
||||
try {
|
||||
$response = Http::timeout(3)->withHeaders([
|
||||
'Authorization' => config('services.fonnte.token') ?? env('FONNTE_TOKEN'),
|
||||
])->post('https://api.fonnte.com/device');
|
||||
|
||||
$data = $response->json();
|
||||
return [
|
||||
'active' => ($data['status'] ?? false) == true,
|
||||
'quota' => $data['quota'] ?? 0,
|
||||
'message' => ($data['status'] ?? false) ? 'API Terhubung' : ($data['reason'] ?? 'Perangkat tidak aktif')
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return ['active' => false, 'quota' => 0, 'message' => 'Layanan WA Offline'];
|
||||
}
|
||||
}
|
||||
|
||||
private function checkEmailService()
|
||||
{
|
||||
try {
|
||||
$transport = Mail::getSymfonyTransport();
|
||||
|
||||
// 1. Cek jika menggunakan driver 'log' (biasanya di localhost)
|
||||
if ($transport instanceof \Illuminate\Mail\Transport\LogTransport) {
|
||||
return [
|
||||
'active' => true,
|
||||
'message' => 'Mail Mode: LOG (Local Development)'
|
||||
];
|
||||
}
|
||||
|
||||
// 2. Jika menggunakan SMTP, coba lakukan jabat tangan (handshake)
|
||||
if (method_exists($transport, 'start')) {
|
||||
$transport->stop();
|
||||
$transport->start();
|
||||
}
|
||||
|
||||
return ['active' => true, 'message' => 'SMTP Terhubung'];
|
||||
} catch (\Exception $e) {
|
||||
// Jika gagal konek ke Mailtrap/Gmail, kembalikan status false
|
||||
return [
|
||||
'active' => false,
|
||||
'message' => 'SMTP Error: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Supply;
|
||||
use App\Models\Category;
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class SupplyController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
/**
|
||||
* Tampilkan daftar perlengkapan.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Supply::with('category');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_perlengkapan', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
|
||||
$supplies = $query->latest()->paginate(10);
|
||||
return view('pages.inventory.supplies.index', compact('supplies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan form tambah.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
// Category ditentukan otomatis oleh Model Supply
|
||||
return view('pages.inventory.supplies.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan data perlengkapan baru.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama_perlengkapan' => 'required|string|max:255',
|
||||
'qty' => 'required|numeric|min:0',
|
||||
'unit' => 'required|string|max:50',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'foto_produk' => 'nullable|image|max:1024',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('foto_produk')) {
|
||||
$validated['foto_produk'] = $request->file('foto_produk')->store('supplies', 'public');
|
||||
}
|
||||
|
||||
$supply = Supply::create($validated);
|
||||
|
||||
$this->logAction('CREATED_SUPPLY', $supply, "Menambah perlengkapan: {$supply->nama_perlengkapan}");
|
||||
|
||||
// FIX: Menggunakan .index agar tidak RouteNotFound
|
||||
return redirect()->route('inventory.supplies.index')->with('success', 'Perlengkapan berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan form edit.
|
||||
*/
|
||||
public function edit(Supply $supply): View
|
||||
{
|
||||
$categories = Category::all();
|
||||
return view('pages.inventory.supplies.edit', compact('supply', 'categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update data perlengkapan.
|
||||
*/
|
||||
public function update(Request $request, Supply $supply): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama_perlengkapan' => 'required|string|max:255',
|
||||
'qty' => 'required|numeric|min:0',
|
||||
'unit' => 'required|string|max:50',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'foto_produk' => 'nullable|image|max:1024',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('foto_produk')) {
|
||||
if ($supply->foto_produk) {
|
||||
Storage::disk('public')->delete($supply->foto_produk);
|
||||
}
|
||||
$validated['foto_produk'] = $request->file('foto_produk')->store('supplies', 'public');
|
||||
}
|
||||
|
||||
$supply->update($validated);
|
||||
|
||||
$this->logAction('UPDATED_SUPPLY', $supply, "Memperbarui data perlengkapan: {$supply->nama_perlengkapan}");
|
||||
|
||||
// FIX: Menggunakan .index
|
||||
return redirect()->route('inventory.supplies.index')->with('success', 'Data perlengkapan berhasil diperbarui.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus data perlengkapan.
|
||||
*/
|
||||
public function destroy(Supply $supply): RedirectResponse
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Akses Ditolak.');
|
||||
}
|
||||
|
||||
$supplyName = $supply->nama_perlengkapan;
|
||||
|
||||
if ($supply->foto_produk) {
|
||||
Storage::disk('public')->delete($supply->foto_produk);
|
||||
}
|
||||
|
||||
// PENTING: Log dilakukan SEBELUM delete agar ID tidak null
|
||||
$this->logAction('DELETED_SUPPLY', $supply, "Menghapus perlengkapan: {$supplyName}");
|
||||
|
||||
$supply->delete();
|
||||
|
||||
// FIX: Menggunakan .index
|
||||
return redirect()->route('inventory.supplies.index')->with('success', 'Data perlengkapan berhasil dihapus.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Cat;
|
||||
use App\Models\Treatment;
|
||||
use App\Models\Medicine;
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Exception;
|
||||
|
||||
class TreatmentController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
/**
|
||||
* Menampilkan daftar rekam medis dengan optimasi Eager Loading.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$treatments = Treatment::with([
|
||||
'cat:id,nama_kucing,foto',
|
||||
'medicine:id,nama_obat,unit',
|
||||
'user:id,name'
|
||||
])
|
||||
->latest('tanggal')
|
||||
->paginate(15);
|
||||
|
||||
return view('pages.treatments.index', compact('treatments'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Form tambah rekam medis.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$selectedCat = $request->filled('cat_id') ? Cat::findOrFail($request->cat_id) : null;
|
||||
$cats = Cat::select('id', 'nama_kucing', 'ras')->get();
|
||||
$medicines = Medicine::where('qty', '>', 0)->get();
|
||||
|
||||
return view('pages.treatments.create', compact('selectedCat', 'cats', 'medicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan rekam medis baru & potong stok (mendukung desimal).
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $this->validateTreatment($request);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($request, &$validated) {
|
||||
$medicine = Medicine::findOrFail($request->medicine_id);
|
||||
|
||||
// Validasi kecukupan stok desimal
|
||||
if ($medicine->qty < $request->qty_used) {
|
||||
throw new Exception("Stok {$medicine->nama_obat} tidak mencukupi (Sisa: {$medicine->qty} {$medicine->unit}).");
|
||||
}
|
||||
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
}
|
||||
|
||||
// Otomatisasi data sistem
|
||||
$validated['user_id'] = auth()->id();
|
||||
$validated['nama_obat'] = $medicine->nama_obat;
|
||||
|
||||
$treatment = Treatment::create($validated);
|
||||
|
||||
// Potong stok (decrement mendukung nilai float/decimal)
|
||||
$medicine->decrement('qty', $request->qty_used);
|
||||
|
||||
$this->logAction('TREATED', $treatment, "Mencatat pengobatan oleh " . auth()->user()->name);
|
||||
});
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)
|
||||
->with('success', 'Rekam medis berhasil dicatat dengan presisi.');
|
||||
|
||||
} catch (Exception $e) {
|
||||
if (isset($validated['foto_bukti'])) {
|
||||
Storage::disk('public')->delete($validated['foto_bukti']);
|
||||
}
|
||||
return back()->withInput()->with('error', 'Gagal menyimpan: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Form edit rekam medis.
|
||||
*/
|
||||
public function edit(Treatment $treatment): View
|
||||
{
|
||||
$cats = Cat::select('id', 'nama_kucing', 'ras')->get();
|
||||
$medicines = Medicine::all();
|
||||
return view('pages.treatments.edit', compact('treatment', 'cats', 'medicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rekam medis & sinkronisasi ulang stok desimal.
|
||||
*/
|
||||
public function update(Request $request, Treatment $treatment): RedirectResponse
|
||||
{
|
||||
$validated = $this->validateTreatment($request);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($request, $treatment, &$validated) {
|
||||
// 1. Kembalikan stok lama (Desimal Revert)
|
||||
$oldMedicine = Medicine::find($treatment->medicine_id);
|
||||
if ($oldMedicine) {
|
||||
$oldMedicine->increment('qty', $treatment->qty_used);
|
||||
}
|
||||
|
||||
// 2. Potong stok baru
|
||||
$newMedicine = Medicine::findOrFail($request->medicine_id);
|
||||
if ($newMedicine->qty < $request->qty_used) {
|
||||
throw new Exception("Stok {$newMedicine->nama_obat} tidak mencukupi untuk perubahan ini.");
|
||||
}
|
||||
$newMedicine->decrement('qty', $request->qty_used);
|
||||
|
||||
// 3. Kelola Foto
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
if ($treatment->foto_bukti) Storage::disk('public')->delete($treatment->foto_bukti);
|
||||
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
}
|
||||
|
||||
// 4. Update data sistem
|
||||
$validated['user_id'] = auth()->id();
|
||||
$validated['nama_obat'] = $newMedicine->nama_obat;
|
||||
$treatment->update($validated);
|
||||
|
||||
$this->logAction('UPDATED_TREATMENT', $treatment, "Diperbarui oleh " . auth()->user()->name);
|
||||
});
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)->with('success', 'Rekam medis & inventori berhasil di-sinkronisasi.');
|
||||
|
||||
} catch (Exception $e) {
|
||||
return back()->withInput()->with('error', 'Gagal memperbarui: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus rekam medis & kembalikan stok.
|
||||
*/
|
||||
public function destroy(Treatment $treatment): RedirectResponse
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($treatment) {
|
||||
if ($treatment->medicine_id) {
|
||||
Medicine::where('id', $treatment->medicine_id)->increment('qty', $treatment->qty_used);
|
||||
}
|
||||
if ($treatment->foto_bukti) Storage::disk('public')->delete($treatment->foto_bukti);
|
||||
|
||||
$this->logAction('DELETED_TREATMENT', $treatment, "Menghapus catatan medis kucing: {$treatment->cat->nama_kucing}");
|
||||
$treatment->delete();
|
||||
});
|
||||
|
||||
return back()->with('success', 'Data rekam medis dihapus dan stok dikembalikan.');
|
||||
} catch (Exception $e) {
|
||||
return back()->with('error', 'Gagal menghapus data.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper Validasi: Terpusat untuk Clean Code & Mendukung Desimal.
|
||||
*/
|
||||
private function validateTreatment(Request $request)
|
||||
{
|
||||
return $request->validate([
|
||||
'cat_id' => 'required|exists:cats,id',
|
||||
'medicine_id' => 'required|exists:medicines,id',
|
||||
// 'numeric' mengizinkan input desimal (koma)
|
||||
'qty_used' => 'required|numeric|min:0.01',
|
||||
'tanggal' => 'required|date',
|
||||
'keluhan' => 'required|string|max:255',
|
||||
'diagnosa' => 'required|string',
|
||||
'dosis' => 'required|string',
|
||||
'keterangan' => 'nullable|string',
|
||||
'foto_bukti' => 'nullable|image|max:2048',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Role;
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Gate; // WAJIB DIIMPORT
|
||||
use Illuminate\Validation\Rules;
|
||||
use App\Models\ActivityLog;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Exports\ActivityLogExport;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
/**
|
||||
* Menampilkan daftar user.
|
||||
* Izin: users (read)
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// GANTI: Otorisasi berdasarkan Matriks Permission
|
||||
Gate::authorize('check-access', ['users', 'read']);
|
||||
|
||||
$users = User::query()
|
||||
->with('role')
|
||||
->when($request->search, function ($query, $search) {
|
||||
$query->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
})
|
||||
->latest()
|
||||
->paginate(10);
|
||||
|
||||
return view('pages.users.index', compact('users'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Form tambah user baru.
|
||||
* Izin: users (create)
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
Gate::authorize('check-access', ['users', 'create']);
|
||||
|
||||
$roles = Role::all();
|
||||
return view('pages.users.create', compact('roles'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan user baru.
|
||||
* Izin: users (create)
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['users', 'create']);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|lowercase|email|max:255|unique:users',
|
||||
'phone' => 'nullable|string|max:15',
|
||||
'role_id' => 'required|exists:roles,id',
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$role = Role::find($validated['role_id']);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'],
|
||||
'role_id' => $validated['role_id'],
|
||||
'role' => $role->slug,
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
// Catat aktivitas (Gunakan parameter model untuk log)
|
||||
$this->logAction('CREATED', $user, "Mendaftarkan user baru: {$user->name} sebagai {$role->name}", 'User');
|
||||
|
||||
return redirect()->route('admin.users.index')->with('success', "User {$user->name} berhasil ditambahkan.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Halaman Edit User.
|
||||
* Izin: users (update)
|
||||
*/
|
||||
public function edit(User $user): View
|
||||
{
|
||||
Gate::authorize('check-access', ['users', 'update']);
|
||||
|
||||
$roles = Role::all();
|
||||
return view('pages.users.edit', compact('user', 'roles'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Data User.
|
||||
* Izin: users (update)
|
||||
*/
|
||||
public function update(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['users', 'update']);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email,' . $user->id,
|
||||
'role_id' => 'required|exists:roles,id',
|
||||
'phone' => 'nullable|string|max:15',
|
||||
]);
|
||||
|
||||
$newRole = Role::find($validated['role_id']);
|
||||
|
||||
// Keamanan Logika: Tetap pertahankan agar user tidak merusak akun sendiri
|
||||
if ($user->id === auth()->id() && $newRole->slug !== 'admin' && auth()->user()->role === 'admin') {
|
||||
return back()->with('error', 'Akses Ditolak! Anda tidak bisa menurunkan otoritas Admin Anda sendiri.');
|
||||
}
|
||||
|
||||
$user->update(array_merge($validated, [
|
||||
'role' => $newRole->slug
|
||||
]));
|
||||
|
||||
$this->logAction('UPDATED', $user, "Memperbarui data profil user: {$user->name}", 'User');
|
||||
|
||||
return redirect()->route('admin.users.index')->with('success', "Data {$user->name} berhasil diperbarui.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus User.
|
||||
* Izin: users (delete)
|
||||
*/
|
||||
public function destroy(User $user): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['users', 'delete']);
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', 'Akses Ditolak! Anda tidak dapat menghapus akun Anda sendiri.');
|
||||
}
|
||||
|
||||
$userName = $user->name;
|
||||
$this->logAction('DELETED', $user, "Menghapus user: {$userName}", 'User');
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('admin.users.index')->with('success', "User {$userName} telah dihapus.");
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* AUDIT TRAIL / LOGS SECTION */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Daftar Aktivitas.
|
||||
* Izin: logs (read)
|
||||
*/
|
||||
public function activityLogs(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['logs', 'read']);
|
||||
|
||||
$logs = ActivityLog::with('user')
|
||||
->when($request->search, function ($query, $search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('description', 'like', "%{$search}%")
|
||||
->orWhereHas('user', function ($qu) use ($search) {
|
||||
$qu->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
})
|
||||
->when($request->action, function ($query, $action) {
|
||||
$query->where('action', 'like', "%{$action}%");
|
||||
})
|
||||
->latest()
|
||||
->paginate(20);
|
||||
|
||||
return view('pages.admin.logs', compact('logs'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Logs ke Excel.
|
||||
* Izin: logs (read)
|
||||
*/
|
||||
public function exportLogs()
|
||||
{
|
||||
Gate::authorize('check-access', ['logs', 'read']);
|
||||
return Excel::download(new ActivityLogExport, 'audit_trail_'.now()->format('Ymd').'.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bersihkan Logs.
|
||||
* Izin: logs (delete)
|
||||
*/
|
||||
public function clearLogs()
|
||||
{
|
||||
Gate::authorize('check-access', ['logs', 'delete']);
|
||||
ActivityLog::truncate();
|
||||
return back()->with('success', 'Seluruh riwayat aktivitas telah dibersihkan.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AdminMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 1. Pastikan User sudah login untuk menghindari error "Attempt to read property on null"
|
||||
if (!auth()->check()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// 2. Ambil slug role (Mendukung sistem baru yang kita buat)
|
||||
// Kita gunakan optional() agar jika user tidak punya role, aplikasi tidak crash.
|
||||
$roleSlug = optional(auth()->user()->role)->slug ?? auth()->user()->role;
|
||||
|
||||
// 3. Logika Existing Anda (Tetap Dipertahankan)
|
||||
if ($roleSlug !== 'admin') {
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
return redirect('dashboard')->with('error', 'Akses ditolak.');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiting throttle key for the request.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CatStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nama_kucing' => 'required|string|max:255',
|
||||
'jenis_kelamin' => 'required|in:Jantan,Betina',
|
||||
'foto' => 'nullable|image|mimes:jpg,jpeg,png|max:2048',
|
||||
'tanggal_lahir' => 'nullable|date',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'lowercase',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class)->ignore($this->user()->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Imports;
|
||||
|
||||
use App\Models\Food;
|
||||
use Maatwebsite\Excel\Concerns\ToModel;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class FoodImport implements ToModel, WithHeadingRow
|
||||
{
|
||||
/**
|
||||
* @param array $row
|
||||
* @return \Illuminate\Database\Eloquent\Model|null
|
||||
*/
|
||||
public function model(array $row)
|
||||
{
|
||||
return new Food([
|
||||
'nama_makanan' => $row['nama_makanan'], // Sesuai header di Excel
|
||||
'qty' => $row['qty'],
|
||||
'unit' => $row['unit'],
|
||||
// Mengonversi format tanggal Excel menjadi format database
|
||||
'expired_date' => isset($row['expired_date'])
|
||||
? Carbon::instance(\PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($row['expired_date']))
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Imports;
|
||||
|
||||
use App\Models\Medicine;
|
||||
use Maatwebsite\Excel\Concerns\ToModel;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
|
||||
class MedicineImport implements ToModel, WithHeadingRow
|
||||
{
|
||||
/**
|
||||
* Memetakan setiap baris Excel ke Model Medicine.
|
||||
*/
|
||||
public function model(array $row)
|
||||
{
|
||||
return new Medicine([
|
||||
'nama_obat' => $row['nama_obat'],
|
||||
'qty' => (float) $row['stok'], // Mendukung desimal (ml)
|
||||
'unit' => $row['satuan'] ?? 'Pcs',
|
||||
'expired_date' => isset($row['kadaluarsa']) ? \Carbon\Carbon::parse($row['kadaluarsa']) : null,
|
||||
// Kategori akan terisi otomatis oleh logic Model 'booted'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Imports;
|
||||
|
||||
use App\Models\Supply;
|
||||
use Maatwebsite\Excel\Concerns\ToModel;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
|
||||
class SupplyImport implements ToModel, WithHeadingRow
|
||||
{
|
||||
/**
|
||||
* Memetakan baris Excel ke Model Supply.
|
||||
* * @param array $row
|
||||
* @return \Illuminate\Database\Eloquent\Model|null
|
||||
*/
|
||||
public function model(array $row)
|
||||
{
|
||||
return new Supply([
|
||||
'nama_perlengkapan' => $row['nama_perlengkapan'], // Harus sesuai header Excel
|
||||
'qty' => $row['qty'],
|
||||
'unit' => $row['unit'],
|
||||
// Kategori dan Category ID akan terisi otomatis oleh logic 'booted' di Model Supply.
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use App\Models\Medicine;
|
||||
|
||||
class LowStockNotification extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public $medicine;
|
||||
|
||||
public function __construct(Medicine $medicine)
|
||||
{
|
||||
$this->medicine = $medicine;
|
||||
}
|
||||
|
||||
public function build()
|
||||
{
|
||||
return $this->subject("⚠️ Stok Obat Habis: {$this->medicine->nama_obat}")
|
||||
->view('emails.low_stock');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ActivityLog extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
protected $casts = ['properties' => 'array'];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class Cat extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
/**
|
||||
* SINKRONISASI TOTAL: 22 Kolom Database
|
||||
* Memasukkan berat_terakhir, lokasi, dan kolom identitas lainnya.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'nama_kucing', 'ras', 'registry', 'jenis_kelamin', 'tanggal_lahir',
|
||||
'foto', 'berat_terakhir', 'lokasi', 'riwayat_sakit_terakhir',
|
||||
'jadwal_vaksin', 'jadwal_obat_cacing', 'jadwal_obat_kutu',
|
||||
'jadwal_grooming', 'induk_jantan_id', 'induk_betina_id',
|
||||
'lokasi_kandang', 'pic_id', 'berat_badan',
|
||||
'pedigree', 'microchip', 'status', 'kode_ems' // Kolom tambahan dari UI Anda
|
||||
];
|
||||
|
||||
/**
|
||||
* Casting Laravel 11/12 Style
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tanggal_lahir' => 'date',
|
||||
'jadwal_vaksin' => 'date',
|
||||
'jadwal_obat_cacing' => 'date',
|
||||
'jadwal_obat_kutu' => 'date',
|
||||
'jadwal_grooming' => 'date',
|
||||
'registry' => 'array',
|
||||
'berat_badan' => 'decimal:2',
|
||||
'berat_terakhir' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* RELATIONS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
public function treatments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Treatment::class);
|
||||
}
|
||||
|
||||
public function weightHistories(): HasMany
|
||||
{
|
||||
return $this->hasMany(WeightHistory::class)->latest();
|
||||
}
|
||||
|
||||
public function sire(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cat::class, 'induk_jantan_id')->withDefault([
|
||||
'nama_kucing' => 'Unknown Sire'
|
||||
]);
|
||||
}
|
||||
|
||||
public function dam(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cat::class, 'induk_betina_id')->withDefault([
|
||||
'nama_kucing' => 'Unknown Dam'
|
||||
]);
|
||||
}
|
||||
|
||||
public function mattingsAsSire(): HasMany
|
||||
{
|
||||
return $this->hasMany(Matting::class, 'sire_id');
|
||||
}
|
||||
|
||||
public function mattingsAsDam(): HasMany
|
||||
{
|
||||
return $this->hasMany(Matting::class, 'dam_id');
|
||||
}
|
||||
|
||||
public function pic(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'pic_id')->withDefault([
|
||||
'name' => 'System'
|
||||
]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ACCESSORS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
protected function genderIcon(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
// Database menggunakan enum 'Jantan'/'Betina' (Case Sensitive)
|
||||
get: fn () => strtolower($this->jenis_kelamin) === 'jantan'
|
||||
? '<i class="fas fa-mars text-indigo-500"></i>'
|
||||
: '<i class="fas fa-venus text-rose-500"></i>'
|
||||
);
|
||||
}
|
||||
|
||||
protected function fotoUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->foto
|
||||
? Storage::url($this->foto)
|
||||
: asset('images/default-cat.png')
|
||||
);
|
||||
}
|
||||
|
||||
protected function age(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (!$this->tanggal_lahir) return 'N/A';
|
||||
$diff = $this->tanggal_lahir->diff(now());
|
||||
|
||||
if ($diff->y > 0) return "{$diff->y} Thn, {$diff->m} Bln";
|
||||
if ($diff->m > 0) return "{$diff->m} Bln, {$diff->d} Hari";
|
||||
return "{$diff->d} Hari";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Usia presisi untuk laporan medis (Tahun, Bulan, Hari)
|
||||
protected function usiaDetail(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (!$this->tanggal_lahir) return 'Data N/A';
|
||||
$diff = $this->tanggal_lahir->diff(now());
|
||||
return "{$diff->y} Thn, {$diff->m} Bln, {$diff->d} Hari";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function registryLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->registry ?? 'NON-PED'
|
||||
);
|
||||
}
|
||||
|
||||
// Badges untuk registry
|
||||
protected function registryBadges(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
// Jika tidak ada registry
|
||||
if (empty($this->registry)) {
|
||||
return '<span class="text-[9px] text-slate-400 font-bold italic tracking-widest">NON-PED</span>';
|
||||
}
|
||||
|
||||
// Mapping warna untuk setiap federasi
|
||||
$config = [
|
||||
'ICA' => ['bg' => 'bg-blue-50', 'text' => 'text-blue-700', 'border' => 'border-blue-100'],
|
||||
'CFA' => ['bg' => 'bg-rose-50', 'text' => 'text-rose-700', 'border' => 'border-rose-100'],
|
||||
'WCF' => ['bg' => 'bg-emerald-50', 'text' => 'text-emerald-700', 'border' => 'border-emerald-100'],
|
||||
'FFF' => ['bg' => 'bg-amber-50', 'text' => 'text-amber-700', 'border' => 'border-amber-100'],
|
||||
];
|
||||
|
||||
$html = '<div class="flex flex-wrap gap-1">';
|
||||
$registries = is_array($this->registry) ? $this->registry : [$this->registry];
|
||||
|
||||
foreach ($registries as $reg) {
|
||||
$style = $config[$reg] ?? ['bg' => 'bg-slate-50', 'text' => 'text-slate-600', 'border' => 'border-slate-200'];
|
||||
|
||||
$html .= "<span class='px-2 py-0.5 {$style['bg']} {$style['text']} {$style['border']} border rounded-lg text-[8px] font-black uppercase tracking-tighter shadow-sm'>";
|
||||
$html .= $reg;
|
||||
$html .= "</span>";
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'keywords'];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($category) {
|
||||
// Otomatis membuat slug dari name sebelum data disimpan
|
||||
if (empty($category->slug)) {
|
||||
$category->slug = Str::slug($category->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
public function medicines(): HasMany
|
||||
{
|
||||
return $this->hasMany(Medicine::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Food extends Model
|
||||
{
|
||||
protected $table = 'foods';
|
||||
|
||||
// Gunakan guarded atau fillable, pastikan 'slug' dan 'category_id' diizinkan
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/**
|
||||
* Relasi ke model Category
|
||||
*/
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Booting model events
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
static::saving(function ($food) {
|
||||
// 1. OTOMATIS GENERATE SLUG
|
||||
// Hanya generate jika nama berubah atau slug masih kosong
|
||||
if ($food->isDirty('nama_makanan') || empty($food->slug)) {
|
||||
$food->slug = Str::slug($food->nama_makanan) . '-' . Str::random(5);
|
||||
}
|
||||
|
||||
// 2. AUTO-CATEGORIZATION (SMART LOGIC)
|
||||
// Hanya jalankan jika category_id belum diisi manual
|
||||
if (empty($food->category_id)) {
|
||||
$productName = strtolower($food->nama_makanan);
|
||||
$categories = Category::whereNotNull('keywords')->get();
|
||||
$found = false;
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
$keywordsArray = explode(',', strtolower($cat->keywords));
|
||||
|
||||
foreach ($keywordsArray as $keyword) {
|
||||
$cleanKeyword = trim($keyword);
|
||||
|
||||
if (!empty($cleanKeyword) && str_contains($productName, $cleanKeyword)) {
|
||||
$food->category_id = $cat->id;
|
||||
$food->kategori = $cat->name;
|
||||
$found = true;
|
||||
break; // Keluar dari loop keyword
|
||||
}
|
||||
}
|
||||
if ($found) break; // Keluar dari loop kategori
|
||||
}
|
||||
|
||||
// 3. FALLBACK LOGIC
|
||||
// Jika tidak ada keyword yang cocok, cari kategori default
|
||||
if (!$found) {
|
||||
$default = Category::where('name', 'Dry Food')
|
||||
->orWhere('name', 'Umum')
|
||||
->first();
|
||||
|
||||
if ($default) {
|
||||
$food->category_id = $default->id;
|
||||
$food->kategori = $default->name;
|
||||
} else {
|
||||
$food->kategori = 'Umum';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Kitten extends Model
|
||||
{
|
||||
// Mengizinkan semua field kecuali ID untuk diisi secara massal
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/**
|
||||
* Casting Data: Memastikan tipe data sinkron antara DB dan PHP.
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tgl_lahir' => 'date',
|
||||
'is_pedigree' => 'boolean',
|
||||
'registry' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* MODEL EVENTS
|
||||
* Membersihkan file storage secara otomatis saat record dihapus.
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
static::deleting(function ($kitten) {
|
||||
// Hapus semua foto pedigree (slot 1-4) jika ada
|
||||
for ($i = 1; $i <= 4; $i++) {
|
||||
$field = "foto_ped_$i";
|
||||
if ($kitten->$field) {
|
||||
Storage::disk('public')->delete($kitten->$field);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* RELATIONS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Menghubungkan ke Master Profil Kucing (Patient Table)
|
||||
*/
|
||||
public function cat(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cat::class, 'cat_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Menghubungkan ke Event Perkawinan (Breeding Event)
|
||||
*/
|
||||
public function matting(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Matting::class, 'matting_id');
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ACCESSORS (UI Helper) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Menghitung berapa banyak dokumen pedigree yang sudah diupload (0-4)
|
||||
*/
|
||||
protected function pedigreeDocsCount(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
$count = 0;
|
||||
for ($i = 1; $i <= 4; $i++) {
|
||||
if ($this->{"foto_ped_$i"}) $count++;
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge Status Pedigree untuk Tampilan Tabel
|
||||
*/
|
||||
protected function pedigreeStatusBadge(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if ($this->is_pedigree) {
|
||||
return '<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase border border-indigo-100 italic">Certificate OK</span>';
|
||||
}
|
||||
return '<span class="px-2 py-0.5 bg-slate-50 text-slate-400 rounded text-[9px] font-black uppercase border border-slate-100">Non-Ped</span>';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mengambil URL Foto Pedigree Slot 1 (Thumbnail)
|
||||
*/
|
||||
protected function firstPedigreeUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->foto_ped_1
|
||||
? asset('storage/' . $this->foto_ped_1)
|
||||
: asset('images/default-doc.png')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class Matting extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tanggal_kawin' => 'date',
|
||||
'tgl_kawin_awal' => 'date',
|
||||
'tgl_kawin_akhir' => 'date',
|
||||
'tgl_lahir_sire' => 'date',
|
||||
'tgl_lahir_dam' => 'date',
|
||||
'jumlah_anak_hidup' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* SINKRONISASI OTOMATIS (The Engine)
|
||||
* Saat data Matting disimpan, otomatis update data di tabel Cats.
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
static::saved(function ($matting) {
|
||||
// 1. Update data Sire (Bapak)
|
||||
if ($matting->sire_id && $matting->sire) {
|
||||
$matting->sire->update([
|
||||
'pedigree' => $matting->no_pedigree_bapak,
|
||||
'microchip' => $now_microchip_bapak ?? $matting->no_microchip_bapak,
|
||||
'tanggal_lahir' => $matting->tgl_lahir_sire,
|
||||
// Pastikan kolom di tabel 'mattings' adalah ems_bapak
|
||||
'kode_ems' => $matting->ems_bapak,
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. Update data Dam (Ibu)
|
||||
if ($matting->dam_id && $matting->dam) {
|
||||
$matting->dam->update([
|
||||
'pedigree' => $matting->no_pedigree_ibu,
|
||||
'microchip' => $matting->no_microchip_ibu,
|
||||
'tanggal_lahir' => $matting->tgl_lahir_dam,
|
||||
// Pastikan kolom di tabel 'mattings' adalah ems_ibu
|
||||
'kode_ems' => $matting->ems_ibu,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* RELATIONS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
public function sire(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cat::class, 'sire_id');
|
||||
}
|
||||
|
||||
public function dam(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cat::class, 'dam_id');
|
||||
}
|
||||
|
||||
public function kittens(): HasMany
|
||||
{
|
||||
return $this->hasMany(Kitten::class, 'matting_id');
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ACCESSORS (UI & Breeding Intelligence) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* EDD (Expected Delivery Date) - H+63
|
||||
*/
|
||||
protected function edd(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->tgl_kawin_awal ? $this->tgl_kawin_awal->addDays(63) : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deskripsi Sisa Hari (Countdown Text)
|
||||
*/
|
||||
protected function countdownText(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (!$this->edd || $this->status !== 'proses') return null;
|
||||
|
||||
$days = (int) now()->startOfDay()->diffInDays($this->edd->startOfDay(), false);
|
||||
|
||||
if ($days > 0) return "$days Hari Lagi";
|
||||
if ($days === 0) return "Prediksi Lahir Hari Ini";
|
||||
return "Overdue " . abs($days) . " Hari";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge Status Modern
|
||||
*/
|
||||
protected function statusBadge(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
$status = strtolower($this->status);
|
||||
return match($status) {
|
||||
'proses' => '<span class="px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-[10px] font-black uppercase border border-blue-100">Monitoring</span>',
|
||||
'berhasil' => '<span class="px-3 py-1 bg-emerald-50 text-emerald-600 rounded-full text-[10px] font-black uppercase border border-emerald-100">Lahir</span>',
|
||||
'gagal' => '<span class="px-3 py-1 bg-rose-50 text-rose-600 rounded-full text-[10px] font-black uppercase border border-rose-100">Gagal</span>',
|
||||
default => '<span class="px-3 py-1 bg-slate-50 text-slate-600 rounded-full text-[10px] font-black uppercase">'.$status.'</span>',
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PIC (Point of Contact)
|
||||
*/
|
||||
public function pic(): BelongsTo
|
||||
{
|
||||
// Pastikan kolom di tabel 'mattings' adalah 'user_id'
|
||||
// Jika kolomnya bernama 'pic_id', ganti 'user_id' menjadi 'pic_id'
|
||||
return $this->belongsTo(User::class, 'user_id')->withDefault([
|
||||
'name' => 'System / Admin'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Medicine extends Model
|
||||
{
|
||||
/**
|
||||
* Mass assignment untuk kolom-kolom inventori.
|
||||
* Kolom 'kategori' tetap ada di fillable agar bisa diisi otomatis oleh sistem.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'nama_obat',
|
||||
'foto_produk',
|
||||
'kategori',
|
||||
'qty',
|
||||
'unit',
|
||||
'expired_date'
|
||||
];
|
||||
|
||||
/**
|
||||
* Logic Otomatisasi & Integritas Data
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
static::saving(function ($medicine) {
|
||||
$name = strtolower($medicine->nama_obat);
|
||||
|
||||
// Ambil semua kategori dari database
|
||||
$categories = \App\Models\Category::all();
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
// Pecah kata kunci berdasarkan koma
|
||||
$keywords = explode(',', strtolower($cat->keywords));
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
if (str_contains($name, trim($keyword))) {
|
||||
$medicine->category_id = $cat->id;
|
||||
$medicine->kategori = $cat->name; // Sinkronisasi teks kategori
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$medicine->kategori = 'Umum';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Casting tipe data untuk presisi medis.
|
||||
*/
|
||||
protected $casts = [
|
||||
'expired_date' => 'date', // Memperbaiki error isPast() di Blade
|
||||
'qty' => 'float', // Mendukung dosis desimal seperti 2.5 ml
|
||||
];
|
||||
|
||||
/**
|
||||
* Accessor: Mempermudah pemanggilan foto produk dengan fallback image.
|
||||
*/
|
||||
public function getFotoUrlAttribute(): string
|
||||
{
|
||||
if ($this->foto_produk && Storage::disk('public')->exists($this->foto_produk)) {
|
||||
return asset('storage/' . $this->foto_produk);
|
||||
}
|
||||
|
||||
// Menggunakan UI Avatars sebagai placeholder jika foto tidak ada
|
||||
return 'https://ui-avatars.com/api/?name=' . urlencode($this->nama_obat) . '&background=f1f5f9&color=64748b';
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke riwayat pengobatan kucing.
|
||||
*/
|
||||
public function treatments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Treatment::class, 'medicine_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Role extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'slug'];
|
||||
|
||||
public function permissions()
|
||||
{
|
||||
return $this->hasMany(RolePermission::class);
|
||||
}
|
||||
|
||||
public function users()
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tambahkan metode ini agar View dapat mengecek izin langsung dari objek Role
|
||||
*/
|
||||
public function hasPermission($moduleSlug, $action)
|
||||
{
|
||||
return $this->permissions()
|
||||
->where('module_slug', $moduleSlug)
|
||||
->where($action, 1) // Cek kolom can_read, can_create, dll
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class RolePermission extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'role_id',
|
||||
'module_slug',
|
||||
'can_read',
|
||||
'can_create',
|
||||
'can_update',
|
||||
'can_delete'
|
||||
];
|
||||
|
||||
public function role()
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $fillable = ['key', 'value', 'group'];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Supply extends Model
|
||||
{
|
||||
protected $table = 'supplies';
|
||||
protected $guarded = ['id'];
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'category_id');
|
||||
}
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::saving(function ($supply) {
|
||||
// 1. Sinkronisasi Slug (Mencegah error SQL sebelumnya)
|
||||
if ($supply->isDirty('nama_perlengkapan') || empty($supply->slug)) {
|
||||
$supply->slug = Str::slug($supply->nama_perlengkapan) . '-' . Str::random(5);
|
||||
}
|
||||
|
||||
// 2. Auto-Categorization
|
||||
if (empty($supply->category_id)) {
|
||||
$name = strtolower($supply->nama_perlengkapan);
|
||||
|
||||
// Ambil kategori yang punya keywords
|
||||
$categories = Category::whereNotNull('keywords')->get();
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
$keywords = explode(',', strtolower($cat->keywords));
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$trimmed = trim($keyword);
|
||||
|
||||
// Cek apakah "pasir" atau keyword lain ada di nama produk
|
||||
if (!empty($trimmed) && str_contains($name, $trimmed)) {
|
||||
$supply->category_id = $cat->id;
|
||||
// PENTING: Update kolom teks 'kategori' agar tampilan tidak 'Umum'
|
||||
$supply->kategori = $cat->name;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback jika tidak ada yang cocok
|
||||
// Cari kategori yang mengandung kata 'Pasir' atau 'Umum'
|
||||
$default = Category::where('name', 'LIKE', '%Pasir%')
|
||||
->orWhere('name', 'Umum')
|
||||
->first();
|
||||
|
||||
if ($default) {
|
||||
$supply->category_id = $default->id;
|
||||
$supply->kategori = $default->name;
|
||||
} else {
|
||||
$supply->kategori = 'Umum';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Treatment extends Model
|
||||
{
|
||||
/**
|
||||
* Kolom yang boleh diisi secara mass-assignment.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'cat_id',
|
||||
'user_id',
|
||||
'tanggal',
|
||||
'keluhan',
|
||||
'diagnosa',
|
||||
'nama_obat',
|
||||
'dosis',
|
||||
'keterangan',
|
||||
'foto_bukti',
|
||||
'medicine_id',
|
||||
'qty_used',
|
||||
];
|
||||
|
||||
/**
|
||||
* Casting data agar formatnya sesuai (Tanggal jadi object Carbon).
|
||||
*/
|
||||
protected $casts = [
|
||||
'tanggal' => 'datetime',
|
||||
'qty_used' => 'decimal:2',
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* RELASI (MENGATASI ERROR RELATION NOT FOUND) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Relasi ke Kucing.
|
||||
*/
|
||||
public function cat(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cat::class, 'cat_id')->withDefault([
|
||||
'nama_kucing' => 'Kucing Terhapus'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi utama ke User (PIC).
|
||||
* Nama fungsi 'user' wajib ada karena dipanggil di TreatmentController baris 31.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id')->withDefault([
|
||||
'name' => 'System / Admin'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias untuk relasi user.
|
||||
* Nama fungsi 'pic' wajib ada karena dipanggil di DashboardController.
|
||||
*/
|
||||
public function pic(): BelongsTo
|
||||
{
|
||||
return $this->user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke Inventori Obat.
|
||||
*/
|
||||
public function medicine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Medicine::class, 'medicine_id')->withDefault([
|
||||
'nama_obat' => $this->nama_obat ?? 'Obat Manual'
|
||||
]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ACCESSORS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Mengambil URL foto bukti.
|
||||
* Cara akses di Blade: {{ $treatment->foto_url }}
|
||||
*/
|
||||
public function getFotoUrlAttribute(): string
|
||||
{
|
||||
if (!$this->foto_bukti) {
|
||||
return 'https://ui-avatars.com/api/?name=No+Image&background=f1f5f9&color=cbd5e1';
|
||||
}
|
||||
|
||||
return Storage::url($this->foto_bukti);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SCOPES */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Scope untuk mengurutkan data terbaru.
|
||||
*/
|
||||
public function scopeLatestFirst(Builder $query): void
|
||||
{
|
||||
$query->orderBy('tanggal', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Traits\HasPermissions; // Tambahan: Import Trait Permission
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo; // Tambahan: Import untuk relasi
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, HasPermissions; // Tambahan: Gunakan HasPermissions
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'role', // Kode existing Anda
|
||||
'role_id', // Tambahan: ID untuk relasi tabel Role
|
||||
'phone',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tambahan: Relasi ke tabel Roles
|
||||
* Setiap user memiliki satu Role.
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
|
||||
public function hasAccess($module, $permission)
|
||||
{
|
||||
// Jika admin, berikan akses penuh secara otomatis
|
||||
if ($this->role && $this->role->slug === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cek di tabel role_permissions melalui relasi role
|
||||
return $this->role->permissions()
|
||||
->where('module_slug', $module)
|
||||
->where($permission, 1) // $permission berisi 'can_read', 'can_update', dll
|
||||
->exists();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WeightHistory extends Model
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Medicine;
|
||||
use App\Models\User;
|
||||
use App\Models\Setting;
|
||||
use App\Helpers\WhatsappHelper;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use App\Mail\LowStockNotification;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MedicineObserver
|
||||
{
|
||||
/**
|
||||
* Handle the Medicine "updated" event.
|
||||
*/
|
||||
public function updated(Medicine $medicine)
|
||||
{
|
||||
// 1. Validasi: Hanya jalankan jika kolom 'qty' berubah
|
||||
if (!$medicine->wasChanged('qty')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Cek apakah stok di bawah ambang batas (5)
|
||||
if ($medicine->qty < 5) {
|
||||
|
||||
// 3. Ambil status Master Switch dari tabel settings
|
||||
$settings = Setting::where('group', 'notification')->pluck('value', 'key');
|
||||
$isEmailActive = ($settings['notif_email_status'] ?? 'inactive') === 'active';
|
||||
$isWaActive = ($settings['notif_wa_status'] ?? 'inactive') === 'active';
|
||||
|
||||
// 4. Cari Admin (Bisa lebih dari satu jika diperlukan)
|
||||
$admins = User::where('role', 'admin')->get();
|
||||
|
||||
if ($admins->isEmpty()) {
|
||||
Log::warning("Notifikasi stok rendah gagal dikirim: Tidak ada user dengan role admin.");
|
||||
return;
|
||||
}
|
||||
|
||||
$msg = "⚠️ *STOK KRITIS (earrin cats)*\n\n";
|
||||
$msg .= "Obat: {$medicine->nama_obat}\n";
|
||||
$msg .= "Sisa: *{$medicine->qty} {$medicine->unit}*\n";
|
||||
$msg .= "Segera lakukan restock untuk pasien cattery.";
|
||||
|
||||
foreach ($admins as $admin) {
|
||||
// Kirim Email jika aktif
|
||||
if ($isEmailActive && $admin->email) {
|
||||
try {
|
||||
Mail::to($admin->email)->send(new LowStockNotification($medicine));
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Gagal mengirim email stok rendah ke {$admin->email}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Kirim WhatsApp jika aktif & nomor tersedia
|
||||
if ($isWaActive && $admin->phone) {
|
||||
try {
|
||||
WhatsappHelper::send($admin->phone, $msg);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Gagal mengirim WA stok rendah ke {$admin->phone}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Food;
|
||||
use App\Models\Medicine;
|
||||
use App\Models\Setting;
|
||||
use App\Observers\FoodObserver;
|
||||
use App\Observers\MedicineObserver;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate; // Import Gate untuk sistem permission
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void { /* ... */ }
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// 1. DAFTARKAN OBSERVERS (Tetap Utuh & Aman)
|
||||
if (Schema::hasTable('medicines')) {
|
||||
Medicine::observe(MedicineObserver::class);
|
||||
}
|
||||
|
||||
if (class_exists(FoodObserver::class) && Schema::hasTable('foods')) {
|
||||
Food::observe(FoodObserver::class);
|
||||
}
|
||||
|
||||
// 2. GLOBAL SETTINGS (Dinamis)
|
||||
if (!app()->runningInConsole() && Schema::hasTable('settings')) {
|
||||
$rawSettings = Setting::pluck('value', 'key')->toArray();
|
||||
View::share('setting', (object) $rawSettings);
|
||||
}
|
||||
|
||||
// 3. VIEW COMPOSER (Fungsi Notifikasi Stok Tetap Berjalan)
|
||||
$alertViews = ['layouts.app', 'layouts.navigation-sidebar', 'dashboard', 'pages.*'];
|
||||
|
||||
View::composer($alertViews, function ($view) {
|
||||
$stockData = Cache::remember('low_stock_alerts', 600, function () {
|
||||
$lowMedicines = Medicine::where('qty', '<', 5)->get(['nama_obat', 'qty', 'unit']);
|
||||
$lowFoods = Food::where('qty', '<', 5)->get(['nama_makanan', 'qty', 'unit']);
|
||||
|
||||
return [
|
||||
'lowStockMedicines' => $lowMedicines,
|
||||
'lowStockFoods' => $lowFoods,
|
||||
'totalAlerts' => $lowMedicines->count() + $lowFoods->count(),
|
||||
];
|
||||
});
|
||||
$view->with($stockData);
|
||||
});
|
||||
|
||||
// 4. SISTEM OTORISASI GLOBAL (Gate Permission)
|
||||
Gate::define('check-access', function ($user, $module, $permission) {
|
||||
// Hindari pengecekan jika user tidak memiliki role_id atau data role
|
||||
if (!$user->role_id && !$user->role) return false;
|
||||
|
||||
// Deteksi Role: Apakah string (lama) atau object (baru)
|
||||
// Jika $user->role adalah string, ambil langsung. Jika object, ambil slug-nya.
|
||||
$roleIdentifier = is_object($user->role) ? $user->role->slug : $user->role;
|
||||
|
||||
// Admin Bypass: Role admin memiliki akses ke seluruh fitur
|
||||
if ($roleIdentifier === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Menyelaraskan nama kolom (can_read, can_create, can_update, can_delete)
|
||||
$column = str_starts_with($permission, 'can_') ? $permission : 'can_' . $permission;
|
||||
|
||||
// Cek matriks di tabel role_permissions berdasarkan role_id
|
||||
return \App\Models\RolePermission::where('role_id', $user->role_id)
|
||||
->where('module_slug', $module)
|
||||
->where($column, 1)
|
||||
->exists();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
namespace App\Traits;
|
||||
|
||||
trait HasPermissions {
|
||||
public function hasPermission($moduleSlug, $action) {
|
||||
// Jika user tidak punya role, akses ditolak
|
||||
if (!$this->role) return false;
|
||||
|
||||
// Cek ke tabel permission_role
|
||||
return $this->role->permissions()
|
||||
->where('module_slug', $moduleSlug)
|
||||
->where($action, 1)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
trait LogsActivity
|
||||
{
|
||||
public function logAction($action, $modelInstance, $description)
|
||||
{
|
||||
if (Auth::check()) {
|
||||
// Pastikan model tidak null sebelum mengambil namanya
|
||||
$modelName = $modelInstance ? class_basename($modelInstance) : 'System';
|
||||
$modelId = $modelInstance ? ($modelInstance->id ?? null) : null;
|
||||
|
||||
ActivityLog::create([
|
||||
'user_id' => Auth::id(),
|
||||
'action' => $action,
|
||||
'model' => $modelName, // Sekarang tidak akan NULL
|
||||
'model_id' => $modelId,
|
||||
'description' => $description,
|
||||
'ip_address' => request()->ip(),
|
||||
// getChanges() hanya untuk update; saat delete kita kirim null atau data asli
|
||||
'properties' => ($modelInstance && method_exists($modelInstance, 'getChanges'))
|
||||
? $modelInstance->getChanges()
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AppLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.app');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GuestLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.guest');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user