@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ConsultationStatus: string
|
||||
{
|
||||
case New = 'new';
|
||||
case Contacted = 'contacted';
|
||||
case Closed = 'closed';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/** Long-running programs highlighted as featured case studies on the site. */
|
||||
enum ProjectProgram: string
|
||||
{
|
||||
case PriceMonitoring = 'price-monitoring';
|
||||
case Investment = 'investment';
|
||||
case Erp = 'erp';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ProjectSector: string
|
||||
{
|
||||
case Government = 'government';
|
||||
case Private = 'private';
|
||||
case Soe = 'soe';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum UserRole: string
|
||||
{
|
||||
/** Manages users plus everything an editor can do. */
|
||||
case Admin = 'admin';
|
||||
|
||||
/** Manages site content and the consultation inbox. */
|
||||
case Editor = 'editor';
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ConsultationStatus;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* A consultation request submitted through one of the site's forms.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $source
|
||||
* @property string $name
|
||||
* @property string|null $company
|
||||
* @property string|null $job_title
|
||||
* @property string $email
|
||||
* @property string $phone
|
||||
* @property string|null $industry
|
||||
* @property string|null $service
|
||||
* @property string|null $timeline
|
||||
* @property string|null $details
|
||||
* @property bool $nda_agreed
|
||||
* @property string $locale
|
||||
* @property ConsultationStatus $status
|
||||
* @property string|null $admin_notes
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
#[Fillable([
|
||||
'source', 'name', 'company', 'job_title', 'email', 'phone', 'industry',
|
||||
'service', 'timeline', 'details', 'nda_agreed', 'locale', 'status', 'admin_notes',
|
||||
])]
|
||||
class Consultation extends Model
|
||||
{
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'nda_agreed' => 'boolean',
|
||||
'status' => ConsultationStatus::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\SiteInfo;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $icon
|
||||
* @property string $building
|
||||
* @property string $street
|
||||
* @property string $area
|
||||
* @property string $city
|
||||
* @property string $district
|
||||
* @property string $phone
|
||||
* @property string|null $maps_url
|
||||
* @property int $sort_order
|
||||
*/
|
||||
#[Fillable(['name', 'icon', 'building', 'street', 'area', 'city', 'district', 'phone', 'maps_url', 'sort_order'])]
|
||||
class Office extends Model
|
||||
{
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(fn () => SiteInfo::forget());
|
||||
static::deleted(fn () => SiteInfo::forget());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ProjectProgram;
|
||||
use App\Enums\ProjectSector;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* An entry in PAJ's IT project track record (the site's portfolio).
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $title
|
||||
* @property string $client
|
||||
* @property int $year
|
||||
* @property ProjectSector $sector
|
||||
* @property ProjectProgram|null $program
|
||||
* @property string|null $scope
|
||||
*/
|
||||
#[Fillable(['title', 'client', 'year', 'sector', 'program', 'scope'])]
|
||||
class Project extends Model
|
||||
{
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'year' => 'integer',
|
||||
'sector' => ProjectSector::class,
|
||||
'program' => ProjectProgram::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest projects first, as the site lists them.
|
||||
*
|
||||
* @param Builder<Project> $query
|
||||
*/
|
||||
public function scopeLatestFirst(Builder $query): void
|
||||
{
|
||||
$query->orderByDesc('year')->orderBy('id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\SiteInfo;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Key/value store for editable site settings (e.g. contact details).
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $key
|
||||
* @property array<string, mixed> $value
|
||||
*/
|
||||
#[Fillable(['key', 'value'])]
|
||||
class SiteSetting extends Model
|
||||
{
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(fn () => SiteInfo::forget());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'value' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $default
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function get(string $key, array $default = []): array
|
||||
{
|
||||
return static::query()->where('key', $key)->first()?->value ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $value
|
||||
*/
|
||||
public static function put(string $key, array $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* A client testimonial, written in both site languages.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $quote_id
|
||||
* @property string $quote_en
|
||||
* @property string $role_id
|
||||
* @property string $role_en
|
||||
* @property string $organization_id
|
||||
* @property string $organization_en
|
||||
* @property bool $is_published
|
||||
* @property int $sort_order
|
||||
*/
|
||||
#[Fillable([
|
||||
'quote_id', 'quote_en', 'role_id', 'role_en',
|
||||
'organization_id', 'organization_en', 'is_published', 'sort_order',
|
||||
])]
|
||||
class Testimonial extends Model
|
||||
{
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_published' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Testimonial> $query
|
||||
*/
|
||||
public function scopePublished(Builder $query): void
|
||||
{
|
||||
$query->where('is_published', true)->orderBy('sort_order')->orderBy('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The testimonial in the visitor's language.
|
||||
*
|
||||
* @return array{id: int, quote: string, role: string, organization: string}
|
||||
*/
|
||||
public function localized(string $locale): array
|
||||
{
|
||||
$suffix = $locale === 'en' ? 'en' : 'id';
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'quote' => $this->{"quote_{$suffix}"},
|
||||
'role' => $this->{"role_{$suffix}"},
|
||||
'organization' => $this->{"organization_{$suffix}"},
|
||||
];
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Enums\UserRole;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
@@ -18,6 +19,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property UserRole $role
|
||||
* @property Carbon|null $email_verified_at
|
||||
* @property string $password
|
||||
* @property string|null $two_factor_secret
|
||||
@@ -27,7 +29,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Fillable(['name', 'email', 'role', 'password'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable implements PasskeyUser
|
||||
{
|
||||
@@ -44,7 +46,16 @@ class User extends Authenticatable implements PasskeyUser
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'role' => UserRole::class,
|
||||
'two_factor_confirmed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Admins manage users on top of everything an editor can do.
|
||||
*/
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === UserRole::Admin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Consultation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
/**
|
||||
* Emails the business development inbox when a consultation request arrives.
|
||||
*/
|
||||
class NewConsultationRequest extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public Consultation $consultation) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$consultation = $this->consultation;
|
||||
|
||||
$message = (new MailMessage)
|
||||
->subject("Permintaan konsultasi baru: {$consultation->name}")
|
||||
->greeting('Permintaan konsultasi baru')
|
||||
->line("Nama: {$consultation->name}")
|
||||
->line("Email: {$consultation->email}")
|
||||
->line("Telepon: {$consultation->phone}");
|
||||
|
||||
foreach ([
|
||||
'Perusahaan' => $consultation->company,
|
||||
'Jabatan' => $consultation->job_title,
|
||||
'Sektor' => $consultation->industry,
|
||||
'Layanan' => $consultation->service,
|
||||
'Linimasa' => $consultation->timeline,
|
||||
'Kebutuhan' => $consultation->details,
|
||||
] as $label => $value) {
|
||||
if ($value) {
|
||||
$message->line("{$label}: {$value}");
|
||||
}
|
||||
}
|
||||
|
||||
return $message
|
||||
->replyTo($consultation->email, $consultation->name)
|
||||
->action('Buka Inbox Konsultasi', route('admin.consultations.index'));
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
@@ -24,6 +26,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureDefaults();
|
||||
|
||||
Gate::define('manage-users', fn (User $user): bool => $user->isAdmin());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,10 +67,6 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
'status' => $request->session()->get('status'),
|
||||
]));
|
||||
|
||||
Fortify::registerView(fn () => Inertia::render('auth/register', [
|
||||
'passwordRules' => Password::defaults()->toPasswordRulesString(),
|
||||
]));
|
||||
|
||||
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge'));
|
||||
|
||||
Fortify::confirmPasswordView(fn () => Inertia::render('auth/confirm-password'));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Office;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Editable company details every public page needs (header, footer, contact
|
||||
* sections). Cached, and cleared whenever offices or settings change.
|
||||
*/
|
||||
class SiteInfo
|
||||
{
|
||||
public const CONTACT_KEY = 'contact';
|
||||
|
||||
private const CACHE_KEY = 'site-info';
|
||||
|
||||
/**
|
||||
* Contact details used until an admin saves their own.
|
||||
*
|
||||
* @var array{phone: string, whatsapp: string, email_busdev: string, email_support: string}
|
||||
*/
|
||||
public const DEFAULT_CONTACT = [
|
||||
'phone' => '(021) 7266-3353',
|
||||
'whatsapp' => '0818-0218-9425',
|
||||
'email_busdev' => 'busdev@probindo.com',
|
||||
'email_support' => 'support@probindo.com',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{contact: array<string, string>, offices: list<array<string, mixed>>}
|
||||
*/
|
||||
public static function get(): array
|
||||
{
|
||||
return Cache::rememberForever(self::CACHE_KEY, fn () => [
|
||||
'contact' => [...self::DEFAULT_CONTACT, ...SiteSetting::get(self::CONTACT_KEY)],
|
||||
'offices' => Office::query()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get(['id', 'name', 'icon', 'building', 'street', 'area', 'city', 'district', 'phone', 'maps_url', 'sort_order'])
|
||||
->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function forget(): void
|
||||
{
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user