chore: stop tracking compiled blade cache
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Helpers\WhatsappHelper;
|
||||
use App\Models\{Cat, Food, Matting, Medicine, Role, Setting, Supply, User};
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SendOperationalNotifications extends Command
|
||||
{
|
||||
protected $signature = 'ilycats:notify {--dry-run : Tampilkan kandidat tanpa mengirim WhatsApp}';
|
||||
protected $description = 'Mengirim notifikasi WhatsApp HPL, kedaluwarsa inventori, dan berat badan yang belum diperbarui.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ((Setting::where('key', 'notif_wa_status')->value('value') ?? 'inactive') !== 'active') {
|
||||
$this->info('Notifikasi WhatsApp sedang nonaktif.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->sendHplNotifications();
|
||||
$this->sendInventoryExpiryNotifications();
|
||||
$this->sendWeightStaleNotifications();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function recipients(string $settingKey)
|
||||
{
|
||||
$roleId = Setting::where('key', $settingKey)->value('value');
|
||||
$query = User::query()
|
||||
->where('is_active', true)
|
||||
->whereNotNull('phone')
|
||||
->where('phone', '!=', '');
|
||||
|
||||
if ($roleId) {
|
||||
$role = Role::find($roleId);
|
||||
$query->where(function ($q) use ($roleId, $role) {
|
||||
$q->where('role_id', $roleId);
|
||||
// Tetap dukung akun admin lama yang belum mempunyai role_id.
|
||||
if ($role?->slug === 'admin') {
|
||||
$q->orWhere('role', 'admin');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$query->where('role', 'admin');
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
private function sendHplNotifications(): void
|
||||
{
|
||||
if ((Setting::where('key', 'notif_hpl_wa')->value('value') ?? 'active') !== 'active') return;
|
||||
$today = now()->startOfDay();
|
||||
$recipients = $this->recipients('notif_hpl_role_id');
|
||||
|
||||
Matting::with(['dam','sire'])->whereIn('status', ['proses','hamil'])->whereNotNull('tgl_kawin_awal')->get()->each(function ($m) use ($today, $recipients) {
|
||||
$hpl = Carbon::parse($m->tgl_kawin_awal)->startOfDay()->addDays(63);
|
||||
$days = (int) $today->diffInDays($hpl, false);
|
||||
if ($days < 0 || $days > 7) return;
|
||||
$label = $days === 0 ? 'HPL hari ini' : "H-{$days}";
|
||||
$message = "🐾 ILYCats - Pengingat HPL\n\nDam: " . ($m->dam?->nama_kucing ?? '-') . "\nSire: " . ($m->sire?->nama_kucing ?? '-') . "\nHPL: " . $hpl->format('d-m-Y') . "\nStatus: {$label}\n\nMohon pantau kondisi induk dan siapkan kebutuhan persalinan.";
|
||||
foreach ($recipients as $user) {
|
||||
$this->sendOnce("hpl:{$m->id}:{$today->toDateString()}:{$user->id}", 'hpl', $user, $message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function sendInventoryExpiryNotifications(): void
|
||||
{
|
||||
if ((Setting::where('key', 'notif_inventory_expiry_wa')->value('value') ?? 'active') !== 'active') return;
|
||||
$today = now()->startOfDay();
|
||||
$limit = $today->copy()->addDays(14);
|
||||
$recipients = $this->recipients('notif_inventory_role_id');
|
||||
$types = [
|
||||
'obat' => [Medicine::class, 'nama_obat'],
|
||||
'makanan' => [Food::class, 'nama_makanan'],
|
||||
'perlengkapan' => [Supply::class, 'nama_perlengkapan'],
|
||||
];
|
||||
|
||||
foreach ($types as $type => [$model, $nameField]) {
|
||||
$model::whereNotNull('expired_at')->whereDate('expired_at', '>=', $today)->whereDate('expired_at', '<=', $limit)->get()->each(function ($item) use ($type, $nameField, $today, $recipients) {
|
||||
$expiry = Carbon::parse($item->expired_at)->startOfDay();
|
||||
$days = (int) $today->diffInDays($expiry, false);
|
||||
$message = "⚠️ ILYCats - Kedaluwarsa Inventori\n\nJenis: " . ucfirst($type) . "\nItem: {$item->{$nameField}}\nKedaluwarsa: " . $expiry->format('d-m-Y') . "\nSisa: {$days} hari\nStok: {$item->qty} {$item->unit}\n\nMohon cek stok dan rencanakan penggantian.";
|
||||
foreach ($recipients as $user) {
|
||||
$this->sendOnce("expiry:{$type}:{$item->id}:{$expiry->toDateString()}:{$user->id}", 'inventory_expiry', $user, $message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function sendWeightStaleNotifications(): void
|
||||
{
|
||||
if ((Setting::where('key', 'notif_weight_stale_wa')->value('value') ?? 'active') !== 'active') return;
|
||||
$recipients = $this->recipients('notif_weight_role_id');
|
||||
$threshold = now()->subDays(7)->startOfDay();
|
||||
|
||||
Cat::activeInCattery()->withMax('weightHistories as last_weight_at', 'tanggal_timbang')->get()->each(function ($cat) use ($threshold, $recipients) {
|
||||
$last = $cat->last_weight_at ? Carbon::parse($cat->last_weight_at)->startOfDay() : null;
|
||||
if ($last && $last->gte($threshold)) return;
|
||||
$days = $last ? $last->diffInDays(now()->startOfDay()) : null;
|
||||
$lastKey = $last?->toDateString() ?? 'none';
|
||||
$message = "⚖️ ILYCats - Berat Belum Diperbarui\n\nKucing: {$cat->nama_kucing}\nBerat terakhir: " . ($cat->berat_badan ? $cat->berat_badan . ' kg' : '-') . "\nTerakhir ditimbang: " . ($last ? $last->format('d-m-Y') . " ({$days} hari lalu)" : 'belum ada pencatatan') . "\n\nMohon lakukan penimbangan terbaru.";
|
||||
foreach ($recipients as $user) {
|
||||
$this->sendOnce("weight:{$cat->id}:{$lastKey}:{$user->id}", 'weight_stale', $user, $message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function sendOnce(string $key, string $type, User $user, string $message): void
|
||||
{
|
||||
if (DB::table('notification_dispatches')->where('notification_key', $key)->exists()) return;
|
||||
if ($this->option('dry-run')) {
|
||||
$this->line("[DRY] {$user->name}: {$key}");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
WhatsappHelper::send($user->phone, $message);
|
||||
DB::table('notification_dispatches')->insert([
|
||||
'notification_key' => $key,
|
||||
'type' => $type,
|
||||
'user_id' => $user->id,
|
||||
'channel' => 'whatsapp',
|
||||
'sent_at' => now(),
|
||||
'message' => $message,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("ILYCats WA notification failed [{$key}]: {$e->getMessage()}");
|
||||
$this->error("Gagal {$key}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,75 @@
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class WhatsappHelper
|
||||
{
|
||||
public static function send($target, $message)
|
||||
public static function send(string $target, string $message): bool
|
||||
{
|
||||
$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"],
|
||||
$target = self::normalizePhone($target);
|
||||
$provider = strtolower((string) config('services.whatsapp.provider', 'fonnte'));
|
||||
|
||||
if ($provider === 'waha') {
|
||||
$baseUrl = rtrim((string) config('services.whatsapp.waha_url'), '/');
|
||||
if ($baseUrl === '') {
|
||||
throw new RuntimeException('WAHA_URL belum dikonfigurasi.');
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
if ($key = config('services.whatsapp.waha_api_key')) {
|
||||
$headers['X-Api-Key'] = $key;
|
||||
}
|
||||
|
||||
$response = Http::timeout(8)->withHeaders($headers)->post($baseUrl . '/api/sendText', [
|
||||
'session' => config('services.whatsapp.waha_session', 'default'),
|
||||
'chatId' => $target . '@c.us',
|
||||
'text' => $message,
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new RuntimeException('WAHA gagal mengirim pesan: HTTP ' . $response->status());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
$token = (string) config('services.whatsapp.fonnte_token');
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('FONNTE_TOKEN belum dikonfigurasi.');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->timeout(8)->withHeaders([
|
||||
'Authorization' => $token,
|
||||
])->post('https://api.fonnte.com/send', [
|
||||
'target' => $target,
|
||||
'message' => $message,
|
||||
]);
|
||||
curl_exec($curl);
|
||||
curl_close($curl);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new RuntimeException('Fonnte gagal mengirim pesan: HTTP ' . $response->status());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static function configured(): bool
|
||||
{
|
||||
$provider = strtolower((string) config('services.whatsapp.provider', 'fonnte'));
|
||||
return $provider === 'waha'
|
||||
? (string) config('services.whatsapp.waha_url') !== ''
|
||||
: (string) config('services.whatsapp.fonnte_token') !== '';
|
||||
}
|
||||
|
||||
public static function normalizePhone(string $phone): string
|
||||
{
|
||||
$phone = preg_replace('/\D+/', '', $phone) ?: '';
|
||||
if (str_starts_with($phone, '0')) {
|
||||
$phone = '62' . substr($phone, 1);
|
||||
}
|
||||
if (!str_starts_with($phone, '62')) {
|
||||
$phone = '62' . ltrim($phone, '0');
|
||||
}
|
||||
return $phone;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,28 @@ class MattingController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public function updateStatus(Request $request, Matting $matting): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'update']);
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|in:proses,hamil,berhasil,gagal',
|
||||
'actual_birth_date' => 'nullable|date',
|
||||
'breeding_notes' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
if ($validated['status'] === 'hamil' && !$matting->pregnancy_confirmed_at) {
|
||||
$validated['pregnancy_confirmed_at'] = now()->toDateString();
|
||||
}
|
||||
if ($validated['status'] === 'berhasil' && empty($validated['actual_birth_date'])) {
|
||||
$validated['actual_birth_date'] = $matting->kittens()->min('tgl_lahir') ?: now()->toDateString();
|
||||
}
|
||||
|
||||
$matting->update($validated);
|
||||
$this->logAction('MATTING_STATUS_UPDATED', $matting, 'Memperbarui tahap pembiakan menjadi ' . $validated['status']);
|
||||
return back()->with('success', 'Tahap pembiakan berhasil diperbarui.');
|
||||
}
|
||||
|
||||
/**
|
||||
* SINKRONISASI DATA INDUK (EMS Fix)
|
||||
*/
|
||||
@@ -267,7 +289,10 @@ class MattingController extends Controller
|
||||
'sire_id' => 'required|exists:cats,id',
|
||||
'dam_id' => 'required|exists:cats,id',
|
||||
'tgl_kawin_awal' => 'required|date',
|
||||
'status' => 'required|in:proses,berhasil,gagal',
|
||||
'status' => 'required|in:proses,hamil,berhasil,gagal',
|
||||
'pregnancy_confirmed_at' => 'nullable|date',
|
||||
'actual_birth_date' => 'nullable|date',
|
||||
'breeding_notes' => 'nullable|string|max:2000',
|
||||
'ems_bapak' => 'nullable|string',
|
||||
'ems_ibu' => 'nullable|string',
|
||||
'no_pedigree_bapak' => 'nullable|string',
|
||||
|
||||
@@ -30,13 +30,24 @@ class CatController extends Controller
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// Proteksi Akses: Read
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$now = now();
|
||||
$listScope = $request->get('list_scope', 'active');
|
||||
if (!in_array($listScope, ['active', 'adopted', 'external_parent', 'all'], true)) {
|
||||
$listScope = 'active';
|
||||
}
|
||||
|
||||
$query = Cat::query()->with(['pic:id,name', 'sire', 'dam', 'registrations.association']);
|
||||
|
||||
// 1. PENCARIAN GLOBAL
|
||||
if ($listScope === 'active') {
|
||||
$query->activeInCattery();
|
||||
} elseif ($listScope === 'adopted') {
|
||||
$query->adopted();
|
||||
} elseif ($listScope === 'external_parent') {
|
||||
$query->externalParents();
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
@@ -46,22 +57,18 @@ class CatController extends Controller
|
||||
->orWhere('pedigree', 'like', "%{$search}%")
|
||||
->orWhere('kode_ems', 'like', "%{$search}%")
|
||||
->orWhere('lokasi_kandang', 'like', "%{$search}%")
|
||||
->orWhereHas('pic', function($sub) use ($search) {
|
||||
$sub->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
->orWhereHas('pic', fn($sub) => $sub->where('name', 'like', "%{$search}%"));
|
||||
});
|
||||
}
|
||||
|
||||
// 2. FILTER OVERDUE (Link dari Dashboard)
|
||||
if ($request->filled('filter_overdue')) {
|
||||
$filter = $request->get('filter_overdue');
|
||||
|
||||
$query->where(function($q) use ($filter, $now) {
|
||||
if ($filter === 'vaccine') {
|
||||
$q->whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now);
|
||||
} elseif ($filter === 'flea' || $filter === 'obat_kutu') {
|
||||
} elseif (in_array($filter, ['flea', 'obat_kutu'], true)) {
|
||||
$q->whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<', $now);
|
||||
} elseif ($filter === 'deworming' || $filter === 'obat_cacing') {
|
||||
} elseif (in_array($filter, ['deworming', 'obat_cacing'], true)) {
|
||||
$q->whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<', $now);
|
||||
} elseif ($filter === 'grooming') {
|
||||
$q->whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<', $now);
|
||||
@@ -69,63 +76,47 @@ class CatController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// --- TAMBAHAN: 2.1 FILTER KESEHATAN (Link dari Dashboard Under Treatment) ---
|
||||
if ($request->get('filter_health') === 'treatment') {
|
||||
// Menggunakan kolom treatment_status: 1 (Treatment), 2 (Healthy)
|
||||
$query->where('treatment_status', 1);
|
||||
}
|
||||
|
||||
// 3. FILTER STATUS & LOGIKA USIA DINAMIS
|
||||
if ($request->filled('status')) {
|
||||
$status = $request->status;
|
||||
|
||||
if (in_array(strtolower($status), ['adult', 'dewasa'])) {
|
||||
// Dewasa: > 12 bulan
|
||||
if (in_array(strtolower($status), ['adult', 'dewasa'], true)) {
|
||||
$query->where('tanggal_lahir', '<=', $now->copy()->subMonths(12));
|
||||
} elseif ($status === 'Junior') {
|
||||
// Junior: 7 bulan - 12 bulan
|
||||
$query->whereBetween('tanggal_lahir', [
|
||||
$now->copy()->subMonths(12)->addDay(),
|
||||
$now->copy()->subMonths(7)
|
||||
]);
|
||||
$query->whereBetween('tanggal_lahir', [$now->copy()->subMonths(12)->addDay(), $now->copy()->subMonths(7)]);
|
||||
} elseif ($status === 'Kitten') {
|
||||
// Kitten: < 7 bulan
|
||||
$query->where('tanggal_lahir', '>', $now->copy()->subMonths(7));
|
||||
} else {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. FILTER STANDAR (Ras & Gender)
|
||||
if ($request->filled('ras')) $query->where('ras', $request->ras);
|
||||
if ($request->filled('ras')) $query->where('ras', $request->ras);
|
||||
if ($request->filled('gender')) $query->where('jenis_kelamin', $request->gender);
|
||||
|
||||
// 5. VALIDASI SORTING
|
||||
$allowedSorts = ['nama_kucing', 'lokasi_kandang', 'berat_badan', 'created_at', 'pic_name'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'created_at';
|
||||
$sort = in_array($request->get('sort'), $allowedSorts, true) ? $request->get('sort') : 'created_at';
|
||||
$direction = strtolower($request->get('direction')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
if ($sort === 'pic_name') {
|
||||
$query->leftJoin('users', 'cats.pic_id', '=', 'users.id')
|
||||
->select('cats.*')
|
||||
->orderBy('users.name', $direction);
|
||||
$query->leftJoin('users', 'cats.pic_id', '=', 'users.id')->select('cats.*')->orderBy('users.name', $direction);
|
||||
} else {
|
||||
$query->orderBy("cats.$sort", $direction);
|
||||
}
|
||||
|
||||
// 6. EKSEKUSI DENGAN PAGINASI
|
||||
$cats = $query->paginate(15)->withQueryString();
|
||||
|
||||
// 7. STATISTIK (DIPERBAIKI: Menggunakan treatment_status)
|
||||
$totalCats = Cat::count();
|
||||
|
||||
// Perbaikan: Tidak lagi menggunakan string search yang tidak akurat (15 vs 4)
|
||||
// Sekarang langsung merujuk pada kolom treatment_status bernilai 1
|
||||
$sickCats = Cat::where('treatment_status', 1)->count();
|
||||
$activeCats = Cat::activeInCattery()->count();
|
||||
$adoptedCats = Cat::adopted()->count();
|
||||
$externalParentCats = Cat::externalParents()->count();
|
||||
$sickCats = Cat::activeInCattery()->where('treatment_status', 1)->count();
|
||||
$breeds = Cat::activeInCattery()->distinct()->whereNotNull('ras')->pluck('ras');
|
||||
|
||||
$breeds = Cat::distinct()->whereNotNull('ras')->pluck('ras');
|
||||
|
||||
return view('pages.cats.index', compact('cats', 'totalCats', 'sickCats', 'breeds'));
|
||||
return view('pages.cats.index', compact(
|
||||
'cats', 'activeCats', 'adoptedCats', 'externalParentCats', 'sickCats', 'breeds', 'listScope'
|
||||
));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
@@ -169,6 +160,13 @@ class CatController extends Controller
|
||||
$validated['induk_jantan_id'] = $request->induk_jantan_id;
|
||||
$validated['induk_betina_id'] = $request->induk_betina_id;
|
||||
$validated['pic_id'] = $request->pic_id ?? auth()->id();
|
||||
$validated['cattery_status'] = $request->boolean('is_external_parent')
|
||||
? 'external_parent'
|
||||
: ($request->boolean('is_adopted') ? 'adopted' : 'active');
|
||||
$validated['adopted_at'] = $validated['cattery_status'] === 'adopted'
|
||||
? ($request->input('adopted_at') ?: now()->toDateString())
|
||||
: null;
|
||||
$validated['adoption_notes'] = $request->input('adoption_notes');
|
||||
|
||||
$cat = Cat::create($validated);
|
||||
|
||||
@@ -267,6 +265,13 @@ private function handleImageUpload($file): string
|
||||
* Kita paksa nilainya menjadi ['NON-PED'] agar data lama di DB terhapus/terganti.
|
||||
*/
|
||||
$validated['registry'] = $request->input('registry', ['NON-PED']);
|
||||
$validated['cattery_status'] = $request->boolean('is_external_parent')
|
||||
? 'external_parent'
|
||||
: ($request->boolean('is_adopted') ? 'adopted' : 'active');
|
||||
$validated['adopted_at'] = $validated['cattery_status'] === 'adopted'
|
||||
? ($request->input('adopted_at') ?: ($cat->adopted_at?->format('Y-m-d') ?? now()->toDateString()))
|
||||
: null;
|
||||
$validated['adoption_notes'] = $request->input('adoption_notes');
|
||||
|
||||
// 5. Update Data Utama
|
||||
// Logika berat_badan sudah otomatis dihandle oleh Model (static::saving/updated)
|
||||
@@ -449,7 +454,9 @@ private function validateCat(Request $request, $id = null)
|
||||
'kode_ems' => 'nullable|string|max:50',
|
||||
|
||||
// Status & Lokasi
|
||||
'status' => 'nullable|string|max:50',
|
||||
'status' => 'nullable|string|max:50',
|
||||
'adopted_at' => 'nullable|date',
|
||||
'adoption_notes'=> 'nullable|string|max:1000',
|
||||
'lokasi_kandang'=> 'nullable|string|max:255',
|
||||
'pic_id' => 'nullable|exists:users,id',
|
||||
|
||||
|
||||
@@ -86,6 +86,29 @@ class CatRegistrationController extends Controller
|
||||
return back()->with('success', 'Dokumen berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
|
||||
public function showDocument(Cat $cat, CatRegistration $registration, CatRegistrationDocument $document)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
abort_unless($registration->cat_id === $cat->id && $document->cat_registration_id === $registration->id, 404);
|
||||
abort_unless(Storage::disk('public')->exists($document->file_path), 404);
|
||||
|
||||
return response()->file(Storage::disk('public')->path($document->file_path), [
|
||||
'Content-Disposition' => 'inline; filename="' . basename($document->file_path) . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadDocument(Cat $cat, CatRegistration $registration, CatRegistrationDocument $document)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
abort_unless($registration->cat_id === $cat->id && $document->cat_registration_id === $registration->id, 404);
|
||||
abort_unless(Storage::disk('public')->exists($document->file_path), 404);
|
||||
|
||||
$extension = pathinfo($document->file_path, PATHINFO_EXTENSION);
|
||||
$filename = str($document->label ?: 'pedigree')->slug('_') . ($extension ? '.' . $extension : '');
|
||||
return Storage::disk('public')->download($document->file_path, $filename);
|
||||
}
|
||||
|
||||
public function destroyDocument(Cat $cat, CatRegistration $registration, CatRegistrationDocument $document)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
@@ -20,14 +20,14 @@ class DashboardController extends Controller
|
||||
$selectedStatus = $request->get('status');
|
||||
|
||||
$stats = [
|
||||
'total_cats' => Cat::count(),
|
||||
'total_cats' => Cat::activeInCattery()->count(),
|
||||
'total_kittens' => Kitten::count(),
|
||||
'sick_cats' => Cat::where('treatment_status', 1)->count(),
|
||||
'total_mating' => Matting::where('status', 'proses')->count(),
|
||||
'sick_cats' => Cat::activeInCattery()->where('treatment_status', 1)->count(),
|
||||
'total_mating' => Matting::whereIn('status', ['proses', 'hamil'])->count(),
|
||||
];
|
||||
|
||||
$upcomingBirths = Matting::with(['dam', 'sire'])
|
||||
->where('status', 'proses')
|
||||
->whereIn('status', ['proses', 'hamil'])
|
||||
->whereNotNull('tgl_kawin_awal')
|
||||
->get()
|
||||
->map(function ($mating) use ($now) {
|
||||
@@ -40,19 +40,19 @@ class DashboardController extends Controller
|
||||
|
||||
$overdueAlerts = [
|
||||
'vaccine' => [
|
||||
'count' => Cat::whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now)->count(),
|
||||
'count' => Cat::activeInCattery()->whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'vaccine']),
|
||||
],
|
||||
'flea' => [
|
||||
'count' => Cat::whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<', $now)->count(),
|
||||
'count' => Cat::activeInCattery()->whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'flea']),
|
||||
],
|
||||
'deworming' => [
|
||||
'count' => Cat::whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<', $now)->count(),
|
||||
'count' => Cat::activeInCattery()->whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'deworming']),
|
||||
],
|
||||
'grooming' => [
|
||||
'count' => Cat::whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<', $now)->count(),
|
||||
'count' => Cat::activeInCattery()->whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'grooming']),
|
||||
],
|
||||
];
|
||||
@@ -72,6 +72,7 @@ class DashboardController extends Controller
|
||||
'weight_histories.berat',
|
||||
'weight_histories.tanggal_timbang'
|
||||
)
|
||||
->where(function ($q) { $q->whereNull('cats.cattery_status')->orWhere('cats.cattery_status', 'active'); })
|
||||
->whereIn('weight_histories.id', function ($query) use ($selectedMonth, $selectedYear) {
|
||||
$query->select(DB::raw('MAX(id)'))
|
||||
->from('weight_histories')
|
||||
@@ -148,6 +149,7 @@ class DashboardController extends Controller
|
||||
'weight_histories.tanggal_timbang'
|
||||
)
|
||||
->whereNull('cats.deleted_at')
|
||||
->where(function ($q) { $q->whereNull('cats.cattery_status')->orWhere('cats.cattery_status', 'active'); })
|
||||
->whereDate('weight_histories.tanggal_timbang', '>=', $trendStart->toDateString())
|
||||
->orderBy('weight_histories.tanggal_timbang')
|
||||
->orderBy('cats.nama_kucing')
|
||||
@@ -303,6 +305,16 @@ class DashboardController extends Controller
|
||||
->sortByDesc('date')
|
||||
->values();
|
||||
|
||||
// Daftar kenaikan/penurunan berat berdasarkan kategori pada periode yang dipilih.
|
||||
$weightMovements = collect(['Kitten', 'Junior', 'Dewasa'])->mapWithKeys(function ($category) use ($weightLatestTable) {
|
||||
$rows = $weightLatestTable->where('category', $category)->whereNotNull('change');
|
||||
return [$category => [
|
||||
'gainers' => $rows->where('change', '>', 0)->sortByDesc('change')->take(5)->values(),
|
||||
'losers' => $rows->where('change', '<', 0)->sortBy('change')->take(5)->values(),
|
||||
]];
|
||||
});
|
||||
$weightPeriodLabel = $trendStart->translatedFormat('M Y') . ' – ' . $now->translatedFormat('M Y');
|
||||
|
||||
// Inventory
|
||||
$allExpiringItems = collect();
|
||||
$lowStock = collect();
|
||||
@@ -392,6 +404,8 @@ class DashboardController extends Controller
|
||||
'weightTableCategory',
|
||||
'weightTrendCategory',
|
||||
'weightTrendMonths',
|
||||
'weightMovements',
|
||||
'weightPeriodLabel',
|
||||
'lowStock',
|
||||
'totalLowStock',
|
||||
'expiringItems',
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Role;
|
||||
use App\Helpers\WhatsappHelper;
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
@@ -24,7 +26,10 @@ class SettingController extends Controller
|
||||
'app_slogan',
|
||||
'app_version', // Tambahan baru
|
||||
'app_address',
|
||||
'wa_number'
|
||||
'wa_number',
|
||||
'notif_hpl_role_id',
|
||||
'notif_inventory_role_id',
|
||||
'notif_weight_role_id'
|
||||
];
|
||||
|
||||
public function index()
|
||||
@@ -46,7 +51,8 @@ class SettingController extends Controller
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
return view('pages.admin.settings', compact('settings', 'waStatus', 'emailStatus', 'logs'));
|
||||
$roles = Role::orderBy('name')->get(['id','name']);
|
||||
return view('pages.admin.settings', compact('settings', 'waStatus', 'emailStatus', 'logs', 'roles'));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
@@ -76,7 +82,21 @@ class SettingController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$this->logAction('UPDATED_SETTINGS', null, "Memperbarui identitas sistem ke versi " . $request->app_version);
|
||||
foreach (['notif_hpl_wa','notif_inventory_expiry_wa','notif_weight_stale_wa','notif_wa_status'] as $notificationKey) {
|
||||
Setting::updateOrCreate(
|
||||
['key' => $notificationKey],
|
||||
['value' => $request->boolean($notificationKey) ? 'active' : 'inactive', 'group' => 'notification']
|
||||
);
|
||||
}
|
||||
|
||||
foreach (['notif_hpl_role_id','notif_inventory_role_id','notif_weight_role_id'] as $roleKey) {
|
||||
if ($request->filled($roleKey)) {
|
||||
$request->validate([$roleKey => 'exists:roles,id']);
|
||||
Setting::updateOrCreate(['key' => $roleKey], ['value' => $request->input($roleKey), 'group' => 'notification']);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logAction('UPDATED_SETTINGS', null, "Memperbarui identitas dan notifikasi sistem ke versi " . $request->app_version);
|
||||
|
||||
return back()->with('success', 'Konfigurasi ' . $request->app_name . ' berhasil diperbarui!');
|
||||
}
|
||||
@@ -104,21 +124,12 @@ class SettingController extends Controller
|
||||
|
||||
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'];
|
||||
}
|
||||
$provider = strtoupper((string) config('services.whatsapp.provider', 'fonnte'));
|
||||
return [
|
||||
'active' => WhatsappHelper::configured(),
|
||||
'quota' => null,
|
||||
'message' => WhatsappHelper::configured() ? "{$provider} dikonfigurasi" : "{$provider} belum dikonfigurasi",
|
||||
];
|
||||
}
|
||||
|
||||
private function checkEmailService()
|
||||
|
||||
@@ -149,6 +149,44 @@ class UserController extends Controller
|
||||
return redirect()->route('admin.users.index')->with('success', "User {$userName} telah dihapus.");
|
||||
}
|
||||
|
||||
public function toggleStatus(User $user): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['users', 'update']);
|
||||
|
||||
if ($user->id === auth()->id() && $user->is_active) {
|
||||
return back()->with('error', 'Akun yang sedang digunakan tidak dapat dinonaktifkan.');
|
||||
}
|
||||
|
||||
$newStatus = !$user->is_active;
|
||||
$user->forceFill([
|
||||
'is_active' => $newStatus,
|
||||
'deactivated_at' => $newStatus ? null : now(),
|
||||
])->save();
|
||||
|
||||
$this->logAction(
|
||||
$newStatus ? 'USER_ACTIVATED' : 'USER_DEACTIVATED',
|
||||
$user,
|
||||
($newStatus ? 'Mengaktifkan' : 'Menonaktifkan') . " akun user: {$user->name}",
|
||||
'User'
|
||||
);
|
||||
|
||||
return back()->with('success', "Status akun {$user->name} berhasil diperbarui.");
|
||||
}
|
||||
|
||||
public function changePassword(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['users', 'update']);
|
||||
|
||||
$validated = $request->validate([
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user->forceFill(['password' => Hash::make($validated['password'])])->save();
|
||||
$this->logAction('USER_PASSWORD_CHANGED', $user, "Mengganti kata sandi user: {$user->name}", 'User');
|
||||
|
||||
return back()->with('success', "Kata sandi {$user->name} berhasil diganti.");
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* AUDIT TRAIL / LOGS SECTION */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
@@ -17,7 +17,7 @@ class AuthenticateApiToken
|
||||
}
|
||||
|
||||
$token = ApiToken::with('user')->where('token_hash', hash('sha256', $plain))->first();
|
||||
if (!$token || !$token->user || ($token->expires_at && $token->expires_at->isPast())) {
|
||||
if (!$token || !$token->user || $token->user->is_active === false || ($token->expires_at && $token->expires_at->isPast())) {
|
||||
return response()->json(['message' => 'Token akses tidak valid atau sudah kedaluwarsa.'], 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserIsActive
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if ($user && $user->is_active === false) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->with('status', 'Akun Anda sedang dinonaktifkan. Hubungi administrator.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -23,7 +23,8 @@ class Cat extends Model
|
||||
'jadwal_vaksin', 'jadwal_obat_cacing', 'jadwal_obat_kutu',
|
||||
'jadwal_grooming', 'induk_jantan_id', 'induk_betina_id',
|
||||
'lokasi_kandang', 'pic_id', 'berat_badan',
|
||||
'pedigree','sertifikat_ped_file', 'microchip', 'status', 'kode_ems','treatment_status'
|
||||
'pedigree','sertifikat_ped_file', 'microchip', 'status', 'kode_ems','treatment_status',
|
||||
'cattery_status','adopted_at','adoption_notes'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -69,6 +70,7 @@ class Cat extends Model
|
||||
'registry' => 'array', // Otomatis mengurus JSON di DB
|
||||
'berat_badan' => 'decimal:2',
|
||||
'berat_terakhir' => 'decimal:2',
|
||||
'adopted_at' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -241,4 +243,30 @@ public function scopeHealthy($query)
|
||||
{
|
||||
return $query->where('treatment_status', 2);
|
||||
}
|
||||
|
||||
public function scopeActiveInCattery($query)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->whereNull('cattery_status')->orWhere('cattery_status', 'active');
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeAdopted($query)
|
||||
{
|
||||
return $query->where('cattery_status', 'adopted');
|
||||
}
|
||||
|
||||
public function scopeExternalParents($query)
|
||||
{
|
||||
return $query->where('cattery_status', 'external_parent');
|
||||
}
|
||||
|
||||
public function getCatteryStatusLabelAttribute(): string
|
||||
{
|
||||
return match ($this->cattery_status ?: 'active') {
|
||||
'adopted' => 'Teradopsi',
|
||||
'external_parent' => 'Induk eksternal',
|
||||
default => 'Aktif di cattery',
|
||||
};
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -21,6 +21,8 @@ class Matting extends Model
|
||||
'tgl_lahir_sire' => 'date',
|
||||
'tgl_lahir_dam' => 'date',
|
||||
'jumlah_anak_hidup' => 'integer',
|
||||
'pregnancy_confirmed_at' => 'date',
|
||||
'actual_birth_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -92,7 +94,7 @@ class Matting extends Model
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (!$this->edd || $this->status !== 'proses') return null;
|
||||
if (!$this->edd || !in_array($this->status, ['proses', 'hamil'], true)) return null;
|
||||
|
||||
$days = (int) now()->startOfDay()->diffInDays($this->edd->startOfDay(), false);
|
||||
|
||||
@@ -112,7 +114,8 @@ class Matting extends Model
|
||||
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">Dipantau</span>',
|
||||
'proses' => '<span class="px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-[10px] font-black uppercase border border-blue-100">Menunggu konfirmasi</span>',
|
||||
'hamil' => '<span class="px-3 py-1 bg-amber-50 text-amber-700 rounded-full text-[10px] font-black uppercase border border-amber-100">Hamil / menunggu lahir</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>',
|
||||
@@ -121,6 +124,21 @@ class Matting extends Model
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function scopeOpenBreeding($query)
|
||||
{
|
||||
return $query->whereIn('status', ['proses', 'hamil']);
|
||||
}
|
||||
|
||||
public function getFlowStepAttribute(): int
|
||||
{
|
||||
return match ($this->status) {
|
||||
'hamil' => 2,
|
||||
'berhasil', 'gagal' => 3,
|
||||
default => 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PIC (Point of Contact)
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,8 @@ class User extends Authenticatable
|
||||
'role', // Kode existing Anda
|
||||
'role_id', // Tambahan: ID untuk relasi tabel Role
|
||||
'phone',
|
||||
'is_active',
|
||||
'deactivated_at',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -52,6 +54,8 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
'deactivated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user