first commit
This commit is contained in:
22
app/Http/Controllers/AuditLogController.php
Normal file
22
app/Http/Controllers/AuditLogController.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class AuditLogController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
abort_unless(auth()->user()->can('pengguna.urus') || auth()->user()->hasRole('Super Admin'), 403);
|
||||
|
||||
$logs = Activity::with('causer')
|
||||
->when($request->filled('log'), fn ($q) => $q->where('log_name', $request->log))
|
||||
->latest()->paginate(30)->withQueryString();
|
||||
|
||||
$logNames = Activity::query()->distinct()->pluck('log_name')->filter()->values();
|
||||
|
||||
return view('audit.index', compact('logs', 'logNames'));
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Auth/LoginController.php
Normal file
69
app/Http/Controllers/Auth/LoginController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function showLoginForm()
|
||||
{
|
||||
if (Auth::check()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
], [], [
|
||||
'email' => 'emel',
|
||||
'password' => 'kata laluan',
|
||||
]);
|
||||
|
||||
$remember = $request->boolean('remember');
|
||||
|
||||
if (! Auth::attempt($credentials, $remember)) {
|
||||
activity('auth')->withProperties(['email' => $request->email])->log('Cubaan log masuk gagal');
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => 'Emel atau kata laluan tidak sah.',
|
||||
]);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->is_active) {
|
||||
Auth::logout();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => 'Akaun anda telah dinyahaktifkan. Sila hubungi pentadbir.',
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
activity('auth')->causedBy($user)->log('Pengguna log masuk');
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
activity('auth')->causedBy($user)->log('Pengguna log keluar');
|
||||
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
188
app/Http/Controllers/ChecklistBuilderController.php
Normal file
188
app/Http/Controllers/ChecklistBuilderController.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ChecklistFormula;
|
||||
use App\Models\ChecklistItem;
|
||||
use App\Models\ChecklistScope;
|
||||
use App\Models\ChecklistSection;
|
||||
use App\Models\ChecklistTemplate;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Mengurus elemen bersarang templat senarai semak (seksyen, skop, item, formula).
|
||||
* Semua tindakan memerlukan kebenaran 'templat.urus'.
|
||||
*/
|
||||
class ChecklistBuilderController extends Controller
|
||||
{
|
||||
// Kebenaran 'templat.urus' dikuatkuasakan pada peringkat laluan (route group).
|
||||
|
||||
// ---------------- Seksyen ----------------
|
||||
public function storeSection(Request $request, ChecklistTemplate $template)
|
||||
{
|
||||
$data = $this->validateSection($request);
|
||||
$template->sections()->create($data);
|
||||
|
||||
return back()->with('success', 'Seksyen ditambah.');
|
||||
}
|
||||
|
||||
public function updateSection(Request $request, ChecklistSection $section)
|
||||
{
|
||||
$section->update($this->validateSection($request));
|
||||
|
||||
return back()->with('success', 'Seksyen dikemas kini.');
|
||||
}
|
||||
|
||||
public function destroySection(ChecklistSection $section)
|
||||
{
|
||||
$section->delete();
|
||||
|
||||
return back()->with('success', 'Seksyen dipadam.');
|
||||
}
|
||||
|
||||
protected function validateSection(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'code' => ['required', 'string', 'max:50'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'order' => ['nullable', 'integer', 'min:0'],
|
||||
'is_formula_enabled' => ['nullable', 'boolean'],
|
||||
]);
|
||||
$data['is_formula_enabled'] = $request->boolean('is_formula_enabled');
|
||||
$data['order'] ??= 0;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
// ---------------- Skop ----------------
|
||||
public function storeScope(Request $request, ChecklistSection $section)
|
||||
{
|
||||
$section->scopes()->create($this->validateScope($request));
|
||||
|
||||
return back()->with('success', 'Skop ditambah.');
|
||||
}
|
||||
|
||||
public function updateScope(Request $request, ChecklistScope $scope)
|
||||
{
|
||||
$scope->update($this->validateScope($request));
|
||||
|
||||
return back()->with('success', 'Skop dikemas kini.');
|
||||
}
|
||||
|
||||
public function destroyScope(ChecklistScope $scope)
|
||||
{
|
||||
$scope->delete();
|
||||
|
||||
return back()->with('success', 'Skop dipadam.');
|
||||
}
|
||||
|
||||
protected function validateScope(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'code' => ['required', 'string', 'max:50'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'order' => ['nullable', 'integer', 'min:0'],
|
||||
]);
|
||||
$data['order'] ??= 0;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
// ---------------- Item ----------------
|
||||
public function storeItem(Request $request, ChecklistSection $section)
|
||||
{
|
||||
$data = $this->validateItem($request);
|
||||
$section->items()->create($data);
|
||||
|
||||
return back()->with('success', 'Item ditambah.');
|
||||
}
|
||||
|
||||
public function updateItem(Request $request, ChecklistItem $item)
|
||||
{
|
||||
$item->update($this->validateItem($request));
|
||||
|
||||
return back()->with('success', 'Item dikemas kini.');
|
||||
}
|
||||
|
||||
public function destroyItem(ChecklistItem $item)
|
||||
{
|
||||
$item->delete();
|
||||
|
||||
return back()->with('success', 'Item dipadam.');
|
||||
}
|
||||
|
||||
protected function validateItem(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'checklist_scope_id' => ['nullable', 'exists:checklist_scopes,id'],
|
||||
'code' => ['required', 'string', 'max:50'],
|
||||
'formula_token' => ['nullable', 'string', 'max:50', 'regex:/^[A-Za-z_][A-Za-z0-9_]*$/'],
|
||||
'label' => ['required', 'string', 'max:500'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'input_type' => ['required', 'in:'.implode(',', array_keys(ChecklistItem::INPUT_TYPES))],
|
||||
'options' => ['nullable', 'string'],
|
||||
'unit' => ['nullable', 'string', 'max:50'],
|
||||
'is_required' => ['nullable', 'boolean'],
|
||||
'is_calculated' => ['nullable', 'boolean'],
|
||||
'formula' => ['nullable', 'string', 'max:500'],
|
||||
'order' => ['nullable', 'integer', 'min:0'],
|
||||
], [], ['formula_token' => 'token formula']);
|
||||
|
||||
$data['is_required'] = $request->boolean('is_required');
|
||||
$data['is_calculated'] = $request->boolean('is_calculated');
|
||||
$data['order'] ??= 0;
|
||||
|
||||
// Tukar senarai pilihan (satu baris satu pilihan) kepada array.
|
||||
if (! empty($data['options'])) {
|
||||
$data['options'] = collect(preg_split('/\r\n|\r|\n/', $data['options']))
|
||||
->map(fn ($v) => trim($v))->filter()->values()->all();
|
||||
} else {
|
||||
$data['options'] = null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
// ---------------- Formula ----------------
|
||||
public function storeFormula(Request $request, ChecklistTemplate $template)
|
||||
{
|
||||
$data = $this->validateFormula($request);
|
||||
$template->formulas()->create($data);
|
||||
|
||||
return back()->with('success', 'Formula ditambah.');
|
||||
}
|
||||
|
||||
public function updateFormula(Request $request, ChecklistFormula $formula)
|
||||
{
|
||||
$formula->update($this->validateFormula($request));
|
||||
|
||||
return back()->with('success', 'Formula dikemas kini.');
|
||||
}
|
||||
|
||||
public function destroyFormula(ChecklistFormula $formula)
|
||||
{
|
||||
$formula->delete();
|
||||
|
||||
return back()->with('success', 'Formula dipadam.');
|
||||
}
|
||||
|
||||
protected function validateFormula(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'checklist_section_id' => ['nullable', 'exists:checklist_sections,id'],
|
||||
'result_token' => ['required', 'string', 'max:50', 'regex:/^[A-Za-z_][A-Za-z0-9_]*$/'],
|
||||
'label' => ['nullable', 'string', 'max:255'],
|
||||
'expression' => ['required', 'string', 'max:500'],
|
||||
'unit' => ['nullable', 'string', 'max:50'],
|
||||
'guard_div_zero' => ['nullable', 'boolean'],
|
||||
'order' => ['nullable', 'integer', 'min:0'],
|
||||
], [], ['result_token' => 'token hasil', 'expression' => 'ungkapan']);
|
||||
|
||||
$data['guard_div_zero'] = $request->boolean('guard_div_zero');
|
||||
$data['order'] ??= 0;
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
110
app/Http/Controllers/ChecklistTemplateController.php
Normal file
110
app/Http/Controllers/ChecklistTemplateController.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ChecklistTemplate;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ChecklistTemplateController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->authorize('viewAny', ChecklistTemplate::class);
|
||||
|
||||
$templates = ChecklistTemplate::withCount('sections')->orderByDesc('is_active')->orderBy('name')->get();
|
||||
|
||||
return view('checklist.templates.index', compact('templates'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$this->authorize('create', ChecklistTemplate::class);
|
||||
|
||||
return view('checklist.templates.create', ['template' => new ChecklistTemplate(['version' => '1.0'])]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->authorize('create', ChecklistTemplate::class);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'version' => ['required', 'string', 'max:50'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$template = ChecklistTemplate::create([
|
||||
'name' => $data['name'],
|
||||
'version' => $data['version'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
]);
|
||||
|
||||
if ($template->is_active) {
|
||||
ChecklistTemplate::where('id', '!=', $template->id)->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
return redirect()->route('checklist-templates.builder', $template)
|
||||
->with('success', 'Templat dicipta. Sila bina seksyen dan item.');
|
||||
}
|
||||
|
||||
/** Skrin pembina templat (seksyen, skop, item, formula). */
|
||||
public function builder(ChecklistTemplate $checklistTemplate)
|
||||
{
|
||||
$this->authorize('view', $checklistTemplate);
|
||||
|
||||
$checklistTemplate->load([
|
||||
'sections.scopes',
|
||||
'sections.items.scope',
|
||||
'sections.formula',
|
||||
'formulas',
|
||||
]);
|
||||
|
||||
return view('checklist.templates.builder', ['template' => $checklistTemplate]);
|
||||
}
|
||||
|
||||
public function update(Request $request, ChecklistTemplate $checklistTemplate)
|
||||
{
|
||||
$this->authorize('update', $checklistTemplate);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'version' => ['required', 'string', 'max:50'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$checklistTemplate->update([
|
||||
'name' => $data['name'],
|
||||
'version' => $data['version'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
]);
|
||||
|
||||
if ($checklistTemplate->is_active) {
|
||||
ChecklistTemplate::where('id', '!=', $checklistTemplate->id)->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Templat dikemas kini.');
|
||||
}
|
||||
|
||||
public function activate(ChecklistTemplate $checklistTemplate)
|
||||
{
|
||||
$this->authorize('update', $checklistTemplate);
|
||||
|
||||
ChecklistTemplate::query()->update(['is_active' => false]);
|
||||
$checklistTemplate->update(['is_active' => true]);
|
||||
|
||||
return back()->with('success', 'Templat ditetapkan sebagai aktif.');
|
||||
}
|
||||
|
||||
public function destroy(ChecklistTemplate $checklistTemplate)
|
||||
{
|
||||
$this->authorize('delete', $checklistTemplate);
|
||||
|
||||
$checklistTemplate->delete();
|
||||
|
||||
return redirect()->route('checklist-templates.index')->with('success', 'Templat dipadam.');
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/CommentController.php
Normal file
45
app/Http/Controllers/CommentController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Submission;
|
||||
use App\Models\SubmissionComment;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function store(Request $request, Submission $submission)
|
||||
{
|
||||
$this->authorize('comment', $submission);
|
||||
|
||||
$data = $request->validate([
|
||||
'body' => ['required', 'string', 'max:2000'],
|
||||
'is_internal' => ['nullable', 'boolean'],
|
||||
], [], ['body' => 'mesej']);
|
||||
|
||||
// Hanya kakitangan JPP boleh hantar komen dalaman.
|
||||
$isInternal = $request->boolean('is_internal') && auth()->user()->can('serahan.lihat_semua');
|
||||
|
||||
$submission->comments()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'body' => $data['body'],
|
||||
'is_internal' => $isInternal,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Mesej dihantar.')->withFragment('komen');
|
||||
}
|
||||
|
||||
/** Soft delete — hanya pentadbir (kebenaran komen.padam), dengan rekod audit. */
|
||||
public function destroy(SubmissionComment $comment)
|
||||
{
|
||||
abort_unless(auth()->user()->can('komen.padam'), 403);
|
||||
|
||||
$comment->update(['deleted_by' => auth()->id()]);
|
||||
$comment->delete();
|
||||
|
||||
activity('komen')->performedOn($comment->submission)->causedBy(auth()->user())
|
||||
->withProperties(['comment_id' => $comment->id])->log('Komen dipadam (soft delete)');
|
||||
|
||||
return back()->with('success', 'Mesej dipadam (rekod audit disimpan).');
|
||||
}
|
||||
}
|
||||
132
app/Http/Controllers/ConsultantController.php
Normal file
132
app/Http/Controllers/ConsultantController.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ConsultantRequest;
|
||||
use App\Models\Consultant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class ConsultantController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', Consultant::class);
|
||||
|
||||
$query = Consultant::query()->withCount('dataCentres');
|
||||
|
||||
if ($search = $request->string('cari')->toString()) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('nama_perunding', 'like', "%$search%")
|
||||
->orWhere('no_pendaftaran_syarikat', 'like', "%$search%")
|
||||
->orWhere('emel', 'like', "%$search%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('aktif', $request->status === 'aktif');
|
||||
}
|
||||
|
||||
$consultants = $query->orderBy('nama_perunding')->paginate(15)->withQueryString();
|
||||
|
||||
return view('consultants.index', compact('consultants'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$this->authorize('create', Consultant::class);
|
||||
|
||||
return view('consultants.create', ['consultant' => new Consultant]);
|
||||
}
|
||||
|
||||
public function store(ConsultantRequest $request)
|
||||
{
|
||||
$consultant = DB::transaction(function () use ($request) {
|
||||
$userId = null;
|
||||
|
||||
if ($request->boolean('buat_akaun')) {
|
||||
$user = User::create([
|
||||
'name' => $request->user_name,
|
||||
'email' => $request->user_email,
|
||||
'password' => Hash::make($request->user_password),
|
||||
'is_active' => true,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
$user->assignRole('Perunding');
|
||||
$userId = $user->id;
|
||||
}
|
||||
|
||||
return Consultant::create([
|
||||
'user_id' => $userId,
|
||||
'nama_perunding' => $request->nama_perunding,
|
||||
'no_pendaftaran_syarikat' => $request->no_pendaftaran_syarikat,
|
||||
'alamat' => $request->alamat,
|
||||
'emel' => $request->emel,
|
||||
'telefon' => $request->telefon,
|
||||
'aktif' => $request->boolean('aktif'),
|
||||
]);
|
||||
});
|
||||
|
||||
return redirect()->route('consultants.show', $consultant)
|
||||
->with('success', 'Perunding berjaya didaftarkan.');
|
||||
}
|
||||
|
||||
public function show(Consultant $consultant)
|
||||
{
|
||||
$this->authorize('view', $consultant);
|
||||
|
||||
$consultant->load(['user', 'dataCentres', 'assignments.dataCentre', 'assignments.assignedBy']);
|
||||
|
||||
return view('consultants.show', compact('consultant'));
|
||||
}
|
||||
|
||||
public function edit(Consultant $consultant)
|
||||
{
|
||||
$this->authorize('update', $consultant);
|
||||
|
||||
return view('consultants.edit', compact('consultant'));
|
||||
}
|
||||
|
||||
public function update(ConsultantRequest $request, Consultant $consultant)
|
||||
{
|
||||
$this->authorize('update', $consultant);
|
||||
|
||||
DB::transaction(function () use ($request, $consultant) {
|
||||
$consultant->update([
|
||||
'nama_perunding' => $request->nama_perunding,
|
||||
'no_pendaftaran_syarikat' => $request->no_pendaftaran_syarikat,
|
||||
'alamat' => $request->alamat,
|
||||
'emel' => $request->emel,
|
||||
'telefon' => $request->telefon,
|
||||
'aktif' => $request->boolean('aktif'),
|
||||
]);
|
||||
|
||||
if ($request->boolean('buat_akaun') && ! $consultant->user_id) {
|
||||
$user = User::create([
|
||||
'name' => $request->user_name,
|
||||
'email' => $request->user_email,
|
||||
'password' => Hash::make($request->user_password),
|
||||
'is_active' => true,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
$user->assignRole('Perunding');
|
||||
$consultant->update(['user_id' => $user->id]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('consultants.show', $consultant)
|
||||
->with('success', 'Maklumat perunding dikemas kini.');
|
||||
}
|
||||
|
||||
public function destroy(Consultant $consultant)
|
||||
{
|
||||
$this->authorize('delete', $consultant);
|
||||
|
||||
$consultant->delete();
|
||||
|
||||
return redirect()->route('consultants.index')
|
||||
->with('success', 'Perunding telah dipadam.');
|
||||
}
|
||||
}
|
||||
11
app/Http/Controllers/Controller.php
Normal file
11
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
}
|
||||
71
app/Http/Controllers/CorrectionRequestController.php
Normal file
71
app/Http/Controllers/CorrectionRequestController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Submission;
|
||||
use App\Models\SubmissionCorrectionRequest;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CorrectionRequestController extends Controller
|
||||
{
|
||||
/** Pegawai cipta permohonan pembetulan (wajib: no muka surat, lokasi, penerangan). */
|
||||
public function store(Request $request, Submission $submission)
|
||||
{
|
||||
$this->authorize('review', $submission);
|
||||
|
||||
$data = $request->validate([
|
||||
'checklist_item_id' => ['nullable', 'exists:checklist_items,id'],
|
||||
'page_reference' => ['required', 'string', 'max:50'],
|
||||
'location' => ['required', 'string', 'max:255'],
|
||||
'description' => ['required', 'string', 'max:2000'],
|
||||
], [], [
|
||||
'page_reference' => 'no muka surat',
|
||||
'location' => 'lokasi/seksyen/item',
|
||||
'description' => 'penerangan pembetulan',
|
||||
]);
|
||||
|
||||
$submission->correctionRequests()->create(array_merge($data, [
|
||||
'status' => 'terbuka',
|
||||
'created_by' => auth()->id(),
|
||||
]));
|
||||
|
||||
activity('serahan')->performedOn($submission)->causedBy(auth()->user())
|
||||
->log('Permohonan pembetulan dibuat');
|
||||
|
||||
return back()->with('success', 'Permohonan pembetulan ditambah.');
|
||||
}
|
||||
|
||||
/** Perunding jawab permohonan pembetulan. */
|
||||
public function respond(Request $request, SubmissionCorrectionRequest $correction)
|
||||
{
|
||||
$submission = $correction->submission;
|
||||
$this->authorize('respondCorrection', $submission);
|
||||
|
||||
$data = $request->validate([
|
||||
'consultant_response' => ['required', 'string', 'max:2000'],
|
||||
], [], ['consultant_response' => 'maklum balas']);
|
||||
|
||||
$correction->update([
|
||||
'consultant_response' => $data['consultant_response'],
|
||||
'status' => 'dijawab',
|
||||
'responded_at' => now(),
|
||||
'responded_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Maklum balas pembetulan dihantar.');
|
||||
}
|
||||
|
||||
/** Pegawai tutup permohonan pembetulan. */
|
||||
public function resolve(SubmissionCorrectionRequest $correction)
|
||||
{
|
||||
$this->authorize('review', $correction->submission);
|
||||
|
||||
$correction->update([
|
||||
'status' => 'selesai',
|
||||
'resolved_at' => now(),
|
||||
'resolved_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Permohonan pembetulan ditandakan selesai.');
|
||||
}
|
||||
}
|
||||
118
app/Http/Controllers/DashboardController.php
Normal file
118
app/Http/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Consultant;
|
||||
use App\Models\DataCentre;
|
||||
use App\Models\ReportingCycle;
|
||||
use App\Models\Submission;
|
||||
use App\Models\SubmissionResult;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user->isPerunding()) {
|
||||
return $this->consultantDashboard();
|
||||
}
|
||||
|
||||
return $this->jppDashboard();
|
||||
}
|
||||
|
||||
protected function jppDashboard()
|
||||
{
|
||||
$activeCycle = ReportingCycle::aktif()->orderByDesc('renewal_year')->first();
|
||||
|
||||
$totalDataCentres = DataCentre::aktif()->count();
|
||||
$activeConsultants = Consultant::aktif()->count();
|
||||
|
||||
$submittedCount = 0;
|
||||
$notSubmittedCount = 0;
|
||||
$overdueCount = 0;
|
||||
$statusBreakdown = collect();
|
||||
|
||||
if ($activeCycle) {
|
||||
$expected = DataCentre::aktif()->whereNotNull('current_consultant_id')->count();
|
||||
$submittedCount = Submission::where('reporting_cycle_id', $activeCycle->id)
|
||||
->where('status', '!=', 'draf')->count();
|
||||
$notSubmittedCount = max(0, $expected - $submittedCount);
|
||||
|
||||
$statusBreakdown = Submission::where('reporting_cycle_id', $activeCycle->id)
|
||||
->select('status', DB::raw('count(*) as jumlah'))
|
||||
->groupBy('status')->pluck('jumlah', 'status');
|
||||
|
||||
if ($activeCycle->isOverdue()) {
|
||||
$overdueCount = $notSubmittedCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Jumlah karbon A,B,C,D,E mengikut tahun pelaporan
|
||||
$carbonByYear = SubmissionResult::query()
|
||||
->join('submissions', 'submissions.id', '=', 'submission_results.submission_id')
|
||||
->join('reporting_cycles', 'reporting_cycles.id', '=', 'submissions.reporting_cycle_id')
|
||||
->whereNull('submissions.deleted_at')
|
||||
->select(
|
||||
'reporting_cycles.reporting_year',
|
||||
'submission_results.result_token',
|
||||
DB::raw('SUM(submission_results.value) as jumlah')
|
||||
)
|
||||
->groupBy('reporting_cycles.reporting_year', 'submission_results.result_token')
|
||||
->get()
|
||||
->groupBy('reporting_year');
|
||||
|
||||
// Pusat data tertinggi mengikut anggaran karbon (token A)
|
||||
$topDataCentres = SubmissionResult::query()
|
||||
->where('submission_results.result_token', 'A')
|
||||
->join('submissions', 'submissions.id', '=', 'submission_results.submission_id')
|
||||
->join('data_centres', 'data_centres.id', '=', 'submissions.data_centre_id')
|
||||
->whereNull('submissions.deleted_at')
|
||||
->select('data_centres.nama_pusat_data', DB::raw('SUM(submission_results.value) as jumlah'))
|
||||
->groupBy('data_centres.nama_pusat_data')
|
||||
->orderByDesc('jumlah')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('dashboard.jpp', compact(
|
||||
'activeCycle', 'totalDataCentres', 'activeConsultants',
|
||||
'submittedCount', 'notSubmittedCount', 'overdueCount',
|
||||
'statusBreakdown', 'carbonByYear', 'topDataCentres'
|
||||
));
|
||||
}
|
||||
|
||||
protected function consultantDashboard()
|
||||
{
|
||||
$consultant = auth()->user()->consultant;
|
||||
|
||||
$dataCentres = collect();
|
||||
$submissions = collect();
|
||||
$pendingCount = 0;
|
||||
$correctionCount = 0;
|
||||
$activeCycle = ReportingCycle::aktif()->orderByDesc('renewal_year')->first();
|
||||
|
||||
if ($consultant) {
|
||||
$dataCentres = DataCentre::where('current_consultant_id', $consultant->id)->get();
|
||||
|
||||
$submissions = Submission::where('consultant_id', $consultant->id)
|
||||
->with(['dataCentre', 'reportingCycle'])
|
||||
->latest()->get();
|
||||
|
||||
$correctionCount = $submissions->where('status', 'pembetulan_perunding')->count();
|
||||
|
||||
if ($activeCycle) {
|
||||
$submittedDcIds = Submission::where('consultant_id', $consultant->id)
|
||||
->where('reporting_cycle_id', $activeCycle->id)
|
||||
->where('status', '!=', 'draf')
|
||||
->pluck('data_centre_id');
|
||||
$pendingCount = $dataCentres->whereNotIn('id', $submittedDcIds)->count();
|
||||
}
|
||||
}
|
||||
|
||||
return view('dashboard.consultant', compact(
|
||||
'consultant', 'dataCentres', 'submissions',
|
||||
'pendingCount', 'correctionCount', 'activeCycle'
|
||||
));
|
||||
}
|
||||
}
|
||||
134
app/Http/Controllers/DataCentreController.php
Normal file
134
app/Http/Controllers/DataCentreController.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\DataCentreRequest;
|
||||
use App\Models\Consultant;
|
||||
use App\Models\DataCentre;
|
||||
use App\Services\AssignmentService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DataCentreController extends Controller
|
||||
{
|
||||
public function __construct(protected AssignmentService $assignmentService) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', DataCentre::class);
|
||||
|
||||
$user = auth()->user();
|
||||
$query = DataCentre::query()->with('currentConsultant');
|
||||
|
||||
// Perunding hanya lihat pusat data yang ditugaskan kepadanya.
|
||||
if ($user->isPerunding() && ! $user->can('pusat_data.urus')) {
|
||||
$query->where('current_consultant_id', $user->consultant?->id ?? 0);
|
||||
}
|
||||
|
||||
if ($search = $request->string('cari')->toString()) {
|
||||
$query->where('nama_pusat_data', 'like', "%$search%")
|
||||
->orWhere('tajuk_permohonan', 'like', "%$search%");
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$dataCentres = $query->orderBy('nama_pusat_data')->paginate(15)->withQueryString();
|
||||
|
||||
return view('data_centres.index', compact('dataCentres'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$this->authorize('create', DataCentre::class);
|
||||
|
||||
return view('data_centres.create', ['dataCentre' => new DataCentre]);
|
||||
}
|
||||
|
||||
public function store(DataCentreRequest $request)
|
||||
{
|
||||
$dataCentre = DataCentre::create($request->validated());
|
||||
|
||||
return redirect()->route('data-centres.show', $dataCentre)
|
||||
->with('success', 'Pusat data berjaya didaftarkan.');
|
||||
}
|
||||
|
||||
public function show(DataCentre $dataCentre)
|
||||
{
|
||||
$this->authorize('view', $dataCentre);
|
||||
|
||||
$dataCentre->load([
|
||||
'currentConsultant',
|
||||
'assignments.consultant',
|
||||
'assignments.assignedBy',
|
||||
'submissions.reportingCycle',
|
||||
]);
|
||||
$consultants = Consultant::aktif()->orderBy('nama_perunding')->get();
|
||||
|
||||
return view('data_centres.show', compact('dataCentre', 'consultants'));
|
||||
}
|
||||
|
||||
public function edit(DataCentre $dataCentre)
|
||||
{
|
||||
$this->authorize('update', $dataCentre);
|
||||
|
||||
return view('data_centres.edit', compact('dataCentre'));
|
||||
}
|
||||
|
||||
public function update(DataCentreRequest $request, DataCentre $dataCentre)
|
||||
{
|
||||
$this->authorize('update', $dataCentre);
|
||||
|
||||
$dataCentre->update($request->validated());
|
||||
|
||||
return redirect()->route('data-centres.show', $dataCentre)
|
||||
->with('success', 'Maklumat pusat data dikemas kini.');
|
||||
}
|
||||
|
||||
public function destroy(DataCentre $dataCentre)
|
||||
{
|
||||
$this->authorize('delete', $dataCentre);
|
||||
|
||||
$dataCentre->delete();
|
||||
|
||||
return redirect()->route('data-centres.index')
|
||||
->with('success', 'Pusat data telah dipadam.');
|
||||
}
|
||||
|
||||
/** Tugaskan / tukar perunding aktif untuk pusat data. */
|
||||
public function assign(Request $request, DataCentre $dataCentre)
|
||||
{
|
||||
$this->authorize('assignConsultant', $dataCentre);
|
||||
|
||||
$data = $request->validate([
|
||||
'consultant_id' => ['required', 'exists:consultants,id'],
|
||||
'start_date' => ['nullable', 'date'],
|
||||
'reason' => ['nullable', 'string', 'max:500'],
|
||||
], [], ['consultant_id' => 'perunding', 'reason' => 'sebab']);
|
||||
|
||||
$consultant = Consultant::findOrFail($data['consultant_id']);
|
||||
|
||||
$this->assignmentService->assign(
|
||||
$dataCentre,
|
||||
$consultant,
|
||||
auth()->user(),
|
||||
$data['start_date'] ?? null,
|
||||
$data['reason'] ?? null
|
||||
);
|
||||
|
||||
return redirect()->route('data-centres.show', $dataCentre)
|
||||
->with('success', 'Perunding berjaya ditugaskan. Penugasan terdahulu (jika ada) telah ditamatkan.');
|
||||
}
|
||||
|
||||
public function endAssignment(Request $request, DataCentre $dataCentre)
|
||||
{
|
||||
$this->authorize('assignConsultant', $dataCentre);
|
||||
|
||||
$data = $request->validate(['reason' => ['nullable', 'string', 'max:500']]);
|
||||
|
||||
$this->assignmentService->endActive($dataCentre, auth()->user(), $data['reason'] ?? null);
|
||||
|
||||
return redirect()->route('data-centres.show', $dataCentre)
|
||||
->with('success', 'Penugasan perunding aktif telah ditamatkan.');
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/DocumentController.php
Normal file
47
app/Http/Controllers/DocumentController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AppSetting;
|
||||
use App\Models\Submission;
|
||||
use App\Models\SubmissionDocument;
|
||||
use App\Services\DocumentService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DocumentController extends Controller
|
||||
{
|
||||
public function __construct(protected DocumentService $documents) {}
|
||||
|
||||
/** Muat naik laporan PDF (perunding sahaja, serahan belum dikunci). */
|
||||
public function store(Request $request, Submission $submission)
|
||||
{
|
||||
$this->authorize('update', $submission);
|
||||
|
||||
$maxKb = (int) AppSetting::get('max_upload_size_kb', config('mbip.max_upload_size_kb', 20480));
|
||||
|
||||
$request->validate([
|
||||
'report' => ['required', 'file', 'mimes:pdf', 'mimetypes:application/pdf', "max:$maxKb"],
|
||||
], [], ['report' => 'laporan PDF']);
|
||||
|
||||
$this->documents->store($submission, $request->file('report'), auth()->user());
|
||||
|
||||
return back()->with('success', 'Laporan PDF berjaya dimuat naik (versi baharu dicipta).');
|
||||
}
|
||||
|
||||
/** Muat turun laporan — dilindungi oleh polisi. */
|
||||
public function download(SubmissionDocument $document)
|
||||
{
|
||||
$this->authorize('view', $document->submission);
|
||||
|
||||
return $this->documents->download($document);
|
||||
}
|
||||
|
||||
public function setActive(SubmissionDocument $document)
|
||||
{
|
||||
$this->authorize('update', $document->submission);
|
||||
|
||||
$this->documents->setActiveVersion($document->submission, $document);
|
||||
|
||||
return back()->with('success', 'Versi laporan aktif dikemas kini.');
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/ReminderController.php
Normal file
67
app/Http/Controllers/ReminderController.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DataCentre;
|
||||
use App\Models\ReportingCycle;
|
||||
use App\Models\Submission;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ReminderController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
abort_unless(auth()->user()->can('peringatan.lihat'), 403);
|
||||
|
||||
$cycles = ReportingCycle::orderByDesc('renewal_year')->get();
|
||||
$cycleId = $request->input('cycle') ?: ReportingCycle::aktif()->orderByDesc('renewal_year')->value('id');
|
||||
$cycle = $cycleId ? ReportingCycle::find($cycleId) : null;
|
||||
|
||||
$rows = collect();
|
||||
|
||||
if ($cycle) {
|
||||
// Pusat data aktif yang mempunyai perunding aktif.
|
||||
$query = DataCentre::query()->aktif()
|
||||
->whereNotNull('current_consultant_id')
|
||||
->with('currentConsultant');
|
||||
|
||||
// ID pusat data yang TELAH menghantar (bukan draf) untuk kitaran ini.
|
||||
$submittedDcIds = Submission::where('reporting_cycle_id', $cycle->id)
|
||||
->where('status', '!=', 'draf')
|
||||
->pluck('data_centre_id')->all();
|
||||
|
||||
// Buang yang telah hantar — peraturan: keluar dari senarai peringatan selepas hantar.
|
||||
$query->whereNotIn('id', $submittedDcIds);
|
||||
|
||||
if ($request->filled('consultant')) {
|
||||
$query->where('current_consultant_id', $request->consultant);
|
||||
}
|
||||
if ($request->filled('data_centre')) {
|
||||
$query->where('id', $request->data_centre);
|
||||
}
|
||||
|
||||
$rows = $query->orderBy('nama_pusat_data')->get()->map(function ($dc) use ($cycle) {
|
||||
// Adakah ada draf separa lengkap?
|
||||
$draft = Submission::where('reporting_cycle_id', $cycle->id)
|
||||
->where('data_centre_id', $dc->id)->first();
|
||||
|
||||
return (object) [
|
||||
'data_centre' => $dc,
|
||||
'consultant' => $dc->currentConsultant,
|
||||
'status' => $draft ? $draft->status : 'belum_mula',
|
||||
'is_overdue' => $cycle->isOverdue(),
|
||||
'submission' => $draft,
|
||||
];
|
||||
});
|
||||
|
||||
// Penapis lampau tempoh sahaja.
|
||||
if ($request->boolean('overdue')) {
|
||||
$rows = $rows->filter(fn ($r) => $r->is_overdue)->values();
|
||||
}
|
||||
}
|
||||
|
||||
$consultants = \App\Models\Consultant::aktif()->orderBy('nama_perunding')->get();
|
||||
|
||||
return view('reminders.index', compact('rows', 'cycles', 'cycle', 'consultants'));
|
||||
}
|
||||
}
|
||||
59
app/Http/Controllers/ReportController.php
Normal file
59
app/Http/Controllers/ReportController.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ReportingCycle;
|
||||
use App\Models\Submission;
|
||||
use App\Models\SubmissionResult;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
abort_unless(auth()->user()->can('laporan.lihat'), 403);
|
||||
|
||||
$cycles = ReportingCycle::orderByDesc('renewal_year')->get();
|
||||
$cycleId = $request->input('cycle');
|
||||
|
||||
$resultsQuery = SubmissionResult::query()
|
||||
->join('submissions', 'submissions.id', '=', 'submission_results.submission_id')
|
||||
->join('data_centres', 'data_centres.id', '=', 'submissions.data_centre_id')
|
||||
->join('reporting_cycles', 'reporting_cycles.id', '=', 'submissions.reporting_cycle_id')
|
||||
->whereNull('submissions.deleted_at');
|
||||
|
||||
if ($cycleId) {
|
||||
$resultsQuery->where('submissions.reporting_cycle_id', $cycleId);
|
||||
}
|
||||
|
||||
// Jadual hasil karbon mengikut pusat data + kitaran.
|
||||
$rows = (clone $resultsQuery)
|
||||
->select(
|
||||
'data_centres.nama_pusat_data',
|
||||
'reporting_cycles.reporting_year',
|
||||
'submission_results.result_token',
|
||||
'submission_results.value'
|
||||
)
|
||||
->orderBy('reporting_cycles.reporting_year')
|
||||
->orderBy('data_centres.nama_pusat_data')
|
||||
->get()
|
||||
->groupBy(fn ($r) => $r->nama_pusat_data.'|'.$r->reporting_year);
|
||||
|
||||
// Trend jumlah karbon (A) mengikut tahun pelaporan.
|
||||
$trend = (clone $resultsQuery)
|
||||
->where('submission_results.result_token', 'A')
|
||||
->select('reporting_cycles.reporting_year', DB::raw('SUM(submission_results.value) as jumlah'))
|
||||
->groupBy('reporting_cycles.reporting_year')
|
||||
->orderBy('reporting_cycles.reporting_year')
|
||||
->pluck('jumlah', 'reporting_year');
|
||||
|
||||
// Pecahan status serahan.
|
||||
$statusBreakdown = Submission::query()
|
||||
->when($cycleId, fn ($q) => $q->where('reporting_cycle_id', $cycleId))
|
||||
->select('status', DB::raw('count(*) as jumlah'))
|
||||
->groupBy('status')->pluck('jumlah', 'status');
|
||||
|
||||
return view('reports.index', compact('cycles', 'cycleId', 'rows', 'trend', 'statusBreakdown'));
|
||||
}
|
||||
}
|
||||
88
app/Http/Controllers/ReportingCycleController.php
Normal file
88
app/Http/Controllers/ReportingCycleController.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ReportingCycleRequest;
|
||||
use App\Models\ChecklistTemplate;
|
||||
use App\Models\ReportingCycle;
|
||||
use App\Services\ReportingCycleService;
|
||||
|
||||
class ReportingCycleController extends Controller
|
||||
{
|
||||
public function __construct(protected ReportingCycleService $service) {}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$this->authorize('viewAny', ReportingCycle::class);
|
||||
|
||||
$cycles = ReportingCycle::withCount('submissions')
|
||||
->orderByDesc('renewal_year')->paginate(15);
|
||||
|
||||
return view('reporting_cycles.index', compact('cycles'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$this->authorize('create', ReportingCycle::class);
|
||||
|
||||
$templates = ChecklistTemplate::orderByDesc('is_active')->get();
|
||||
|
||||
return view('reporting_cycles.create', [
|
||||
'cycle' => new ReportingCycle(['licence_period_year' => 1, 'status' => 'aktif']),
|
||||
'templates' => $templates,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(ReportingCycleRequest $request)
|
||||
{
|
||||
$attrs = $this->service->buildAttributes(
|
||||
(int) $request->renewal_year,
|
||||
$request->cut_off_date
|
||||
);
|
||||
|
||||
$cycle = ReportingCycle::create(array_merge($request->validated(), $attrs));
|
||||
|
||||
return redirect()->route('reporting-cycles.index')
|
||||
->with('success', "Kitaran pembaharuan {$cycle->renewal_year} (pelaporan {$cycle->reporting_year}) dicipta.");
|
||||
}
|
||||
|
||||
public function edit(ReportingCycle $reportingCycle)
|
||||
{
|
||||
$this->authorize('update', $reportingCycle);
|
||||
|
||||
$templates = ChecklistTemplate::orderByDesc('is_active')->get();
|
||||
|
||||
return view('reporting_cycles.edit', ['cycle' => $reportingCycle, 'templates' => $templates]);
|
||||
}
|
||||
|
||||
public function update(ReportingCycleRequest $request, ReportingCycle $reportingCycle)
|
||||
{
|
||||
$this->authorize('update', $reportingCycle);
|
||||
|
||||
// Kira semula tahun pelaporan & tempoh jika tahun pembaharuan berubah.
|
||||
$attrs = $this->service->buildAttributes(
|
||||
(int) $request->renewal_year,
|
||||
$request->cut_off_date
|
||||
);
|
||||
// Kekalkan templat pilihan pengguna jika diberi.
|
||||
$attrs['checklist_template_id'] = $request->checklist_template_id ?? $attrs['checklist_template_id'];
|
||||
|
||||
$reportingCycle->update(array_merge($request->validated(), $attrs));
|
||||
|
||||
return redirect()->route('reporting-cycles.index')
|
||||
->with('success', 'Kitaran pelaporan dikemas kini.');
|
||||
}
|
||||
|
||||
public function destroy(ReportingCycle $reportingCycle)
|
||||
{
|
||||
$this->authorize('delete', $reportingCycle);
|
||||
|
||||
if ($reportingCycle->submissions()->exists()) {
|
||||
return back()->with('error', 'Kitaran tidak boleh dipadam kerana telah mempunyai serahan.');
|
||||
}
|
||||
|
||||
$reportingCycle->delete();
|
||||
|
||||
return redirect()->route('reporting-cycles.index')->with('success', 'Kitaran dipadam.');
|
||||
}
|
||||
}
|
||||
42
app/Http/Controllers/SettingController.php
Normal file
42
app/Http/Controllers/SettingController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AppSetting;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SettingController extends Controller
|
||||
{
|
||||
public function edit()
|
||||
{
|
||||
abort_unless(auth()->user()->can('tetapan.urus'), 403);
|
||||
|
||||
$settings = AppSetting::orderBy('group')->orderBy('key')->get()->groupBy('group');
|
||||
|
||||
return view('settings.edit', compact('settings'));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
abort_unless(auth()->user()->can('tetapan.urus'), 403);
|
||||
|
||||
$values = $request->input('settings', []);
|
||||
|
||||
// Proses setiap tetapan. Kotak semak (boolean) yang tidak ditanda tidak
|
||||
// dihantar oleh borang, jadi tetapan boolean dikendalikan secara eksplisit.
|
||||
foreach (AppSetting::all() as $setting) {
|
||||
if ($setting->type === 'boolean') {
|
||||
$value = $request->boolean("settings.{$setting->key}") ? '1' : '0';
|
||||
} elseif (array_key_exists($setting->key, $values)) {
|
||||
$value = $values[$setting->key];
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
AppSetting::set($setting->key, $value);
|
||||
}
|
||||
|
||||
activity('tetapan')->causedBy(auth()->user())->log('Tetapan aplikasi dikemas kini');
|
||||
|
||||
return back()->with('success', 'Tetapan aplikasi disimpan.');
|
||||
}
|
||||
}
|
||||
143
app/Http/Controllers/SubmissionController.php
Normal file
143
app/Http/Controllers/SubmissionController.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DataCentre;
|
||||
use App\Models\ReportingCycle;
|
||||
use App\Models\Submission;
|
||||
use App\Services\SubmissionWorkflowService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SubmissionController extends Controller
|
||||
{
|
||||
public function __construct(protected SubmissionWorkflowService $workflow) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', Submission::class);
|
||||
|
||||
$user = auth()->user();
|
||||
$query = Submission::query()->with(['dataCentre', 'reportingCycle', 'consultant', 'assignedOfficer']);
|
||||
|
||||
// Perunding: hanya serahan sendiri.
|
||||
if (! $user->can('serahan.lihat_semua')) {
|
||||
$query->where('consultant_id', $user->consultant?->id ?? 0);
|
||||
}
|
||||
|
||||
if ($request->filled('cycle')) {
|
||||
$query->where('reporting_cycle_id', $request->cycle);
|
||||
}
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
if ($request->filled('consultant') && $user->can('serahan.lihat_semua')) {
|
||||
$query->where('consultant_id', $request->consultant);
|
||||
}
|
||||
|
||||
$submissions = $query->latest()->paginate(15)->withQueryString();
|
||||
$cycles = ReportingCycle::orderByDesc('renewal_year')->get();
|
||||
|
||||
return view('submissions.index', compact('submissions', 'cycles'));
|
||||
}
|
||||
|
||||
/** Borang pilih pusat data + kitaran untuk memulakan serahan. */
|
||||
public function create(Request $request)
|
||||
{
|
||||
$this->authorize('create', Submission::class);
|
||||
|
||||
$consultant = auth()->user()->consultant;
|
||||
abort_unless($consultant, 403, 'Akaun anda tiada profil perunding.');
|
||||
|
||||
$dataCentres = DataCentre::where('current_consultant_id', $consultant->id)->aktif()->get();
|
||||
$cycles = ReportingCycle::aktif()->orderByDesc('renewal_year')->get();
|
||||
|
||||
return view('submissions.create', compact('dataCentres', 'cycles'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->authorize('create', Submission::class);
|
||||
|
||||
$consultant = auth()->user()->consultant;
|
||||
abort_unless($consultant, 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'data_centre_id' => ['required', 'exists:data_centres,id'],
|
||||
'reporting_cycle_id' => ['required', 'exists:reporting_cycles,id'],
|
||||
], [], ['data_centre_id' => 'pusat data', 'reporting_cycle_id' => 'kitaran pelaporan']);
|
||||
|
||||
$dataCentre = DataCentre::findOrFail($data['data_centre_id']);
|
||||
|
||||
// Pastikan pusat data ini milik perunding.
|
||||
abort_unless((int) $dataCentre->current_consultant_id === (int) $consultant->id, 403,
|
||||
'Anda tidak ditugaskan untuk pusat data ini.');
|
||||
|
||||
$cycle = ReportingCycle::findOrFail($data['reporting_cycle_id']);
|
||||
$submission = $this->workflow->findOrCreateDraft($dataCentre, $cycle);
|
||||
|
||||
return redirect()->route('submissions.edit', $submission)
|
||||
->with('success', 'Serahan draf disediakan. Sila lengkapkan senarai semak.');
|
||||
}
|
||||
|
||||
/** Borang pengisian senarai semak (perunding). */
|
||||
public function edit(Submission $submission)
|
||||
{
|
||||
$this->authorize('update', $submission);
|
||||
|
||||
$submission->load([
|
||||
'dataCentre', 'reportingCycle', 'checklistTemplate',
|
||||
'answers', 'documents', 'correctionRequests',
|
||||
]);
|
||||
|
||||
$template = $submission->checklistTemplate;
|
||||
$template?->load(['sections.scopes', 'sections.items.scope', 'formulas']);
|
||||
|
||||
$answers = $submission->answers->keyBy('checklist_item_id');
|
||||
|
||||
return view('submissions.fill', compact('submission', 'template', 'answers'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Submission $submission)
|
||||
{
|
||||
$this->authorize('update', $submission);
|
||||
|
||||
$answers = $request->input('answers', []);
|
||||
$this->workflow->saveAnswers($submission, $answers);
|
||||
|
||||
return back()->with('success', 'Draf senarai semak disimpan.');
|
||||
}
|
||||
|
||||
public function submit(Request $request, Submission $submission)
|
||||
{
|
||||
$this->authorize('update', $submission);
|
||||
|
||||
// Simpan dahulu jawapan terkini jika dihantar bersama.
|
||||
if ($request->has('answers')) {
|
||||
$this->workflow->saveAnswers($submission, $request->input('answers', []));
|
||||
}
|
||||
|
||||
$this->workflow->submit($submission, auth()->user());
|
||||
|
||||
return redirect()->route('submissions.show', $submission)
|
||||
->with('success', 'Serahan berjaya dihantar kepada JPP.');
|
||||
}
|
||||
|
||||
public function show(Submission $submission)
|
||||
{
|
||||
$this->authorize('view', $submission);
|
||||
|
||||
$submission->load([
|
||||
'dataCentre', 'reportingCycle', 'consultant', 'assignedOfficer',
|
||||
'checklistTemplate.sections.scopes', 'checklistTemplate.sections.items.scope',
|
||||
'answers', 'results', 'documents.uploadedBy',
|
||||
'statusHistories.changedBy', 'correctionRequests.item', 'correctionRequests.createdBy',
|
||||
'comments.user',
|
||||
]);
|
||||
|
||||
$template = $submission->checklistTemplate;
|
||||
$answers = $submission->answers->keyBy('checklist_item_id');
|
||||
$officers = \App\Models\User::role(['Pegawai JPP', 'Penolong Pegawai', 'Kerani', 'JPP Admin'])->get();
|
||||
|
||||
return view('submissions.show', compact('submission', 'template', 'answers', 'officers'));
|
||||
}
|
||||
}
|
||||
122
app/Http/Controllers/SubmissionReviewController.php
Normal file
122
app/Http/Controllers/SubmissionReviewController.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Submission;
|
||||
use App\Models\SubmissionAnswer;
|
||||
use App\Services\SubmissionWorkflowService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SubmissionReviewController extends Controller
|
||||
{
|
||||
public function __construct(protected SubmissionWorkflowService $workflow) {}
|
||||
|
||||
/** Tukar status serahan. Status 'selesai' & 'pembetulan_perunding' ada syarat tambahan. */
|
||||
public function updateStatus(Request $request, Submission $submission)
|
||||
{
|
||||
$this->authorize('review', $submission);
|
||||
|
||||
$data = $request->validate([
|
||||
'status' => ['required', Rule::in(array_keys(Submission::STATUSES))],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
// Wajib jika status = selesai: tarikh hardcopy dihantar ke Bahagian Kawalan Perancangan.
|
||||
'hardcopy_sent_date' => ['nullable', 'required_if:status,selesai', 'date'],
|
||||
], [], ['hardcopy_sent_date' => 'tarikh hardcopy dihantar ke Bahagian Kawalan Perancangan']);
|
||||
|
||||
// Status 'pembetulan_perunding' mesti melalui panel permohonan pembetulan.
|
||||
if ($data['status'] === 'pembetulan_perunding' && ! $submission->correctionRequests()->where('status', 'terbuka')->exists()) {
|
||||
return back()->with('error', 'Sila tambah sekurang-kurangnya satu permohonan pembetulan (no muka surat, lokasi & penerangan) sebelum menetapkan status Pembetulan Perunding.');
|
||||
}
|
||||
|
||||
$extra = [];
|
||||
if ($data['status'] === 'selesai') {
|
||||
$extra['hardcopy_sent_date'] = $data['hardcopy_sent_date'];
|
||||
}
|
||||
|
||||
$this->workflow->changeStatus($submission, $data['status'], auth()->user(), $data['notes'] ?? null, $extra);
|
||||
|
||||
return back()->with('success', 'Status serahan dikemas kini.');
|
||||
}
|
||||
|
||||
public function assignOfficer(Request $request, Submission $submission)
|
||||
{
|
||||
$this->authorize('review', $submission);
|
||||
|
||||
$data = $request->validate(['assigned_officer_id' => ['nullable', 'exists:users,id']]);
|
||||
$submission->update(['assigned_officer_id' => $data['assigned_officer_id'] ?? null]);
|
||||
|
||||
activity('serahan')->performedOn($submission)->causedBy(auth()->user())
|
||||
->log('Pegawai bertanggungjawab dikemas kini');
|
||||
|
||||
return back()->with('success', 'Pegawai bertanggungjawab dikemas kini.');
|
||||
}
|
||||
|
||||
public function lock(Request $request, Submission $submission)
|
||||
{
|
||||
$this->authorize('lock', $submission);
|
||||
|
||||
$data = $request->validate(['lock_reason' => ['nullable', 'string', 'max:500']]);
|
||||
$this->workflow->lock($submission, auth()->user(), $data['lock_reason'] ?? null);
|
||||
|
||||
return back()->with('success', 'Serahan telah dikunci. Perunding tidak boleh mengubah sehingga dibuka semula.');
|
||||
}
|
||||
|
||||
public function unlock(Request $request, Submission $submission)
|
||||
{
|
||||
$this->authorize('lock', $submission);
|
||||
|
||||
$data = $request->validate([
|
||||
'lock_reason' => ['required', 'string', 'max:500'],
|
||||
], [], ['lock_reason' => 'sebab buka kunci']);
|
||||
|
||||
$this->workflow->unlock($submission, auth()->user(), $data['lock_reason']);
|
||||
|
||||
return back()->with('success', 'Serahan dibuka semula untuk pembetulan oleh perunding.');
|
||||
}
|
||||
|
||||
public function markHardcopy(Request $request, Submission $submission)
|
||||
{
|
||||
$this->authorize('markHardcopy', $submission);
|
||||
|
||||
$data = $request->validate([
|
||||
'hardcopy_received' => ['required', 'boolean'],
|
||||
'hardcopy_received_date' => ['nullable', 'required_if:hardcopy_received,1', 'date'],
|
||||
], [], ['hardcopy_received_date' => 'tarikh terima salinan keras']);
|
||||
|
||||
$submission->update([
|
||||
'hardcopy_received' => $request->boolean('hardcopy_received'),
|
||||
'hardcopy_received_date' => $request->boolean('hardcopy_received') ? $data['hardcopy_received_date'] : null,
|
||||
]);
|
||||
|
||||
activity('serahan')->performedOn($submission)->causedBy(auth()->user())
|
||||
->log('Status penerimaan salinan keras dikemas kini');
|
||||
|
||||
return back()->with('success', 'Status salinan keras dikemas kini.');
|
||||
}
|
||||
|
||||
public function reviewNote(Request $request, Submission $submission)
|
||||
{
|
||||
$this->authorize('review', $submission);
|
||||
|
||||
$data = $request->validate(['review_notes' => ['nullable', 'string', 'max:2000']]);
|
||||
$submission->update(['review_notes' => $data['review_notes'] ?? null]);
|
||||
|
||||
return back()->with('success', 'Nota semakan disimpan.');
|
||||
}
|
||||
|
||||
/** Pegawai mengesahkan / catat nota bagi satu jawapan item. */
|
||||
public function reviewAnswer(Request $request, SubmissionAnswer $answer)
|
||||
{
|
||||
$this->authorize('review', $answer->submission);
|
||||
|
||||
$data = $request->validate([
|
||||
'validation_status' => ['required', 'in:belum_semak,sah,tidak_sah'],
|
||||
'officer_note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$answer->update($data);
|
||||
|
||||
return back()->with('success', 'Semakan item dikemas kini.');
|
||||
}
|
||||
}
|
||||
108
app/Http/Controllers/UserController.php
Normal file
108
app/Http/Controllers/UserController.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
abort_unless(auth()->user()->can('pengguna.urus'), 403);
|
||||
|
||||
$users = User::with('roles')
|
||||
->when($request->filled('cari'), fn ($q) => $q->where('name', 'like', '%'.$request->cari.'%')->orWhere('email', 'like', '%'.$request->cari.'%'))
|
||||
->orderBy('name')->paginate(15)->withQueryString();
|
||||
|
||||
return view('users.index', compact('users'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
abort_unless(auth()->user()->can('pengguna.urus'), 403);
|
||||
|
||||
return view('users.create', ['user' => new User, 'roles' => Role::orderBy('name')->get()]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
abort_unless(auth()->user()->can('pengguna.urus'), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'jawatan' => ['nullable', 'string', 'max:255'],
|
||||
'password' => ['required', 'confirmed', Password::min(8)],
|
||||
'role' => ['required', 'exists:roles,name'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'jawatan' => $data['jawatan'] ?? null,
|
||||
'password' => Hash::make($data['password']),
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
$user->syncRoles([$data['role']]);
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'Pengguna dicipta.');
|
||||
}
|
||||
|
||||
public function edit(User $user)
|
||||
{
|
||||
abort_unless(auth()->user()->can('pengguna.urus'), 403);
|
||||
|
||||
return view('users.edit', ['user' => $user, 'roles' => Role::orderBy('name')->get()]);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user)
|
||||
{
|
||||
abort_unless(auth()->user()->can('pengguna.urus'), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'jawatan' => ['nullable', 'string', 'max:255'],
|
||||
'password' => ['nullable', 'confirmed', Password::min(8)],
|
||||
'role' => ['required', 'exists:roles,name'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$user->update([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'jawatan' => $data['jawatan'] ?? null,
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
]);
|
||||
|
||||
if (! empty($data['password'])) {
|
||||
$user->update(['password' => Hash::make($data['password'])]);
|
||||
}
|
||||
|
||||
$user->syncRoles([$data['role']]);
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'Pengguna dikemas kini.');
|
||||
}
|
||||
|
||||
public function destroy(User $user)
|
||||
{
|
||||
abort_unless(auth()->user()->can('pengguna.urus'), 403);
|
||||
|
||||
abort_if($user->id === auth()->id(), 403, 'Anda tidak boleh memadam akaun sendiri.');
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'Pengguna dipadam.');
|
||||
}
|
||||
}
|
||||
57
app/Http/Requests/ConsultantRequest.php
Normal file
57
app/Http/Requests/ConsultantRequest.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ConsultantRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('perunding.urus');
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$consultantId = $this->route('consultant')?->id;
|
||||
|
||||
return [
|
||||
'nama_perunding' => ['required', 'string', 'max:255'],
|
||||
'no_pendaftaran_syarikat' => ['nullable', 'string', 'max:100'],
|
||||
'alamat' => ['nullable', 'string', 'max:1000'],
|
||||
'emel' => ['nullable', 'email', 'max:255'],
|
||||
'telefon' => ['nullable', 'string', 'max:50'],
|
||||
'aktif' => ['required', 'boolean'],
|
||||
// Maklumat akaun pengguna perunding
|
||||
'buat_akaun' => ['nullable', 'boolean'],
|
||||
'user_name' => ['nullable', 'required_if:buat_akaun,1', 'string', 'max:255'],
|
||||
'user_email' => [
|
||||
'nullable', 'required_if:buat_akaun,1', 'email', 'max:255',
|
||||
Rule::unique('users', 'email')->ignore($this->route('consultant')?->user_id),
|
||||
],
|
||||
'user_password' => ['nullable', 'required_if:buat_akaun,1', 'string', 'min:8'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'nama_perunding' => 'nama perunding',
|
||||
'no_pendaftaran_syarikat' => 'no pendaftaran syarikat',
|
||||
'emel' => 'emel',
|
||||
'telefon' => 'telefon',
|
||||
'user_name' => 'nama pengguna',
|
||||
'user_email' => 'emel akaun',
|
||||
'user_password' => 'kata laluan akaun',
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'aktif' => $this->boolean('aktif'),
|
||||
'buat_akaun' => $this->boolean('buat_akaun'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
37
app/Http/Requests/DataCentreRequest.php
Normal file
37
app/Http/Requests/DataCentreRequest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class DataCentreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('pusat_data.urus');
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nama_pusat_data' => ['required', 'string', 'max:255'],
|
||||
'tajuk_permohonan' => ['nullable', 'string', 'max:255'],
|
||||
'lokasi_pusat_data' => ['nullable', 'string', 'max:255'],
|
||||
'alamat' => ['nullable', 'string', 'max:1000'],
|
||||
'keluasan_tapak' => ['nullable', 'numeric', 'min:0'],
|
||||
'it_load' => ['nullable', 'numeric', 'min:0'],
|
||||
'status' => ['required', 'in:aktif,tidak_aktif'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'nama_pusat_data' => 'nama pusat data',
|
||||
'tajuk_permohonan' => 'tajuk permohonan',
|
||||
'lokasi_pusat_data' => 'lokasi pusat data',
|
||||
'keluasan_tapak' => 'keluasan tapak',
|
||||
'it_load' => 'kapasiti IT Load',
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Http/Requests/ReportingCycleRequest.php
Normal file
40
app/Http/Requests/ReportingCycleRequest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ReportingCycleRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('kitaran.urus');
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$cycleId = $this->route('reporting_cycle')?->id;
|
||||
|
||||
return [
|
||||
'renewal_year' => [
|
||||
'required', 'integer', 'min:2000', 'max:2100',
|
||||
Rule::unique('reporting_cycles', 'renewal_year')->ignore($cycleId),
|
||||
],
|
||||
'cut_off_date' => ['nullable', 'date'],
|
||||
'licence_period_year' => ['required', 'integer', 'min:1', 'max:10'],
|
||||
'checklist_template_id' => ['nullable', 'exists:checklist_templates,id'],
|
||||
'status' => ['required', 'in:aktif,tutup'],
|
||||
'catatan' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'renewal_year' => 'tahun pembaharuan',
|
||||
'cut_off_date' => 'tarikh tutup',
|
||||
'licence_period_year' => 'tempoh lesen (tahun)',
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Models/AppSetting.php
Normal file
44
app/Models/AppSetting.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AppSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'value',
|
||||
'type',
|
||||
'group',
|
||||
'label',
|
||||
'description',
|
||||
];
|
||||
|
||||
public static function get(string $key, $default = null)
|
||||
{
|
||||
$setting = Cache::remember("setting.$key", 3600, fn () => static::where('key', $key)->first());
|
||||
|
||||
if (! $setting) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return match ($setting->type) {
|
||||
'integer' => (int) $setting->value,
|
||||
'boolean' => filter_var($setting->value, FILTER_VALIDATE_BOOLEAN),
|
||||
default => $setting->value,
|
||||
};
|
||||
}
|
||||
|
||||
public static function set(string $key, $value): void
|
||||
{
|
||||
static::where('key', $key)->update(['value' => $value]);
|
||||
Cache::forget("setting.$key");
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(fn ($m) => Cache::forget("setting.{$m->key}"));
|
||||
}
|
||||
}
|
||||
41
app/Models/ChecklistFormula.php
Normal file
41
app/Models/ChecklistFormula.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ChecklistFormula extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'checklist_template_id',
|
||||
'checklist_section_id',
|
||||
'result_token',
|
||||
'label',
|
||||
'expression',
|
||||
'unit',
|
||||
'guard_div_zero',
|
||||
'order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'guard_div_zero' => 'boolean',
|
||||
'order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChecklistTemplate::class, 'checklist_template_id');
|
||||
}
|
||||
|
||||
public function section(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChecklistSection::class, 'checklist_section_id');
|
||||
}
|
||||
}
|
||||
69
app/Models/ChecklistItem.php
Normal file
69
app/Models/ChecklistItem.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ChecklistItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const INPUT_TYPES = [
|
||||
'number' => 'Nombor',
|
||||
'text' => 'Teks',
|
||||
'textarea' => 'Teks Panjang',
|
||||
'select' => 'Senarai Pilihan',
|
||||
'checkbox' => 'Kotak Semak',
|
||||
'file_reference' => 'Rujukan Fail',
|
||||
'calculated' => 'Dikira Automatik',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'checklist_section_id',
|
||||
'checklist_scope_id',
|
||||
'code',
|
||||
'formula_token',
|
||||
'label',
|
||||
'description',
|
||||
'input_type',
|
||||
'options',
|
||||
'unit',
|
||||
'is_required',
|
||||
'is_calculated',
|
||||
'formula',
|
||||
'order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'options' => 'array',
|
||||
'is_required' => 'boolean',
|
||||
'is_calculated' => 'boolean',
|
||||
'order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function section(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChecklistSection::class, 'checklist_section_id');
|
||||
}
|
||||
|
||||
public function scope(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChecklistScope::class, 'checklist_scope_id');
|
||||
}
|
||||
|
||||
public function answers(): HasMany
|
||||
{
|
||||
return $this->hasMany(SubmissionAnswer::class);
|
||||
}
|
||||
|
||||
public function isNumeric(): bool
|
||||
{
|
||||
return in_array($this->input_type, ['number', 'calculated'], true);
|
||||
}
|
||||
}
|
||||
38
app/Models/ChecklistScope.php
Normal file
38
app/Models/ChecklistScope.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ChecklistScope extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'checklist_section_id',
|
||||
'code',
|
||||
'title',
|
||||
'description',
|
||||
'order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function section(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChecklistSection::class, 'checklist_section_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChecklistItem::class)->orderBy('order');
|
||||
}
|
||||
}
|
||||
50
app/Models/ChecklistSection.php
Normal file
50
app/Models/ChecklistSection.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ChecklistSection extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'checklist_template_id',
|
||||
'code',
|
||||
'title',
|
||||
'description',
|
||||
'order',
|
||||
'is_formula_enabled',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_formula_enabled' => 'boolean',
|
||||
'order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChecklistTemplate::class, 'checklist_template_id');
|
||||
}
|
||||
|
||||
public function scopes(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChecklistScope::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChecklistItem::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function formula(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChecklistFormula::class);
|
||||
}
|
||||
}
|
||||
47
app/Models/ChecklistTemplate.php
Normal file
47
app/Models/ChecklistTemplate.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ChecklistTemplate extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'version',
|
||||
'description',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function sections(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChecklistSection::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function formulas(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChecklistFormula::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public static function activeTemplate(): ?self
|
||||
{
|
||||
return static::active()->latest('id')->first();
|
||||
}
|
||||
}
|
||||
68
app/Models/Consultant.php
Normal file
68
app/Models/Consultant.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
|
||||
class Consultant extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'nama_perunding',
|
||||
'no_pendaftaran_syarikat',
|
||||
'alamat',
|
||||
'emel',
|
||||
'telefon',
|
||||
'aktif',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'aktif' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['nama_perunding', 'no_pendaftaran_syarikat', 'emel', 'telefon', 'aktif', 'user_id'])
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('perunding');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/** Pusat data yang sedang ditugaskan kepada perunding ini. */
|
||||
public function dataCentres(): HasMany
|
||||
{
|
||||
return $this->hasMany(DataCentre::class, 'current_consultant_id');
|
||||
}
|
||||
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(DataCentreConsultantAssignment::class);
|
||||
}
|
||||
|
||||
public function submissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Submission::class);
|
||||
}
|
||||
|
||||
public function scopeAktif($query)
|
||||
{
|
||||
return $query->where('aktif', true);
|
||||
}
|
||||
}
|
||||
69
app/Models/DataCentre.php
Normal file
69
app/Models/DataCentre.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
|
||||
class DataCentre extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'nama_pusat_data',
|
||||
'tajuk_permohonan',
|
||||
'lokasi_pusat_data',
|
||||
'alamat',
|
||||
'keluasan_tapak',
|
||||
'it_load',
|
||||
'status',
|
||||
'current_consultant_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'keluasan_tapak' => 'decimal:2',
|
||||
'it_load' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['nama_pusat_data', 'tajuk_permohonan', 'status', 'current_consultant_id', 'it_load', 'keluasan_tapak'])
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('pusat_data');
|
||||
}
|
||||
|
||||
public function currentConsultant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Consultant::class, 'current_consultant_id');
|
||||
}
|
||||
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(DataCentreConsultantAssignment::class)->latest('start_date');
|
||||
}
|
||||
|
||||
public function activeAssignment()
|
||||
{
|
||||
return $this->hasOne(DataCentreConsultantAssignment::class)->where('is_active', true);
|
||||
}
|
||||
|
||||
public function submissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Submission::class);
|
||||
}
|
||||
|
||||
public function scopeAktif($query)
|
||||
{
|
||||
return $query->where('status', 'aktif');
|
||||
}
|
||||
}
|
||||
46
app/Models/DataCentreConsultantAssignment.php
Normal file
46
app/Models/DataCentreConsultantAssignment.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DataCentreConsultantAssignment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'data_centre_id',
|
||||
'consultant_id',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'reason',
|
||||
'assigned_by',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function dataCentre(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DataCentre::class);
|
||||
}
|
||||
|
||||
public function consultant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Consultant::class);
|
||||
}
|
||||
|
||||
public function assignedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_by');
|
||||
}
|
||||
}
|
||||
77
app/Models/ReportingCycle.php
Normal file
77
app/Models/ReportingCycle.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
|
||||
class ReportingCycle extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'renewal_year',
|
||||
'reporting_year',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'cut_off_date',
|
||||
'licence_period_year',
|
||||
'checklist_template_id',
|
||||
'status',
|
||||
'catatan',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'renewal_year' => 'integer',
|
||||
'reporting_year' => 'integer',
|
||||
'period_start' => 'date',
|
||||
'period_end' => 'date',
|
||||
'cut_off_date' => 'date',
|
||||
'licence_period_year' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Peraturan perniagaan: tahun pelaporan = tahun pembaharuan - 2.
|
||||
* Cth: pembaharuan 2028 => pelaporan 2026.
|
||||
*/
|
||||
public static function reportingYearFor(int $renewalYear): int
|
||||
{
|
||||
return $renewalYear - 2;
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['renewal_year', 'reporting_year', 'cut_off_date', 'status'])
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('kitaran_pelaporan');
|
||||
}
|
||||
|
||||
public function checklistTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChecklistTemplate::class);
|
||||
}
|
||||
|
||||
public function submissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Submission::class);
|
||||
}
|
||||
|
||||
public function scopeAktif($query)
|
||||
{
|
||||
return $query->where('status', 'aktif');
|
||||
}
|
||||
|
||||
public function isOverdue(): bool
|
||||
{
|
||||
return $this->cut_off_date && now()->startOfDay()->gt($this->cut_off_date);
|
||||
}
|
||||
}
|
||||
152
app/Models/Submission.php
Normal file
152
app/Models/Submission.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
|
||||
class Submission extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, LogsActivity;
|
||||
|
||||
public const STATUSES = [
|
||||
'draf' => 'Draf',
|
||||
'baru' => 'Baru',
|
||||
'dalam_tindakan' => 'Dalam Tindakan',
|
||||
'pembetulan_perunding' => 'Pembetulan Perunding',
|
||||
'selesai' => 'Selesai',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'data_centre_id',
|
||||
'reporting_cycle_id',
|
||||
'consultant_id',
|
||||
'checklist_template_id',
|
||||
'status',
|
||||
'is_locked',
|
||||
'locked_by',
|
||||
'locked_at',
|
||||
'lock_reason',
|
||||
'assigned_officer_id',
|
||||
'hardcopy_received',
|
||||
'hardcopy_received_date',
|
||||
'hardcopy_sent_date',
|
||||
'submitted_at',
|
||||
'submitted_by',
|
||||
'review_notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_locked' => 'boolean',
|
||||
'locked_at' => 'datetime',
|
||||
'hardcopy_received' => 'boolean',
|
||||
'hardcopy_received_date' => 'date',
|
||||
'hardcopy_sent_date' => 'date',
|
||||
'submitted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['status', 'is_locked', 'assigned_officer_id', 'hardcopy_received', 'hardcopy_sent_date'])
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('serahan');
|
||||
}
|
||||
|
||||
public function dataCentre(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DataCentre::class);
|
||||
}
|
||||
|
||||
public function reportingCycle(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ReportingCycle::class);
|
||||
}
|
||||
|
||||
public function consultant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Consultant::class);
|
||||
}
|
||||
|
||||
public function checklistTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChecklistTemplate::class);
|
||||
}
|
||||
|
||||
public function assignedOfficer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_officer_id');
|
||||
}
|
||||
|
||||
public function answers(): HasMany
|
||||
{
|
||||
return $this->hasMany(SubmissionAnswer::class);
|
||||
}
|
||||
|
||||
public function results(): HasMany
|
||||
{
|
||||
return $this->hasMany(SubmissionResult::class);
|
||||
}
|
||||
|
||||
public function documents(): HasMany
|
||||
{
|
||||
return $this->hasMany(SubmissionDocument::class)->orderByDesc('version');
|
||||
}
|
||||
|
||||
public function activeDocument(): HasOne
|
||||
{
|
||||
return $this->hasOne(SubmissionDocument::class)->where('is_active', true);
|
||||
}
|
||||
|
||||
public function statusHistories(): HasMany
|
||||
{
|
||||
return $this->hasMany(SubmissionStatusHistory::class)->latest();
|
||||
}
|
||||
|
||||
public function correctionRequests(): HasMany
|
||||
{
|
||||
return $this->hasMany(SubmissionCorrectionRequest::class)->latest();
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(SubmissionComment::class)->oldest();
|
||||
}
|
||||
|
||||
/** Perunding boleh edit selagi belum dikunci dan belum selesai. */
|
||||
public function isEditableByConsultant(): bool
|
||||
{
|
||||
return ! $this->is_locked && $this->status !== 'selesai';
|
||||
}
|
||||
|
||||
public function isSubmitted(): bool
|
||||
{
|
||||
return ! in_array($this->status, ['draf'], true);
|
||||
}
|
||||
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return self::STATUSES[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
public function statusBadgeClass(): string
|
||||
{
|
||||
return [
|
||||
'draf' => 'secondary',
|
||||
'baru' => 'info',
|
||||
'dalam_tindakan' => 'primary',
|
||||
'pembetulan_perunding' => 'warning',
|
||||
'selesai' => 'success',
|
||||
][$this->status] ?? 'secondary';
|
||||
}
|
||||
}
|
||||
38
app/Models/SubmissionAnswer.php
Normal file
38
app/Models/SubmissionAnswer.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SubmissionAnswer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'submission_id',
|
||||
'checklist_item_id',
|
||||
'value',
|
||||
'unit',
|
||||
'page_reference',
|
||||
'consultant_note',
|
||||
'officer_note',
|
||||
'validation_status',
|
||||
];
|
||||
|
||||
public function submission(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Submission::class);
|
||||
}
|
||||
|
||||
public function item(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChecklistItem::class, 'checklist_item_id');
|
||||
}
|
||||
|
||||
public function numericValue(): ?float
|
||||
{
|
||||
return is_numeric($this->value) ? (float) $this->value : null;
|
||||
}
|
||||
}
|
||||
40
app/Models/SubmissionComment.php
Normal file
40
app/Models/SubmissionComment.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class SubmissionComment extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'submission_id',
|
||||
'user_id',
|
||||
'body',
|
||||
'is_internal',
|
||||
'attachment_path',
|
||||
'attachment_name',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_internal' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function submission(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Submission::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
61
app/Models/SubmissionCorrectionRequest.php
Normal file
61
app/Models/SubmissionCorrectionRequest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SubmissionCorrectionRequest extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUSES = [
|
||||
'terbuka' => 'Terbuka',
|
||||
'dijawab' => 'Dijawab',
|
||||
'selesai' => 'Selesai',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'submission_id',
|
||||
'checklist_item_id',
|
||||
'page_reference',
|
||||
'location',
|
||||
'description',
|
||||
'status',
|
||||
'consultant_response',
|
||||
'responded_at',
|
||||
'responded_by',
|
||||
'created_by',
|
||||
'resolved_at',
|
||||
'resolved_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'responded_at' => 'datetime',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function submission(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Submission::class);
|
||||
}
|
||||
|
||||
public function item(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChecklistItem::class, 'checklist_item_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function respondedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'responded_by');
|
||||
}
|
||||
}
|
||||
56
app/Models/SubmissionDocument.php
Normal file
56
app/Models/SubmissionDocument.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SubmissionDocument extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'submission_id',
|
||||
'original_filename',
|
||||
'stored_path',
|
||||
'file_hash',
|
||||
'mime_type',
|
||||
'size',
|
||||
'version',
|
||||
'is_active',
|
||||
'uploaded_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'size' => 'integer',
|
||||
'version' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function submission(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Submission::class);
|
||||
}
|
||||
|
||||
public function uploadedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
|
||||
public function humanSize(): string
|
||||
{
|
||||
$bytes = (int) $this->size;
|
||||
if ($bytes >= 1048576) {
|
||||
return round($bytes / 1048576, 2).' MB';
|
||||
}
|
||||
if ($bytes >= 1024) {
|
||||
return round($bytes / 1024, 2).' KB';
|
||||
}
|
||||
|
||||
return $bytes.' B';
|
||||
}
|
||||
}
|
||||
32
app/Models/SubmissionResult.php
Normal file
32
app/Models/SubmissionResult.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SubmissionResult extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'submission_id',
|
||||
'result_token',
|
||||
'label',
|
||||
'value',
|
||||
'unit',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'value' => 'decimal:4',
|
||||
];
|
||||
}
|
||||
|
||||
public function submission(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Submission::class);
|
||||
}
|
||||
}
|
||||
30
app/Models/SubmissionStatusHistory.php
Normal file
30
app/Models/SubmissionStatusHistory.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SubmissionStatusHistory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'submission_id',
|
||||
'from_status',
|
||||
'to_status',
|
||||
'changed_by',
|
||||
'notes',
|
||||
];
|
||||
|
||||
public function submission(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Submission::class);
|
||||
}
|
||||
|
||||
public function changedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'changed_by');
|
||||
}
|
||||
}
|
||||
72
app/Models/User.php
Normal file
72
app/Models/User.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, Notifiable, HasRoles, SoftDeletes, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
'jawatan',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['name', 'email', 'phone', 'jawatan', 'is_active'])
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('pengguna');
|
||||
}
|
||||
|
||||
public function consultant(): HasOne
|
||||
{
|
||||
return $this->hasOne(Consultant::class);
|
||||
}
|
||||
|
||||
public function assignedSubmissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Submission::class, 'assigned_officer_id');
|
||||
}
|
||||
|
||||
/** Adakah pengguna ini seorang perunding. */
|
||||
public function isPerunding(): bool
|
||||
{
|
||||
return $this->hasRole('Perunding');
|
||||
}
|
||||
|
||||
/** Adakah pengguna ini kakitangan JPP (bukan perunding). */
|
||||
public function isJpp(): bool
|
||||
{
|
||||
return $this->hasAnyRole(['Super Admin', 'JPP Admin', 'Pegawai JPP', 'Penolong Pegawai', 'Kerani']);
|
||||
}
|
||||
}
|
||||
34
app/Policies/ChecklistTemplatePolicy.php
Normal file
34
app/Policies/ChecklistTemplatePolicy.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ChecklistTemplate;
|
||||
use App\Models\User;
|
||||
|
||||
class ChecklistTemplatePolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('templat.urus');
|
||||
}
|
||||
|
||||
public function view(User $user, ChecklistTemplate $template): bool
|
||||
{
|
||||
return $user->can('templat.urus');
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->can('templat.urus');
|
||||
}
|
||||
|
||||
public function update(User $user, ChecklistTemplate $template): bool
|
||||
{
|
||||
return $user->can('templat.urus');
|
||||
}
|
||||
|
||||
public function delete(User $user, ChecklistTemplate $template): bool
|
||||
{
|
||||
return $user->can('templat.urus');
|
||||
}
|
||||
}
|
||||
39
app/Policies/ConsultantPolicy.php
Normal file
39
app/Policies/ConsultantPolicy.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Consultant;
|
||||
use App\Models\User;
|
||||
|
||||
class ConsultantPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('perunding.urus');
|
||||
}
|
||||
|
||||
public function view(User $user, Consultant $consultant): bool
|
||||
{
|
||||
if ($user->can('perunding.urus')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Perunding boleh lihat profil sendiri.
|
||||
return $user->consultant && $user->consultant->id === $consultant->id;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->can('perunding.urus');
|
||||
}
|
||||
|
||||
public function update(User $user, Consultant $consultant): bool
|
||||
{
|
||||
return $user->can('perunding.urus');
|
||||
}
|
||||
|
||||
public function delete(User $user, Consultant $consultant): bool
|
||||
{
|
||||
return $user->can('perunding.urus');
|
||||
}
|
||||
}
|
||||
46
app/Policies/DataCentrePolicy.php
Normal file
46
app/Policies/DataCentrePolicy.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\DataCentre;
|
||||
use App\Models\User;
|
||||
|
||||
class DataCentrePolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('pusat_data.urus') || $user->can('serahan.urus_sendiri');
|
||||
}
|
||||
|
||||
/** Perunding hanya boleh lihat pusat data yang sedang ditugaskan kepadanya. */
|
||||
public function view(User $user, DataCentre $dataCentre): bool
|
||||
{
|
||||
if ($user->can('pusat_data.urus')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$consultantId = $user->consultant?->id;
|
||||
|
||||
return $consultantId !== null && (int) $dataCentre->current_consultant_id === (int) $consultantId;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->can('pusat_data.urus');
|
||||
}
|
||||
|
||||
public function update(User $user, DataCentre $dataCentre): bool
|
||||
{
|
||||
return $user->can('pusat_data.urus');
|
||||
}
|
||||
|
||||
public function delete(User $user, DataCentre $dataCentre): bool
|
||||
{
|
||||
return $user->can('pusat_data.urus');
|
||||
}
|
||||
|
||||
public function assignConsultant(User $user, DataCentre $dataCentre): bool
|
||||
{
|
||||
return $user->can('tugasan.urus');
|
||||
}
|
||||
}
|
||||
34
app/Policies/ReportingCyclePolicy.php
Normal file
34
app/Policies/ReportingCyclePolicy.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ReportingCycle;
|
||||
use App\Models\User;
|
||||
|
||||
class ReportingCyclePolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('kitaran.urus') || $user->can('serahan.urus_sendiri');
|
||||
}
|
||||
|
||||
public function view(User $user, ReportingCycle $cycle): bool
|
||||
{
|
||||
return $user->can('kitaran.urus') || $user->can('serahan.urus_sendiri');
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->can('kitaran.urus');
|
||||
}
|
||||
|
||||
public function update(User $user, ReportingCycle $cycle): bool
|
||||
{
|
||||
return $user->can('kitaran.urus');
|
||||
}
|
||||
|
||||
public function delete(User $user, ReportingCycle $cycle): bool
|
||||
{
|
||||
return $user->can('kitaran.urus');
|
||||
}
|
||||
}
|
||||
80
app/Policies/SubmissionPolicy.php
Normal file
80
app/Policies/SubmissionPolicy.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Submission;
|
||||
use App\Models\User;
|
||||
|
||||
class SubmissionPolicy
|
||||
{
|
||||
/** Perunding hanya boleh lihat serahan miliknya; kakitangan JPP lihat semua. */
|
||||
public function view(User $user, Submission $submission): bool
|
||||
{
|
||||
if ($user->can('serahan.lihat_semua')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->ownsSubmission($user, $submission);
|
||||
}
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('serahan.lihat_semua') || $user->can('serahan.urus_sendiri');
|
||||
}
|
||||
|
||||
/** Hanya perunding boleh cipta serahan untuk pusat data yang ditugaskan. */
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->can('serahan.urus_sendiri');
|
||||
}
|
||||
|
||||
/** Perunding boleh kemaskini selagi miliknya, belum dikunci & belum selesai. */
|
||||
public function update(User $user, Submission $submission): bool
|
||||
{
|
||||
if (! $user->can('serahan.urus_sendiri')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->ownsSubmission($user, $submission) && $submission->isEditableByConsultant();
|
||||
}
|
||||
|
||||
/** Pegawai JPP menyemak serahan. */
|
||||
public function review(User $user, Submission $submission): bool
|
||||
{
|
||||
return $user->can('serahan.semak');
|
||||
}
|
||||
|
||||
public function lock(User $user, Submission $submission): bool
|
||||
{
|
||||
return $user->can('serahan.kunci');
|
||||
}
|
||||
|
||||
public function markHardcopy(User $user, Submission $submission): bool
|
||||
{
|
||||
return $user->can('serahan.hardcopy');
|
||||
}
|
||||
|
||||
/** Perunding boleh jawab permohonan pembetulan untuk serahan miliknya. */
|
||||
public function respondCorrection(User $user, Submission $submission): bool
|
||||
{
|
||||
return $user->can('serahan.urus_sendiri')
|
||||
&& $this->ownsSubmission($user, $submission)
|
||||
&& $submission->status === 'pembetulan_perunding';
|
||||
}
|
||||
|
||||
public function comment(User $user, Submission $submission): bool
|
||||
{
|
||||
if (! $user->can('komen.cipta')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('serahan.lihat_semua') || $this->ownsSubmission($user, $submission);
|
||||
}
|
||||
|
||||
protected function ownsSubmission(User $user, Submission $submission): bool
|
||||
{
|
||||
$consultantId = $user->consultant?->id;
|
||||
|
||||
return $consultantId !== null && (int) $submission->consultant_id === (int) $consultantId;
|
||||
}
|
||||
}
|
||||
25
app/Providers/AppServiceProvider.php
Normal file
25
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Super Admin mempunyai akses penuh kepada semua kebenaran.
|
||||
Gate::before(function ($user, $ability) {
|
||||
return $user->hasRole('Super Admin') ? true : null;
|
||||
});
|
||||
|
||||
Paginator::useBootstrapFive();
|
||||
}
|
||||
}
|
||||
80
app/Services/AssignmentService.php
Normal file
80
app/Services/AssignmentService.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Consultant;
|
||||
use App\Models\DataCentre;
|
||||
use App\Models\DataCentreConsultantAssignment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AssignmentService
|
||||
{
|
||||
/**
|
||||
* Tugaskan perunding kepada pusat data.
|
||||
*
|
||||
* Peraturan: satu pusat data hanya boleh ada SATU perunding aktif pada satu masa.
|
||||
* Penugasan terdahulu ditamatkan (end_date diisi, is_active=false) dan disimpan
|
||||
* dalam sejarah penugasan.
|
||||
*/
|
||||
public function assign(DataCentre $dataCentre, Consultant $consultant, User $officer, ?string $startDate = null, ?string $reason = null): DataCentreConsultantAssignment
|
||||
{
|
||||
return DB::transaction(function () use ($dataCentre, $consultant, $officer, $startDate, $reason) {
|
||||
$start = $startDate ? Carbon::parse($startDate) : now();
|
||||
|
||||
// Tamatkan semua penugasan aktif sedia ada untuk pusat data ini.
|
||||
$dataCentre->assignments()
|
||||
->where('is_active', true)
|
||||
->get()
|
||||
->each(function (DataCentreConsultantAssignment $existing) use ($start) {
|
||||
$existing->update([
|
||||
'is_active' => false,
|
||||
'end_date' => $existing->end_date ?? $start->copy()->subDay(),
|
||||
]);
|
||||
});
|
||||
|
||||
$assignment = $dataCentre->assignments()->create([
|
||||
'consultant_id' => $consultant->id,
|
||||
'start_date' => $start,
|
||||
'reason' => $reason,
|
||||
'assigned_by' => $officer->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$dataCentre->update(['current_consultant_id' => $consultant->id]);
|
||||
|
||||
activity('penugasan')
|
||||
->performedOn($dataCentre)
|
||||
->causedBy($officer)
|
||||
->withProperties([
|
||||
'consultant_id' => $consultant->id,
|
||||
'consultant' => $consultant->nama_perunding,
|
||||
'reason' => $reason,
|
||||
])
|
||||
->log('Perunding ditugaskan kepada pusat data');
|
||||
|
||||
return $assignment;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tamatkan penugasan aktif tanpa menggantikan dengan perunding baharu.
|
||||
*/
|
||||
public function endActive(DataCentre $dataCentre, User $officer, ?string $reason = null): void
|
||||
{
|
||||
DB::transaction(function () use ($dataCentre, $officer, $reason) {
|
||||
$dataCentre->assignments()->where('is_active', true)->update([
|
||||
'is_active' => false,
|
||||
'end_date' => now(),
|
||||
'reason' => $reason,
|
||||
]);
|
||||
$dataCentre->update(['current_consultant_id' => null]);
|
||||
|
||||
activity('penugasan')
|
||||
->performedOn($dataCentre)
|
||||
->causedBy($officer)
|
||||
->log('Penugasan perunding ditamatkan');
|
||||
});
|
||||
}
|
||||
}
|
||||
67
app/Services/DocumentService.php
Normal file
67
app/Services/DocumentService.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Submission;
|
||||
use App\Models\SubmissionDocument;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DocumentService
|
||||
{
|
||||
/**
|
||||
* Simpan laporan PDF baharu untuk serahan dengan versi & hash.
|
||||
* Versi sebelumnya ditanda tidak aktif tetapi dikekalkan (sejarah versi).
|
||||
* Fail disimpan pada disk 'local' (di luar direktori public).
|
||||
*/
|
||||
public function store(Submission $submission, UploadedFile $file, User $user): SubmissionDocument
|
||||
{
|
||||
return DB::transaction(function () use ($submission, $file, $user) {
|
||||
$nextVersion = (int) $submission->documents()->max('version') + 1;
|
||||
|
||||
$hash = hash_file('sha256', $file->getRealPath());
|
||||
$path = $file->storeAs(
|
||||
"reports/{$submission->id}",
|
||||
'v'.$nextVersion.'_'.uniqid().'.pdf',
|
||||
'local'
|
||||
);
|
||||
|
||||
// Nyahaktifkan versi aktif sebelumnya.
|
||||
$submission->documents()->where('is_active', true)->update(['is_active' => false]);
|
||||
|
||||
$document = $submission->documents()->create([
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'stored_path' => $path,
|
||||
'file_hash' => $hash,
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
'version' => $nextVersion,
|
||||
'is_active' => true,
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
activity('dokumen')
|
||||
->performedOn($submission)
|
||||
->causedBy($user)
|
||||
->withProperties(['version' => $nextVersion, 'filename' => $document->original_filename])
|
||||
->log('Laporan PDF dimuat naik');
|
||||
|
||||
return $document;
|
||||
});
|
||||
}
|
||||
|
||||
public function setActiveVersion(Submission $submission, SubmissionDocument $document): void
|
||||
{
|
||||
$submission->documents()->where('is_active', true)->update(['is_active' => false]);
|
||||
$document->update(['is_active' => true]);
|
||||
}
|
||||
|
||||
public function download(SubmissionDocument $document)
|
||||
{
|
||||
abort_unless(Storage::disk('local')->exists($document->stored_path), 404, 'Fail laporan tidak dijumpai.');
|
||||
|
||||
return Storage::disk('local')->download($document->stored_path, $document->original_filename);
|
||||
}
|
||||
}
|
||||
204
app/Services/ExpressionEvaluator.php
Normal file
204
app/Services/ExpressionEvaluator.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Penilai ungkapan aritmetik yang selamat (recursive-descent parser).
|
||||
*
|
||||
* Menyokong: + - * / ( ), nombor perpuluhan, dan pemboleh ubah (token) yang
|
||||
* dipetakan daripada array $variables. Tiada eval()/PHP code dilaksanakan,
|
||||
* jadi selamat untuk formula yang dikonfigur oleh pengguna JPP.
|
||||
*/
|
||||
class ExpressionEvaluator
|
||||
{
|
||||
/** @var array<int, array{type:string, value:string}> */
|
||||
protected array $tokens = [];
|
||||
|
||||
protected int $pos = 0;
|
||||
|
||||
/** @var array<string, float> */
|
||||
protected array $variables = [];
|
||||
|
||||
protected bool $guardDivZero = false;
|
||||
|
||||
protected bool $divByZeroHit = false;
|
||||
|
||||
/**
|
||||
* @param array<string, float|int|null> $variables
|
||||
*/
|
||||
public function evaluate(string $expression, array $variables = [], bool $guardDivZero = false): ?float
|
||||
{
|
||||
$this->variables = array_map(fn ($v) => $v === null ? 0.0 : (float) $v, $variables);
|
||||
$this->guardDivZero = $guardDivZero;
|
||||
$this->divByZeroHit = false;
|
||||
$this->tokens = $this->tokenize($expression);
|
||||
$this->pos = 0;
|
||||
|
||||
if (empty($this->tokens)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $this->parseExpression();
|
||||
|
||||
if ($this->pos < count($this->tokens)) {
|
||||
throw new RuntimeException('Ungkapan formula tidak sah berhampiran: '.($this->tokens[$this->pos]['value'] ?? ''));
|
||||
}
|
||||
|
||||
if ($this->guardDivZero && $this->divByZeroHit) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{type:string, value:string}>
|
||||
*/
|
||||
protected function tokenize(string $expr): array
|
||||
{
|
||||
$tokens = [];
|
||||
$len = strlen($expr);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $len) {
|
||||
$ch = $expr[$i];
|
||||
|
||||
if (ctype_space($ch)) {
|
||||
$i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($ch, ['+', '-', '*', '/', '(', ')'], true)) {
|
||||
$tokens[] = ['type' => 'op', 'value' => $ch];
|
||||
$i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nombor (termasuk perpuluhan)
|
||||
if (ctype_digit($ch) || ($ch === '.' && $i + 1 < $len && ctype_digit($expr[$i + 1]))) {
|
||||
$num = '';
|
||||
while ($i < $len && (ctype_digit($expr[$i]) || $expr[$i] === '.')) {
|
||||
$num .= $expr[$i];
|
||||
$i++;
|
||||
}
|
||||
$tokens[] = ['type' => 'num', 'value' => $num];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Token / pemboleh ubah: huruf, nombor, underscore (cth: SKOP1, A, D4I)
|
||||
if (ctype_alpha($ch) || $ch === '_') {
|
||||
$tok = '';
|
||||
while ($i < $len && (ctype_alnum($expr[$i]) || $expr[$i] === '_')) {
|
||||
$tok .= $expr[$i];
|
||||
$i++;
|
||||
}
|
||||
$tokens[] = ['type' => 'var', 'value' => $tok];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new RuntimeException("Aksara tidak sah dalam formula: '$ch'");
|
||||
}
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
protected function peek(): ?array
|
||||
{
|
||||
return $this->tokens[$this->pos] ?? null;
|
||||
}
|
||||
|
||||
protected function consume(): ?array
|
||||
{
|
||||
return $this->tokens[$this->pos++] ?? null;
|
||||
}
|
||||
|
||||
/** expression = term (('+' | '-') term)* */
|
||||
protected function parseExpression(): float
|
||||
{
|
||||
$value = $this->parseTerm();
|
||||
|
||||
while (($t = $this->peek()) && $t['type'] === 'op' && in_array($t['value'], ['+', '-'], true)) {
|
||||
$op = $this->consume()['value'];
|
||||
$right = $this->parseTerm();
|
||||
$value = $op === '+' ? $value + $right : $value - $right;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** term = factor (('*' | '/') factor)* */
|
||||
protected function parseTerm(): float
|
||||
{
|
||||
$value = $this->parseFactor();
|
||||
|
||||
while (($t = $this->peek()) && $t['type'] === 'op' && in_array($t['value'], ['*', '/'], true)) {
|
||||
$op = $this->consume()['value'];
|
||||
$right = $this->parseFactor();
|
||||
|
||||
if ($op === '*') {
|
||||
$value *= $right;
|
||||
} else {
|
||||
if ($right == 0.0) {
|
||||
$this->divByZeroHit = true;
|
||||
if ($this->guardDivZero) {
|
||||
$value = 0.0;
|
||||
|
||||
continue;
|
||||
}
|
||||
throw new RuntimeException('Pembahagian dengan sifar dalam formula.');
|
||||
}
|
||||
$value /= $right;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** factor = number | variable | '(' expression ')' | ('+'|'-') factor */
|
||||
protected function parseFactor(): float
|
||||
{
|
||||
$t = $this->peek();
|
||||
|
||||
if ($t === null) {
|
||||
throw new RuntimeException('Ungkapan formula tidak lengkap.');
|
||||
}
|
||||
|
||||
if ($t['type'] === 'op' && $t['value'] === '(') {
|
||||
$this->consume();
|
||||
$value = $this->parseExpression();
|
||||
$close = $this->consume();
|
||||
if (! $close || $close['value'] !== ')') {
|
||||
throw new RuntimeException('Kurungan tidak seimbang dalam formula.');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($t['type'] === 'op' && in_array($t['value'], ['+', '-'], true)) {
|
||||
$op = $this->consume()['value'];
|
||||
$value = $this->parseFactor();
|
||||
|
||||
return $op === '-' ? -$value : $value;
|
||||
}
|
||||
|
||||
if ($t['type'] === 'num') {
|
||||
$this->consume();
|
||||
|
||||
return (float) $t['value'];
|
||||
}
|
||||
|
||||
if ($t['type'] === 'var') {
|
||||
$this->consume();
|
||||
|
||||
return $this->variables[$t['value']] ?? 0.0;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Token formula tidak dijangka: '.$t['value']);
|
||||
}
|
||||
}
|
||||
100
app/Services/FormulaService.php
Normal file
100
app/Services/FormulaService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ChecklistTemplate;
|
||||
use App\Models\Submission;
|
||||
|
||||
/**
|
||||
* Mengira hasil formula seksyen (A, B, C, D, E ...) berdasarkan jawapan serahan.
|
||||
*
|
||||
* Token tersedia kepada formula:
|
||||
* - formula_token bagi setiap checklist_item (cth: SKOP1, EE, RE, D4I)
|
||||
* - result_token bagi setiap formula yang telah dikira sebelumnya (cth: A, B)
|
||||
*
|
||||
* Formula dinilai mengikut turutan `order` supaya C boleh merujuk A & B, dsb.
|
||||
*/
|
||||
class FormulaService
|
||||
{
|
||||
public function __construct(protected ExpressionEvaluator $evaluator) {}
|
||||
|
||||
/**
|
||||
* Kira semua hasil formula bagi satu serahan.
|
||||
*
|
||||
* @return array<int, array{result_token:string,label:?string,value:?float,unit:?string}>
|
||||
*/
|
||||
public function computeForSubmission(Submission $submission): array
|
||||
{
|
||||
$template = $submission->checklistTemplate
|
||||
?: ($submission->checklist_template_id ? ChecklistTemplate::find($submission->checklist_template_id) : ChecklistTemplate::activeTemplate());
|
||||
|
||||
if (! $template) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Bina peta token => nilai daripada jawapan item.
|
||||
$variables = [];
|
||||
$answers = $submission->relationLoaded('answers')
|
||||
? $submission->answers
|
||||
: $submission->answers()->with('item')->get();
|
||||
|
||||
foreach ($answers as $answer) {
|
||||
$item = $answer->item;
|
||||
if ($item && $item->formula_token) {
|
||||
$variables[$item->formula_token] = is_numeric($answer->value) ? (float) $answer->value : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->compute($template, $variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kira hasil formula daripada peta pemboleh ubah mentah (berguna untuk ujian).
|
||||
*
|
||||
* @param array<string, float|int|null> $variables
|
||||
* @return array<int, array{result_token:string,label:?string,value:?float,unit:?string}>
|
||||
*/
|
||||
public function compute(ChecklistTemplate $template, array $variables): array
|
||||
{
|
||||
$formulas = $template->relationLoaded('formulas')
|
||||
? $template->formulas->sortBy('order')
|
||||
: $template->formulas()->orderBy('order')->get();
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($formulas as $formula) {
|
||||
$value = $this->evaluator->evaluate($formula->expression, $variables, $formula->guard_div_zero);
|
||||
|
||||
// Hasil ini menjadi token yang boleh dirujuk oleh formula seterusnya.
|
||||
$variables[$formula->result_token] = $value ?? 0.0;
|
||||
|
||||
$results[] = [
|
||||
'result_token' => $formula->result_token,
|
||||
'label' => $formula->label,
|
||||
'value' => $value,
|
||||
'unit' => $formula->unit,
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kira semula dan simpan hasil ke dalam jadual submission_results.
|
||||
*/
|
||||
public function recalculateAndStore(Submission $submission): void
|
||||
{
|
||||
$results = $this->computeForSubmission($submission);
|
||||
|
||||
foreach ($results as $r) {
|
||||
$submission->results()->updateOrCreate(
|
||||
['result_token' => $r['result_token']],
|
||||
[
|
||||
'label' => $r['label'],
|
||||
'value' => $r['value'],
|
||||
'unit' => $r['unit'],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Services/ReportingCycleService.php
Normal file
37
app/Services/ReportingCycleService.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AppSetting;
|
||||
use App\Models\ChecklistTemplate;
|
||||
use App\Models\ReportingCycle;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ReportingCycleService
|
||||
{
|
||||
/**
|
||||
* Bina atribut kitaran daripada tahun pembaharuan.
|
||||
* Peraturan perniagaan:
|
||||
* - reporting_year = renewal_year - 2 (cth: pembaharuan 2028 => pelaporan 2026)
|
||||
* - tempoh pelaporan: 1 Jan - 31 Dis tahun pelaporan
|
||||
* - tarikh tutup lalai: hari/bulan yang dikonfigur JPP, tahun (renewal_year - 1)
|
||||
*/
|
||||
public function buildAttributes(int $renewalYear, ?string $cutOffDate = null): array
|
||||
{
|
||||
$reportingYear = ReportingCycle::reportingYearFor($renewalYear);
|
||||
|
||||
$cutOffDay = (int) AppSetting::get('default_cut_off_day', 1);
|
||||
$cutOffMonth = (int) AppSetting::get('default_cut_off_month', 12);
|
||||
|
||||
// Pembaharuan dibuat sebelum/pada 1 Disember tahun sebelum pembaharuan.
|
||||
$defaultCutOff = Carbon::create($renewalYear - 1, $cutOffMonth, $cutOffDay);
|
||||
|
||||
return [
|
||||
'reporting_year' => $reportingYear,
|
||||
'period_start' => Carbon::create($reportingYear, 1, 1),
|
||||
'period_end' => Carbon::create($reportingYear, 12, 31),
|
||||
'cut_off_date' => $cutOffDate ? Carbon::parse($cutOffDate) : $defaultCutOff,
|
||||
'checklist_template_id' => ChecklistTemplate::activeTemplate()?->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
144
app/Services/SubmissionWorkflowService.php
Normal file
144
app/Services/SubmissionWorkflowService.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ChecklistTemplate;
|
||||
use App\Models\DataCentre;
|
||||
use App\Models\ReportingCycle;
|
||||
use App\Models\Submission;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SubmissionWorkflowService
|
||||
{
|
||||
public function __construct(protected FormulaService $formulaService) {}
|
||||
|
||||
/**
|
||||
* Dapatkan atau cipta serahan draf untuk pusat data + kitaran tertentu.
|
||||
*/
|
||||
public function findOrCreateDraft(DataCentre $dataCentre, ReportingCycle $cycle): Submission
|
||||
{
|
||||
$consultantId = $dataCentre->current_consultant_id;
|
||||
|
||||
if (! $consultantId) {
|
||||
throw ValidationException::withMessages([
|
||||
'data_centre_id' => 'Pusat data ini tiada perunding aktif. Sila hubungi JPP.',
|
||||
]);
|
||||
}
|
||||
|
||||
return Submission::firstOrCreate(
|
||||
[
|
||||
'data_centre_id' => $dataCentre->id,
|
||||
'reporting_cycle_id' => $cycle->id,
|
||||
],
|
||||
[
|
||||
'consultant_id' => $consultantId,
|
||||
'checklist_template_id' => $cycle->checklist_template_id ?? ChecklistTemplate::activeTemplate()?->id,
|
||||
'status' => 'draf',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan jawapan senarai semak (draf). $answers = [item_id => [value, unit, page_reference, consultant_note]]
|
||||
*/
|
||||
public function saveAnswers(Submission $submission, array $answers): void
|
||||
{
|
||||
DB::transaction(function () use ($submission, $answers) {
|
||||
foreach ($answers as $itemId => $data) {
|
||||
$submission->answers()->updateOrCreate(
|
||||
['checklist_item_id' => $itemId],
|
||||
[
|
||||
'value' => $data['value'] ?? null,
|
||||
'unit' => $data['unit'] ?? null,
|
||||
'page_reference' => $data['page_reference'] ?? null,
|
||||
'consultant_note' => $data['consultant_note'] ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Kira semula nilai formula (A, B, C, D, E ...).
|
||||
$submission->load('answers.item');
|
||||
$this->formulaService->recalculateAndStore($submission);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hantar serahan secara muktamad (draf -> baru).
|
||||
*/
|
||||
public function submit(Submission $submission, User $user): void
|
||||
{
|
||||
if ($submission->is_locked) {
|
||||
throw ValidationException::withMessages(['status' => 'Serahan telah dikunci dan tidak boleh dihantar semula.']);
|
||||
}
|
||||
|
||||
// Mesti ada sekurang-kurangnya satu laporan PDF aktif sebelum hantar.
|
||||
if (! $submission->activeDocument()->exists()) {
|
||||
throw ValidationException::withMessages(['document' => 'Sila muat naik laporan PDF sebelum menghantar serahan.']);
|
||||
}
|
||||
|
||||
$from = $submission->status;
|
||||
// Jika sedang dalam pembetulan, hantar semula sebagai 'dalam_tindakan'.
|
||||
$to = $from === 'pembetulan_perunding' ? 'dalam_tindakan' : 'baru';
|
||||
|
||||
$this->changeStatus($submission, $to, $user, 'Serahan dihantar oleh perunding.', [
|
||||
'submitted_at' => now(),
|
||||
'submitted_by' => $user->id,
|
||||
]);
|
||||
|
||||
$this->formulaService->recalculateAndStore($submission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tukar status serahan dengan rekod sejarah & audit.
|
||||
*/
|
||||
public function changeStatus(Submission $submission, string $to, User $user, ?string $notes = null, array $extra = []): void
|
||||
{
|
||||
DB::transaction(function () use ($submission, $to, $user, $notes, $extra) {
|
||||
$from = $submission->status;
|
||||
|
||||
$submission->fill(array_merge(['status' => $to], $extra));
|
||||
$submission->save();
|
||||
|
||||
$submission->statusHistories()->create([
|
||||
'from_status' => $from,
|
||||
'to_status' => $to,
|
||||
'changed_by' => $user->id,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
|
||||
activity('serahan')
|
||||
->performedOn($submission)
|
||||
->causedBy($user)
|
||||
->withProperties(['from' => $from, 'to' => $to, 'notes' => $notes])
|
||||
->log("Status serahan ditukar daripada {$from} kepada {$to}");
|
||||
});
|
||||
}
|
||||
|
||||
public function lock(Submission $submission, User $user, ?string $reason = null): void
|
||||
{
|
||||
$submission->update([
|
||||
'is_locked' => true,
|
||||
'locked_by' => $user->id,
|
||||
'locked_at' => now(),
|
||||
'lock_reason' => $reason,
|
||||
]);
|
||||
|
||||
activity('serahan')->performedOn($submission)->causedBy($user)
|
||||
->withProperties(['reason' => $reason])->log('Serahan dikunci');
|
||||
}
|
||||
|
||||
public function unlock(Submission $submission, User $user, string $reason): void
|
||||
{
|
||||
$submission->update([
|
||||
'is_locked' => false,
|
||||
'lock_reason' => $reason,
|
||||
'locked_by' => $user->id,
|
||||
'locked_at' => now(),
|
||||
]);
|
||||
|
||||
activity('serahan')->performedOn($submission)->causedBy($user)
|
||||
->withProperties(['reason' => $reason])->log('Serahan dibuka semula');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user