Admin Panel
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-09-29 14:57:37 +07:00
parent 04fbddba13
commit 8c6dad2a31
94 changed files with 4864 additions and 1151 deletions
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ContactSettingsRequest;
use App\Models\SiteSetting;
use App\Support\SiteInfo;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
/**
* Company contact details and office locations shown across the site.
*/
class CompanyController extends Controller
{
public function edit(): Response
{
return Inertia::render('admin/company/edit', SiteInfo::get());
}
public function updateContact(ContactSettingsRequest $request): RedirectResponse
{
SiteSetting::put(SiteInfo::CONTACT_KEY, $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kontak perusahaan diperbarui.']);
return back();
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\ConsultationStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateConsultationRequest;
use App\Models\Consultation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ConsultationController extends Controller
{
public function index(Request $request): Response
{
$status = ConsultationStatus::tryFrom((string) $request->query('status'));
return Inertia::render('admin/consultations/index', [
'consultations' => Consultation::query()
->when($status, fn ($query) => $query->where('status', $status))
->latest()
->paginate(20)
->withQueryString(),
'status' => $status?->value,
'counts' => Consultation::query()
->selectRaw('status, count(*) as total')
->groupBy('status')
->pluck('total', 'status'),
]);
}
public function update(UpdateConsultationRequest $request, Consultation $consultation): RedirectResponse
{
$consultation->update($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Permintaan konsultasi diperbarui.']);
return back();
}
public function destroy(Consultation $consultation): RedirectResponse
{
$consultation->delete();
Inertia::flash('toast', ['type' => 'success', 'message' => 'Permintaan konsultasi dihapus.']);
return back();
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\ConsultationStatus;
use App\Http\Controllers\Controller;
use App\Models\Consultation;
use App\Models\Project;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __invoke(): Response
{
return Inertia::render('dashboard', [
'projects' => Project::query()
->latestFirst()
->get(['id', 'title', 'client', 'year', 'sector', 'program', 'scope']),
'newConsultations' => Consultation::query()
->where('status', ConsultationStatus::New)
->count(),
'latestConsultations' => Consultation::query()
->latest()
->limit(5)
->get(['id', 'name', 'company', 'service', 'status', 'created_at']),
]);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\OfficeRequest;
use App\Models\Office;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
class OfficeController extends Controller
{
public function store(OfficeRequest $request): RedirectResponse
{
Office::query()->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kantor ditambahkan.']);
return back();
}
public function update(OfficeRequest $request, Office $office): RedirectResponse
{
$office->update($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kantor diperbarui.']);
return back();
}
public function destroy(Office $office): RedirectResponse
{
$office->delete();
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kantor dihapus.']);
return back();
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ProjectRequest;
use App\Models\Project;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class ProjectController extends Controller
{
public function index(): Response
{
return Inertia::render('admin/projects/index', [
'projects' => Project::query()->latestFirst()->get(),
]);
}
public function store(ProjectRequest $request): RedirectResponse
{
Project::query()->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Proyek ditambahkan.']);
return back();
}
public function update(ProjectRequest $request, Project $project): RedirectResponse
{
$project->update($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Proyek diperbarui.']);
return back();
}
public function destroy(Project $project): RedirectResponse
{
$project->delete();
Inertia::flash('toast', ['type' => 'success', 'message' => 'Proyek dihapus.']);
return back();
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\TestimonialRequest;
use App\Models\Testimonial;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class TestimonialController extends Controller
{
public function index(): Response
{
return Inertia::render('admin/testimonials/index', [
'testimonials' => Testimonial::query()->orderBy('sort_order')->orderBy('id')->get(),
]);
}
public function store(TestimonialRequest $request): RedirectResponse
{
Testimonial::query()->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Testimoni ditambahkan.']);
return back();
}
public function update(TestimonialRequest $request, Testimonial $testimonial): RedirectResponse
{
$testimonial->update($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Testimoni diperbarui.']);
return back();
}
public function destroy(Testimonial $testimonial): RedirectResponse
{
$testimonial->delete();
Inertia::flash('toast', ['type' => 'success', 'message' => 'Testimoni dihapus.']);
return back();
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UserRequest;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
/**
* Admin-only management of panel accounts (registration is disabled).
*/
class UserController extends Controller
{
public function index(): Response
{
return Inertia::render('admin/users/index', [
'users' => User::query()->orderBy('name')->get(['id', 'name', 'email', 'role', 'created_at']),
]);
}
public function store(UserRequest $request): RedirectResponse
{
$user = User::query()->create($request->safe()->only(['name', 'email', 'role', 'password']));
// Accounts are created by an admin, so there is nothing left to verify.
$user->forceFill(['email_verified_at' => now()])->save();
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengguna ditambahkan.']);
return back();
}
public function update(UserRequest $request, User $user): RedirectResponse
{
$data = $request->safe()->only(['name', 'email', 'role']);
if ($request->filled('password')) {
$data['password'] = $request->validated('password');
}
// Keep at least one admin: nobody may demote themselves.
if ($user->is($request->user())) {
unset($data['role']);
}
$user->update($data);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengguna diperbarui.']);
return back();
}
public function destroy(Request $request, User $user): RedirectResponse
{
abort_if($user->is($request->user()), 403, 'Anda tidak dapat menghapus akun sendiri.');
$user->delete();
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengguna dihapus.']);
return back();
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreConsultationRequest;
use App\Models\Consultation;
use App\Notifications\NewConsultationRequest;
use App\Support\SiteInfo;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Notification;
class ConsultationController extends Controller
{
/**
* Save a consultation request from the public site and notify the team.
*/
public function store(StoreConsultationRequest $request): RedirectResponse
{
$consultation = Consultation::query()->create([
...$request->safe()->except(['website', 'nda_agreed']),
'nda_agreed' => $request->boolean('nda_agreed'),
'locale' => app()->getLocale(),
]);
Notification::route('mail', SiteInfo::get()['contact']['email_busdev'])
->notify(new NewConsultationRequest($consultation));
return back();
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers;
use App\Models\Project;
use App\Models\Testimonial;
use Illuminate\Support\Collection;
use Inertia\Inertia;
use Inertia\Response;
/**
* Public company-profile pages. Page copy lives in the frontend content
* files; editable data (portfolio, testimonials) comes from the database.
*/
class SitePageController extends Controller
{
public function home(): Response
{
return Inertia::render('site/home', ['projects' => $this->projects()]);
}
public function about(): Response
{
return Inertia::render('site/about', ['projects' => $this->projects()]);
}
public function services(): Response
{
return Inertia::render('site/services');
}
public function clients(): Response
{
$locale = app()->getLocale();
return Inertia::render('site/clients', [
'projects' => $this->projects(),
'testimonials' => Testimonial::query()
->published()
->get()
->map(fn (Testimonial $testimonial) => $testimonial->localized($locale)),
]);
}
public function contact(): Response
{
return Inertia::render('site/contact');
}
/**
* @return Collection<int, Project>
*/
private function projects(): Collection
{
return Project::query()
->latestFirst()
->get(['id', 'title', 'client', 'year', 'sector', 'program', 'scope']);
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Support\SiteInfo;
use Illuminate\Http\Request;
use Inertia\Middleware;
@@ -39,6 +40,10 @@ class HandleInertiaRequests extends Middleware
...parent::share($request),
'name' => config('app.name'),
'locale' => app()->getLocale(),
'site' => fn () => SiteInfo::get(),
'can' => [
'manageUsers' => (bool) $request->user()?->can('manage-users'),
],
'auth' => [
'user' => $request->user(),
],
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ContactSettingsRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'phone' => ['required', 'string', 'max:50'],
'whatsapp' => ['required', 'string', 'max:50'],
'email_busdev' => ['required', 'email', 'max:255'],
'email_support' => ['required', 'email', 'max:255'],
];
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class OfficeRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'icon' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9_]+$/'],
'building' => ['required', 'string', 'max:255'],
'street' => ['required', 'string', 'max:255'],
'area' => ['required', 'string', 'max:255'],
'city' => ['required', 'string', 'max:255'],
'district' => ['required', 'string', 'max:255'],
'phone' => ['required', 'string', 'max:50'],
'maps_url' => ['nullable', 'url', 'max:500'],
'sort_order' => ['required', 'integer', 'min:0'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Admin;
use App\Enums\ProjectProgram;
use App\Enums\ProjectSector;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProjectRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'client' => ['required', 'string', 'max:255'],
'year' => ['required', 'integer', 'min:1985', 'max:'.(now()->year + 1)],
'sector' => ['required', Rule::enum(ProjectSector::class)],
'program' => ['nullable', Rule::enum(ProjectProgram::class)],
'scope' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class TestimonialRequest extends FormRequest
{
/**
* An unchecked checkbox is not submitted, so treat it as unpublished.
*/
protected function prepareForValidation(): void
{
$this->merge(['is_published' => $this->boolean('is_published')]);
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'quote_id' => ['required', 'string', 'max:2000'],
'quote_en' => ['required', 'string', 'max:2000'],
'role_id' => ['required', 'string', 'max:255'],
'role_en' => ['required', 'string', 'max:255'],
'organization_id' => ['required', 'string', 'max:255'],
'organization_en' => ['required', 'string', 'max:255'],
'is_published' => ['boolean'],
'sort_order' => ['required', 'integer', 'min:0'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Admin;
use App\Enums\ConsultationStatus;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateConsultationRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'status' => ['required', Rule::enum(ConsultationStatus::class)],
'admin_notes' => ['nullable', 'string', 'max:5000'],
];
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Requests\Admin;
use App\Concerns\PasswordValidationRules;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Create or update a panel user. On update the password is optional.
*/
class UserRequest extends FormRequest
{
use PasswordValidationRules;
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var User|null $user */
$user = $this->route('user');
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required', 'email', 'max:255',
Rule::unique(User::class)->ignore($user?->id),
],
'role' => ['required', Rule::enum(UserRole::class)],
'password' => $user
? ['nullable', ...$this->passwordRulesWithoutRequired()]
: $this->passwordRules(),
];
}
/**
* @return array<int, mixed>
*/
private function passwordRulesWithoutRequired(): array
{
return array_values(array_filter(
$this->passwordRules(),
fn ($rule) => $rule !== 'required',
));
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* A consultation request from one of the public site forms.
*/
class StoreConsultationRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'source' => ['required', 'in:home,services,contact'],
'name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'job_title' => ['nullable', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'phone' => ['required', 'string', 'max:50'],
'industry' => ['nullable', 'string', 'max:100'],
'service' => ['nullable', 'string', 'max:100'],
'timeline' => ['nullable', 'in:immediate,quarter,midterm,feasibility'],
'details' => ['nullable', 'string', 'max:5000'],
// The full contact form requires agreeing to an NDA first.
'nda_agreed' => ['accepted_if:source,contact'],
// Honeypot: hidden from people, so only bots fill it in.
'website' => ['prohibited'],
];
}
}