Initial commit: Struktur aplikasi ilycats

This commit is contained in:
Dwi F Sukmana
2026-02-08 15:16:35 +07:00
commit 0921901aed
235 changed files with 29016 additions and 0 deletions
@@ -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.");
}
}
}
+34
View File
@@ -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,
];
}
}
+17
View File
@@ -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();
}
}
+19
View File
@@ -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',
];
}
}
+94
View File
@@ -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'],
],
];
}
}
+39
View File
@@ -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';
}
}
+22
View File
@@ -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',
];
}
}
+131
View File
@@ -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'],
],
],
],
];
}
}
+20
View File
@@ -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',
]);
}
}
+259
View File
@@ -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;
}
}
+170
View File
@@ -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.');
}
}
+8
View File
@@ -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'
));
}
}
+128
View File
@@ -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'));
}
}
+161
View File
@@ -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('/');
}
}
+139
View File
@@ -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");
}
}
+158
View File
@@ -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.");
}
}
+149
View File
@@ -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()
];
}
}
}
+126
View File
@@ -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',
]);
}
}
+200
View File
@@ -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.');
}
}
+34
View File
@@ -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);
}
}
+85
View File
@@ -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());
}
}
+31
View File
@@ -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),
],
];
}
}
+28
View File
@@ -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,
]);
}
}
+24
View File
@@ -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'
]);
}
}
+25
View File
@@ -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.
]);
}
}
+26
View File
@@ -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');
}
}
+16
View File
@@ -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);
}
}
+182
View File
@@ -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;
}
);
}
}
+26
View File
@@ -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);
}
}
+75
View File
@@ -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';
}
}
}
});
}
}
+112
View File
@@ -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')
);
}
}
+137
View File
@@ -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'
]);
}
}
+80
View File
@@ -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');
}
}
+31
View File
@@ -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();
}
}
+22
View File
@@ -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);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = ['key', 'value', 'group'];
}
+67
View File
@@ -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';
}
}
});
}
}
+109
View File
@@ -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');
}
}
+77
View File
@@ -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();
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WeightHistory extends Model
{
//
}
+67
View File
@@ -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());
}
}
}
}
}
}
+79
View File
@@ -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();
});
}
}
+15
View File
@@ -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();
}
}
+31
View File
@@ -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,
]);
}
}
}
+17
View File
@@ -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');
}
}
+17
View File
@@ -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');
}
}