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
+4
View File
@@ -11,6 +11,10 @@ APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
ADMIN_NAME=Administrator
ADMIN_EMAIL=admin@probindo.com
ADMIN_PASSWORD=
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
+67 -10
View File
@@ -80,13 +80,33 @@ DB_PASSWORD=password_anda
Sesuaikan `DB_PORT`, `DB_USERNAME`, dan `DB_PASSWORD` dengan instalasi PostgreSQL di komputer Anda.
### 5. Jalankan migrasi
### 5. Atur akun admin pertama
```bash
php artisan migrate
Di `.env`, isi akun admin yang akan dibuat oleh seeder:
```env
ADMIN_NAME=Administrator
ADMIN_EMAIL=admin@probindo.com
ADMIN_PASSWORD=isi-password-yang-kuat
```
### 6. Jalankan aplikasi
Jika `ADMIN_PASSWORD` dikosongkan, seeder membuat password acak dan menampilkannya **sekali** di terminal, jadi segera simpan.
### 6. Jalankan migrasi & isi data awal
```bash
php artisan migrate --seed
```
Seeder membuat:
- akun **Admin** dari `ADMIN_*` di atas (langsung bisa login di **/login**, atau lewat tautan "Admin Panel" di footer website),
- 61 proyek portofolio, 2 kantor, dan kontak perusahaan.
Seeder aman dijalankan ulang: akun admin yang sudah ada tidak diubah (password yang sudah diganti tidak di-reset), dan data konten hanya diisi jika tabelnya masih kosong.
Akun berikutnya ditambahkan dari menu **Pengguna** di admin panel, atau lewat terminal dengan `php artisan admin:create`.
### 7. Jalankan aplikasi
Cara termudah, satu perintah untuk menjalankan server Laravel, queue worker, dan Vite sekaligus:
@@ -124,21 +144,52 @@ Route didefinisikan di `routes/web.php`.
```
resources/js/
├── data/site.ts # Info perusahaan, 2 lokasi kantor, 8 jasa, opsi form
├── data/ # Konstanta perusahaan & helper hitung portofolio
├── layouts/site-layout.tsx # Kerangka halaman: header + konten + footer
├── components/site/ # Komponen UI yang dipakai ulang
│ ├── section.tsx, container.tsx, section-header.tsx, page-hero.tsx
│ ├── button.tsx, card.tsx, feature-card.tsx, step-card.tsx, ...
│ ├── site-header.tsx, site-footer.tsx, theme-toggle.tsx
│ └── sections/ # Section bersama (form konsultasi, grid jasa, CTA)
└── pages/site/ # Halaman, tinggal menyusun section + data
├── pages/site/ # Halaman website, tinggal menyusun section + data
└── pages/admin/ # Halaman admin panel (CMS)
```
- **Ubah kontak atau alamat kantor** cukup di `resources/js/data/site.ts`, otomatis berlaku di semua halaman.
- **Kontak, kantor, portofolio, dan testimoni** disimpan di database dan dikelola dari admin panel (lihat bagian Admin Panel). Jumlah proyek dan klien di website dihitung otomatis dari tabel `projects`.
- **Teks website (Bahasa Indonesia & Inggris)** ada di `resources/js/content/`: satu file per halaman (`home.ts`, `about.ts`, ...) plus `common.ts` untuk navbar, footer, form, dan 8 jasa. Setiap file berisi objek `id` dan `en`; TypeScript akan error bila versi `en` tidak lengkap. Gunakan `**teks**` untuk penekanan dan `==teks==` untuk highlight merah di judul.
- **Bahasa** dipilih lewat tombol ID/EN di navbar dan disimpan di cookie `locale` (default `id`), dibaca oleh middleware `app/Http/Middleware/HandleLocale.php`.
- **Warna tema (light & dark)** ada di `resources/css/app.css`, pada blok `.probindo-site` (light) dan `.dark .probindo-site` (dark).
## Admin Panel (CMS)
Login di `/login`, lalu kelola konten dari sidebar:
| Menu | Fungsi | Tabel |
|---|---|---|
| Inbox Konsultasi | Permintaan dari form konsultasi di Beranda, Jasa, dan Kontak. Status: Baru, Dihubungi, Selesai, plus catatan internal | `consultations` |
| Portofolio | Tambah, ubah, dan hapus proyek. Angka "proyek" dan "klien" di website ikut berubah | `projects` |
| Info Perusahaan | Telepon, WhatsApp, email, dan alamat kantor | `site_settings`, `offices` |
| Testimoni | Testimoni dua bahasa. Bagian testimoni di halaman Klien hanya tampil jika ada yang dipublikasikan | `testimonials` |
| Pengguna | Kelola akun admin panel (hanya Admin) | `users` |
**Peran:** *Admin* bisa mengakses semua menu, termasuk Pengguna. *Editor* bisa mengakses konten dan inbox konsultasi.
### Notifikasi email konsultasi
Setiap permintaan konsultasi baru dikirim ke email busdev (diatur di Info Perusahaan). Email dikirim lewat **queue**, jadi queue worker harus berjalan. `composer dev` sudah menjalankannya; di server jalankan `php artisan queue:work` (misalnya lewat Supervisor).
Secara default `MAIL_MAILER=log`, sehingga email hanya ditulis ke `storage/logs/laravel.log`. Untuk benar-benar mengirim email, isi SMTP di `.env`:
```env
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=user@probindo.com
MAIL_PASSWORD=********
MAIL_FROM_ADDRESS="no-reply@probindo.com"
MAIL_FROM_NAME="Probindo Website"
```
## Perintah yang Sering Dipakai
| Perintah | Fungsi |
@@ -157,7 +208,11 @@ resources/js/
File `public/hot` tertinggal dari sesi `npm run dev` sebelumnya. Jalankan lagi `npm run dev`, atau hapus `public/hot` lalu `npm run build`.
**Error `could not find driver` saat test**
Ekstensi `pdo_sqlite` belum aktif. Buka `php.ini`, aktifkan `extension=pdo_sqlite`, lalu restart terminal.
Ekstensi `pdo_sqlite` belum aktif. Buka `php.ini`, aktifkan `extension=pdo_sqlite`, lalu restart terminal. Alternatifnya, jalankan test di database PostgreSQL terpisah (jangan pakai database utama, karena test mengosongkan tabel):
```bash
DB_CONNECTION=pgsql DB_DATABASE=probindo_test php artisan test
```
**Error koneksi database saat migrate**
Pastikan PostgreSQL berjalan, database sudah dibuat, dan `DB_*` di `.env` sudah benar. Setelah mengubah `.env`, jalankan `php artisan config:clear`.
@@ -165,6 +220,8 @@ Pastikan PostgreSQL berjalan, database sudah dibuat, dan `DB_*` di `.env` sudah
**Import `@/routes` error di editor**
File route TypeScript di-generate otomatis. Jalankan `php artisan wayfinder:generate` atau `npm run dev`.
## Catatan
**Perubahan kontak/kantor tidak muncul di website**
Info perusahaan di-cache dan otomatis diperbarui saat disimpan dari admin panel. Jika data diubah langsung di database, jalankan `php artisan cache:forget site-info`.
Form konsultasi di halaman Beranda, Jasa, dan Kontak **belum terhubung ke backend**: saat ini hanya menampilkan pesan sukses di browser tanpa menyimpan atau mengirim data.
**Email konsultasi tidak terkirim**
Pastikan queue worker berjalan dan SMTP sudah diisi (lihat Notifikasi email konsultasi). Job yang gagal bisa dilihat dengan `php artisan queue:failed`.
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum ConsultationStatus: string
{
case New = 'new';
case Contacted = 'contacted';
case Closed = 'closed';
}
+11
View File
@@ -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';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum ProjectSector: string
{
case Government = 'government';
case Private = 'private';
case Soe = 'soe';
}
+12
View File
@@ -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'],
];
}
}
+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'],
];
}
}
+47
View File
@@ -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,
];
}
}
+40
View File
@@ -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',
];
}
}
+46
View File
@@ -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');
}
}
+50
View File
@@ -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]);
}
}
+63
View File
@@ -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
View File
@@ -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'));
}
}
+4
View File
@@ -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());
}
/**
-4
View File
@@ -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'));
+50
View File
@@ -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);
}
}
+17
View File
@@ -123,4 +123,21 @@ return [
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
/*
|--------------------------------------------------------------------------
| Initial Admin Account
|--------------------------------------------------------------------------
|
| Used by AdminUserSeeder (`php artisan migrate --seed`) to create the
| first admin panel account. Leave the password empty to have a random
| one generated and printed once during seeding.
|
*/
'admin' => [
'name' => env('ADMIN_NAME', 'Administrator'),
'email' => env('ADMIN_EMAIL', 'admin@probindo.com'),
'password' => env('ADMIN_PASSWORD'),
],
];
-1
View File
@@ -161,7 +161,6 @@ return [
*/
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
Features::twoFactorAuthentication([
+12
View File
@@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
@@ -27,6 +28,7 @@ class UserFactory extends Factory
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'role' => UserRole::Editor,
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
@@ -36,6 +38,16 @@ class UserFactory extends Factory
];
}
/**
* Indicate that the user is an admin (can also manage users).
*/
public function admin(): static
{
return $this->state(fn (array $attributes) => [
'role' => UserRole::Admin,
]);
}
/**
* Indicate that the model's email address should be unverified.
*/
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('role')->default('editor')->after('email');
});
// Accounts created before roles existed were made with `admin:create`.
DB::table('users')->update(['role' => 'admin']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('consultations', function (Blueprint $table) {
$table->id();
// Which site form the request came from: home, services or contact.
$table->string('source');
$table->string('name');
$table->string('company')->nullable();
$table->string('job_title')->nullable();
$table->string('email');
$table->string('phone');
$table->string('industry')->nullable();
$table->string('service')->nullable();
$table->string('timeline')->nullable();
$table->text('details')->nullable();
$table->boolean('nda_agreed')->default(false);
$table->string('locale', 2)->default('id');
$table->string('status')->default('new')->index();
$table->text('admin_notes')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('consultations');
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('client');
$table->unsignedSmallInteger('year')->index();
$table->string('sector')->index();
$table->string('program')->nullable();
$table->string('scope')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('projects');
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('offices', function (Blueprint $table) {
$table->id();
$table->string('name');
// Material Symbols icon name shown next to the office.
$table->string('icon')->default('apartment');
$table->string('building');
$table->string('street');
$table->string('area');
$table->string('city');
// Short location used in chips, e.g. "Bintaro, Jakarta Selatan".
$table->string('district');
$table->string('phone');
$table->string('maps_url')->nullable();
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('offices');
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('testimonials', function (Blueprint $table) {
$table->id();
// Text shown to visitors is stored in both site languages.
$table->text('quote_id');
$table->text('quote_en');
$table->string('role_id');
$table->string('role_en');
$table->string('organization_id');
$table->string('organization_en');
$table->boolean('is_published')->default(true);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('testimonials');
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('site_settings', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->json('value');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('site_settings');
}
};
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Database\Seeders;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
/**
* Creates the first admin panel account from the ADMIN_* settings in .env.
* An existing account is left untouched, so re-seeding never resets a
* password that was changed from the panel.
*/
class AdminUserSeeder extends Seeder
{
public function run(): void
{
$email = config('app.admin.email');
if (User::query()->where('email', $email)->exists()) {
$this->command?->info("Admin {$email} sudah ada, dilewati.");
return;
}
$password = config('app.admin.password') ?: Str::password(16, symbols: false);
$user = User::query()->create([
'name' => config('app.admin.name'),
'email' => $email,
'role' => UserRole::Admin,
'password' => $password,
]);
$user->forceFill(['email_verified_at' => now()])->save();
if (config('app.admin.password')) {
$this->command?->info("Admin {$email} dibuat dengan ADMIN_PASSWORD dari .env.");
} else {
$this->command?->warn("Admin {$email} dibuat. Password (simpan sekarang, hanya ditampilkan sekali): {$password}");
}
}
}
+6 -6
View File
@@ -2,7 +2,6 @@
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
@@ -12,14 +11,15 @@ class DatabaseSeeder extends Seeder
/**
* Seed the application's database.
*
* The first admin comes from ADMIN_* in .env; more accounts can be
* added from the admin panel or with `php artisan admin:create`.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
$this->call([
AdminUserSeeder::class,
SiteContentSeeder::class,
]);
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace Database\Seeders;
use App\Models\Office;
use App\Models\Project;
use App\Models\SiteSetting;
use App\Support\SiteInfo;
use Illuminate\Database\Seeder;
/**
* Initial website content: the project portfolio, office locations and
* contact details. Safe to re-run: each part is only seeded when empty.
*/
class SiteContentSeeder extends Seeder
{
public function run(): void
{
if (Project::query()->doesntExist()) {
foreach ($this->projects() as $project) {
Project::query()->create($project);
}
}
if (Office::query()->doesntExist()) {
foreach ($this->offices() as $index => $office) {
Office::query()->create([...$office, 'sort_order' => $index]);
}
}
if (SiteSetting::query()->where('key', SiteInfo::CONTACT_KEY)->doesntExist()) {
SiteSetting::put(SiteInfo::CONTACT_KEY, SiteInfo::DEFAULT_CONTACT);
}
}
/**
* @return list<array<string, mixed>>
*/
private function offices(): array
{
return [
[
'name' => 'PAJ Software Engineering Center',
'icon' => 'code_blocks',
'building' => 'Anindhaloka',
'street' => 'Jl. YRS No.20, RW.1',
'area' => 'Bintaro, Kec. Pesanggrahan',
'city' => 'Kota Jakarta Selatan 12330',
'district' => 'Bintaro, Jakarta Selatan',
'phone' => '(021) 7266-3353',
'maps_url' => 'https://maps.google.com/?q=Jl.+YRS+No.20+Bintaro+Pesanggrahan+Jakarta+Selatan',
],
[
'name' => 'PAJ Business Development Center',
'icon' => 'apartment',
'building' => 'Plaza Simatupang Lt. 6 Unit 3',
'street' => 'Jl. TB. Simatupang Kav. IS No. 01',
'area' => 'Pondok Pinang, Kebayoran Lama',
'city' => 'Jakarta Selatan 12310',
'district' => 'Plaza Simatupang, Jakarta Selatan',
'phone' => '+62 21 5789 5927',
'maps_url' => 'https://maps.google.com/?q=Plaza+Simatupang+Jakarta',
],
];
}
/**
* @return list<array<string, mixed>>
*/
private function projects(): array
{
return [
['title' => 'Penyusunan dan Pembuatan SOP Direktorat Data dan Informasi', 'client' => 'Kementerian Investasi / Badan Koordinasi Penanaman Modal (BKPM)', 'year' => 2025, 'sector' => 'government', 'program' => 'investment', 'scope' => null],
['title' => 'Perencanaan Peta Jalan Direktorat Data dan Informasi TA 2024', 'client' => 'Kementerian Investasi / Badan Koordinasi Penanaman Modal (BKPM)', 'year' => 2024, 'sector' => 'government', 'program' => 'investment', 'scope' => null],
['title' => 'Modul Pelayanan Metrologi Legal Submodul Verifikasi (Sistem Informasi Ketertelusuran Standar (SIKS))', 'client' => 'Kementerian Perdagangan', 'year' => 2024, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Penyediaan Data Harga dan Pasokan Cabe dan Bawang di Pasar Induk Wilayah Barat', 'client' => 'Kementerian Perdagangan', 'year' => 2024, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pemeliharaan Sistem Pelaporan Harga (SP2KP), Pusat Data dan Sistem Informasi', 'client' => 'Kementerian Perdagangan', 'year' => 2023, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Penyediaan Data Harga dan Pasokan Cabe dan Bawang di Pasar Induk Wilayah Barat', 'client' => 'Kementerian Perdagangan', 'year' => 2023, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pembangunan Aplikasi Sertifikasi Prasarana Perkeretaapian Dengan Memanfaatkan Teknologi Informasi', 'client' => 'Kementerian Perhubungan', 'year' => 2022, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Pengembangan Aplikasi LDH', 'client' => 'Badan Informasi Geospasial (BIG)', 'year' => 2022, 'sector' => 'government', 'program' => 'erp', 'scope' => 'OpenERP Community: manajemen dokumen dan custom development'],
['title' => 'Perawatan Sistem Pelaporan Harga', 'client' => 'Kementerian Perdagangan', 'year' => 2022, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pengembangan Aplikasi Sistem Informasi Ketertelusuran Standar', 'client' => 'Kementerian Perdagangan', 'year' => 2021, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Pengembangan Modul Master Data Aplikasi SIREBEKA (Sistem Informasi Remunerasi Berbasis Kinerja)', 'client' => 'Kementerian Perhubungan', 'year' => 2021, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Pengelolaan Sistem Pelaporan Harga', 'client' => 'Kementerian Perdagangan', 'year' => 2020, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pengadaan Metrologi Aplikasi Mobile: Pendataan dan Pemetaan Kondisi UTTP dan BDKT dalam Kerangka Pengawasan Kemetrologian', 'client' => 'Kementerian Perdagangan', 'year' => 2020, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Penyediaan Data Harga dan Pasokan Cabe dan Bawang di Pasar Induk Wilayah Barat', 'client' => 'Kementerian Perdagangan', 'year' => 2020, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Penyediaan Data Harga dan Pasokan Cabe dan Bawang di Pasar Induk Wilayah Timur', 'client' => 'Kementerian Perdagangan', 'year' => 2020, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Perawatan Sistem Pelaporan Harga', 'client' => 'Kementerian Perdagangan', 'year' => 2020, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Perancangan Antar Muka Sistem Informasi Ketertelusuran Standar', 'client' => 'Kementerian Perdagangan', 'year' => 2019, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Pengembangan dan Pemeliharaan Portal NSWI TA 2019', 'client' => 'Badan Koordinasi Penanaman Modal (BKPM)', 'year' => 2019, 'sector' => 'government', 'program' => 'investment', 'scope' => null],
['title' => 'Pengembangan Aplikasi Website Intranet', 'client' => 'Kementerian Perdagangan', 'year' => 2018, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Pemantauan Harga Bapok 34 Ibukota Provinsi pada Sabtu atau Minggu, Libur Nasional dan HBKN, Pasar Kab/Kota, Daerah TTT dan Pasar Ritel Modern Tahun 2018', 'client' => 'Kementerian Perdagangan', 'year' => 2018, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pengembangan Business Intelligence (BI) TA 2018', 'client' => 'Badan Koordinasi Penanaman Modal (BKPM)', 'year' => 2018, 'sector' => 'government', 'program' => 'investment', 'scope' => null],
['title' => 'Pengembangan Dashboard Produk Wisata MICE', 'client' => 'Kementerian Pariwisata', 'year' => 2017, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Pembuatan Sistem Informasi Manajemen Harga dan Pasokan', 'client' => 'Kementerian Perdagangan', 'year' => 2017, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Penerapan Model Pembangunan dan Revitalisasi Pasar Rakyat Dengan Pendekatan Teknologi Informasi Berbasis Mobile', 'client' => 'Kementerian Perdagangan', 'year' => 2017, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Penyusunan Pemutakhiran (Updating) Cetak Biru Pengembangan Sistem Pelayanan Informasi dan Perizinan', 'client' => 'Badan Koordinasi Penanaman Modal (BKPM)', 'year' => 2017, 'sector' => 'government', 'program' => 'investment', 'scope' => null],
['title' => 'Pemantauan Stok dan Pasokan Barang Kebutuhan Pokok 100 Titik di Pasar Rakyat (Tradisional), Pasar Induk dan Sentra Produksi', 'client' => 'Kementerian Perdagangan', 'year' => 2017, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Verifikasi Pelaporan Harga Barang Pokok pada SP2KP', 'client' => 'Kementerian Perdagangan', 'year' => 2016, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pengembangan Program Aplikasi Harga Kabupaten Kota', 'client' => 'Kementerian Perdagangan', 'year' => 2016, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Updating Program Penilaian Pasar', 'client' => 'Kementerian Perdagangan', 'year' => 2016, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Pengembangan Sistem Aplikasi Jaringan Informasi Dokumentasi Hukum', 'client' => 'Badan Informasi Geospasial (BIG)', 'year' => 2016, 'sector' => 'government', 'program' => 'erp', 'scope' => 'OpenERP Community: manajemen dokumen dan custom development'],
['title' => 'Verifikasi Pelaporan Harga Barang Pokok pada Sistem Pemantauan Pasar Kebutuhan Pokok (SP2KP)', 'client' => 'Kementerian Perdagangan', 'year' => 2016, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pekerjaan Pengembangan Program Aplikasi Harga Kabupaten Kota', 'client' => 'Kementerian Perdagangan', 'year' => 2016, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pembuatan Software pada Kegiatan Penguatan Regional Climate Change Center', 'client' => 'Badan Meteorologi Klimatologi dan Geofisika (BMKG)', 'year' => 2015, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Kajian Tata Kelola Teknologi Informasi BKPM TA 2014', 'client' => 'Badan Koordinasi Penanaman Modal (BKPM)', 'year' => 2014, 'sector' => 'government', 'program' => 'investment', 'scope' => null],
['title' => 'Sistem Informasi Monitoring Updating Harga dan Pasokan Bawang Merah', 'client' => 'Kementerian Perdagangan', 'year' => 2014, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pemetaan Indikasi dan Variabel Penentu Kenaikan Harga Bahan Pokok (Komoditas Cabe Merah, Bawang Merah dan Beras)', 'client' => 'Kementerian Perdagangan', 'year' => 2014, 'sector' => 'government', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pembangunan Sistem Informasi Pelayanan Dokumen Hukum', 'client' => 'Badan Informasi Geospasial (BIG)', 'year' => 2014, 'sector' => 'government', 'program' => 'erp', 'scope' => 'OpenERP Community: manajemen dokumen dan custom development'],
['title' => 'Pendampingan Helpdesk Implementasi Sistem Pelayanan Informasi dan Perizinan Investasi Secara Elektronik (SPIPISE)', 'client' => 'Badan Koordinasi Penanaman Modal (BKPM)', 'year' => 2013, 'sector' => 'government', 'program' => 'investment', 'scope' => null],
['title' => 'Pengadaan Jasa Konsultasi Pekerjaan Pembangunan Sub Sistem Bidang Ketahanan Lembaga Usaha Ekonomi', 'client' => 'Kementerian Perdagangan', 'year' => 2012, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Pendampingan Helpdesk Implementasi Sistem Pelayanan Informasi dan Perizinan Investasi Secara Elektronik (SPIPISE)', 'client' => 'Badan Koordinasi Penanaman Modal (BKPM)', 'year' => 2012, 'sector' => 'government', 'program' => 'investment', 'scope' => null],
['title' => 'Pembangunan Aplikasi Jaringan Dokumentasi dan Informasi Hukum', 'client' => 'Badan Informasi Geospasial (BIG)', 'year' => 2012, 'sector' => 'government', 'program' => 'erp', 'scope' => 'OpenERP Community: manajemen dokumen dan custom development'],
['title' => 'Pengembangan Portal National Single Window for Investment (NSWI) untuk Mendukung SPIPISE BKPM', 'client' => 'Badan Koordinasi Penanaman Modal (BKPM)', 'year' => 2010, 'sector' => 'government', 'program' => 'investment', 'scope' => null],
['title' => 'Pembuatan Database Eksportir Importir, Perusahaan, Asosiasi dan Pelaku Industri Kreatif serta Lembaga Pendidikan Formal/Non Formal', 'client' => 'Departemen Perdagangan Republik Indonesia', 'year' => 2009, 'sector' => 'government', 'program' => null, 'scope' => null],
['title' => 'Pembangunan Sistem Informasi Tracking Promosi Investasi Tahun Anggaran 2006', 'client' => 'Badan Koordinasi Penanaman Modal (BKPM)', 'year' => 2006, 'sector' => 'government', 'program' => 'investment', 'scope' => null],
['title' => 'Implementasi Odoo ERP', 'client' => 'Marawah Beach Club', 'year' => 2022, 'sector' => 'private', 'program' => 'erp', 'scope' => 'Odoo Enterprise: POS, event, pembelian, penjualan, website, dan custom development'],
['title' => 'Enhancement Manajemen Sistem', 'client' => 'PT Cipta Kridatama', 'year' => 2022, 'sector' => 'private', 'program' => null, 'scope' => null],
['title' => 'Implementasi OpenERP Manufacturing System', 'client' => 'CV Mujur Jaya Abadi', 'year' => 2015, 'sector' => 'private', 'program' => 'erp', 'scope' => 'OpenERP Community: akunting, manufaktur, pembelian, penjualan, inventory, dan custom development'],
['title' => 'Pengembangan Reswara Integrated Management System', 'client' => 'PT Reswara Minergi Hartama', 'year' => 2013, 'sector' => 'private', 'program' => null, 'scope' => null],
['title' => 'Kegiatan Pengembangan dan Operasionalisasi Sistem Pemantauan Pasar Kebutuhan Pokok (SP2KP)', 'client' => 'PT LAPI ITB (Bapokstra)', 'year' => 2013, 'sector' => 'private', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pengembangan Aplikasi GIS Pelaporan Online Harga', 'client' => 'PT DKN', 'year' => 2011, 'sector' => 'private', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pengembangan Portal Ekonomi Kreatif Indonesia (www.indonesiakreatif.net)', 'client' => 'CV Multi Sarana Putra', 'year' => 2010, 'sector' => 'private', 'program' => null, 'scope' => null],
['title' => 'Pengembangan Cetak Biru Teknologi Informasi Pusat Pengendalian Online Harga dan Stok', 'client' => 'PT Pranala Nitisala', 'year' => 2010, 'sector' => 'private', 'program' => 'price-monitoring', 'scope' => null],
['title' => 'Pembuatan Sistem Informasi Monitoring Penerimaan dan Pengeluaran BBM di Setiap Fasilitas Rantai Pasok BBM', 'client' => 'KSO LAPI – Pranala', 'year' => 2008, 'sector' => 'private', 'program' => null, 'scope' => null],
['title' => 'Validasi dan Koreksi Data Akunting', 'client' => 'PT Lautan Berlian Utama Motor', 'year' => 2008, 'sector' => 'private', 'program' => null, 'scope' => null],
['title' => 'Pengembangan, Pemutakhiran dan Pemeliharaan Website Tahun Anggaran 2007', 'client' => 'CV Multi Sarana Putra', 'year' => 2007, 'sector' => 'private', 'program' => null, 'scope' => null],
['title' => 'Pembuatan Compact Disc (CD) Profil Wilayah, Sektor dan Komoditi', 'client' => 'PT Qualita Prima Pariwara', 'year' => 2007, 'sector' => 'private', 'program' => null, 'scope' => null],
['title' => 'Pengembangan Aplikasi Titis Sampurna Accounting Information System', 'client' => 'PT Titis Sampurna', 'year' => 2006, 'sector' => 'private', 'program' => 'erp', 'scope' => null],
['title' => 'Jasa Konsultasi Pengembangan Sistem Informasi Eksekutif Keuangan', 'client' => 'CV Multi Sarana Putra', 'year' => 2006, 'sector' => 'private', 'program' => null, 'scope' => null],
['title' => 'Desain Sistem Informasi Museum Bank Indonesia', 'client' => 'PT Global Media Inline', 'year' => 2006, 'sector' => 'private', 'program' => null, 'scope' => null],
['title' => 'Pembangunan Sistem Aplikasi', 'client' => 'PT Sucofindo (Persero)', 'year' => 2015, 'sector' => 'soe', 'program' => null, 'scope' => null],
['title' => 'Jasa Pemeliharaan Sistem Merpati Operation System Infrastructure (MOSI), Mobile Flight Log (MFL), Schedule Messaging System (SMS)', 'client' => 'PT Merpati Nusantara Airlines', 'year' => 2006, 'sector' => 'soe', 'program' => null, 'scope' => null],
];
}
}
View File
+68 -68
View File
@@ -9,9 +9,8 @@
@theme {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
'Manrope', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
@@ -188,74 +187,75 @@ html.dark:has(.probindo-site) {
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.87 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.87 0 0);
/* Admin panel (shadcn) tokens, mapped to the Probindo palette. */
--background: #f6f7f9;
--foreground: #0f172a;
--card: #ffffff;
--card-foreground: #0f172a;
--popover: #ffffff;
--popover-foreground: #0f172a;
--primary: #9e1b22;
--primary-foreground: #ffffff;
--secondary: #f2f4f7;
--secondary-foreground: #0f172a;
--muted: #f2f4f7;
--muted-foreground: #4b5563;
--accent: #fbeced;
--accent-foreground: #9e1b22;
--destructive: #dc2626;
--destructive-foreground: #dc2626;
--border: #ebe1e2;
--input: #e2d6d7;
--ring: #c81e2a;
--chart-1: #9e1b22;
--chart-2: #c81e2a;
--chart-3: #fb7185;
--chart-4: #64748b;
--chart-5: #f59e0b;
--radius: 0.75rem;
--sidebar: #ffffff;
--sidebar-foreground: #0f172a;
--sidebar-primary: #9e1b22;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #fbeced;
--sidebar-accent-foreground: #9e1b22;
--sidebar-border: #ebe1e2;
--sidebar-ring: #c81e2a;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.985 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
--background: #0b0f17;
--foreground: #f1f3f9;
--card: #131824;
--card-foreground: #f1f3f9;
--popover: #131824;
--popover-foreground: #f1f3f9;
--primary: #b81f28;
--primary-foreground: #ffffff;
--secondary: #1f273b;
--secondary-foreground: #f1f3f9;
--muted: #171e2e;
--muted-foreground: #b5bece;
--accent: #2a1a22;
--accent-foreground: #fda4af;
--destructive: #7f1d1d;
--destructive-foreground: #fca5a5;
--border: #262d3d;
--input: #2e3648;
--ring: #ff4d58;
--chart-1: #ff4d58;
--chart-2: #fda4af;
--chart-3: #b81f28;
--chart-4: #94a3b8;
--chart-5: #fbbf24;
--sidebar: #0d121c;
--sidebar-foreground: #f1f3f9;
--sidebar-primary: #b81f28;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #1f273b;
--sidebar-accent-foreground: #f1f3f9;
--sidebar-border: #1f273b;
--sidebar-ring: #ff4d58;
}
@layer base {
+3
View File
@@ -15,6 +15,9 @@ void createInertiaApp({
switch (true) {
case name.startsWith('site/'):
return SiteLayout;
// The admin login is a branded full-page screen of its own.
case name === 'auth/login':
return null;
case name.startsWith('auth/'):
return AuthLayout;
case name.startsWith('settings/'):
@@ -0,0 +1,23 @@
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAppearance } from '@/hooks/use-appearance';
/** Light/dark switch for the admin panel header. */
export function AdminThemeToggle() {
const { resolvedAppearance, updateAppearance } = useAppearance();
const isDark = resolvedAppearance === 'dark';
const label = isDark ? 'Ganti ke mode terang' : 'Ganti ke mode gelap';
return (
<Button
variant="outline"
size="icon"
aria-label={label}
title={label}
onClick={() => updateAppearance(isDark ? 'light' : 'dark')}
className="size-9 cursor-pointer"
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
);
}
@@ -0,0 +1,216 @@
import { Form } from '@inertiajs/react';
import type { ComponentProps, ReactNode } from 'react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import type { RouteFormDefinition } from '@/wayfinder';
const controlClasses =
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs outline-none focus-visible:ring-[3px] disabled:opacity-50 md:text-sm';
export function PageHeader({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-1">
<h1 className="font-display text-xl font-bold tracking-tight">
{title}
</h1>
{description && (
<p className="text-muted-foreground text-sm">
{description}
</p>
)}
</div>
{action}
</div>
);
}
export function FormField({
id,
label,
error,
className,
children,
}: {
id: string;
label: string;
error?: string;
className?: string;
children: ReactNode;
}) {
return (
<div className={cn('grid gap-2', className)}>
<Label htmlFor={id}>{label}</Label>
{children}
<InputError message={error} />
</div>
);
}
export function NativeSelect({
options,
className,
...props
}: ComponentProps<'select'> & {
options: ReadonlyArray<{ value: string; label: string }>;
}) {
return (
<select
className={cn(controlClasses, 'h-9 cursor-pointer py-1', className)}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
export function Textarea({ className, ...props }: ComponentProps<'textarea'>) {
return (
<textarea
rows={4}
className={cn(controlClasses, className)}
{...props}
/>
);
}
type Errors = Partial<Record<string, string>>;
/**
* A dialog holding a create/edit form; it closes itself once the server
* accepts the submission.
*/
export function FormDialog({
trigger,
title,
description,
form,
submitLabel = 'Simpan',
className,
children,
}: {
trigger: ReactNode;
title: string;
description?: string;
form: RouteFormDefinition<'get' | 'post'>;
submitLabel?: string;
className?: string;
children: (errors: Errors) => ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent
className={cn(
'max-h-[90vh] overflow-y-auto sm:max-w-lg',
className,
)}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && (
<DialogDescription>{description}</DialogDescription>
)}
</DialogHeader>
<Form
{...form}
options={{ preserveScroll: true }}
onSuccess={() => setOpen(false)}
className="flex flex-col gap-4"
>
{({ processing, errors }) => (
<>
{children(errors)}
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button type="button" variant="secondary">
Batal
</Button>
</DialogClose>
<Button type="submit" disabled={processing}>
{submitLabel}
</Button>
</DialogFooter>
</>
)}
</Form>
</DialogContent>
</Dialog>
);
}
/** Asks for confirmation before sending a delete request. */
export function ConfirmDelete({
trigger,
title,
description,
form,
}: {
trigger: ReactNode;
title: string;
description: string;
form: RouteFormDefinition<'get' | 'post'>;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Form
{...form}
options={{ preserveScroll: true }}
onSuccess={() => setOpen(false)}
>
{({ processing }) => (
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button type="button" variant="secondary">
Batal
</Button>
</DialogClose>
<Button
type="submit"
variant="destructive"
disabled={processing}
>
Hapus
</Button>
</DialogFooter>
)}
</Form>
</DialogContent>
</Dialog>
);
}
+13 -9
View File
@@ -1,18 +1,22 @@
import { usePage } from '@inertiajs/react';
import AppLogoIcon from '@/components/app-logo-icon';
import { company } from '@/data/site';
/** PAJ logo mark with the admin panel name, used in the sidebar header. */
export default function AppLogo() {
const { name } = usePage().props;
return (
<>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md">
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
<div className="border-sidebar-border flex aspect-square size-8 items-center justify-center rounded-md border bg-white p-1">
<img
src={company.logo}
alt={company.name}
className="h-full w-full object-contain"
/>
</div>
<div className="ml-1 grid flex-1 text-left text-sm">
<span className="mb-0.5 truncate leading-tight font-semibold">
{name}
<span className="font-display truncate leading-tight font-bold tracking-wide uppercase">
{company.brand}
</span>
<span className="text-sidebar-foreground/60 truncate font-mono text-[11px]">
Admin Panel
</span>
</div>
</>
@@ -1,3 +1,4 @@
import { AdminThemeToggle } from '@/components/admin-theme-toggle';
import { Breadcrumbs } from '@/components/breadcrumbs';
import { SidebarTrigger } from '@/components/ui/sidebar';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
@@ -13,6 +14,9 @@ export function AppSidebarHeader({
<SidebarTrigger className="-ml-1" />
<Breadcrumbs breadcrumbs={breadcrumbs} />
</div>
<div className="ml-auto">
<AdminThemeToggle />
</div>
</header>
);
}
+53 -12
View File
@@ -1,5 +1,13 @@
import { Link } from '@inertiajs/react';
import { BookOpen, FolderGit2, LayoutGrid } from 'lucide-react';
import { Link, usePage } from '@inertiajs/react';
import {
Building2,
FolderKanban,
Globe,
Inbox,
LayoutGrid,
MessageSquareQuote,
Users,
} from 'lucide-react';
import AppLogo from '@/components/app-logo';
import { NavFooter } from '@/components/nav-footer';
import { NavMain } from '@/components/nav-main';
@@ -13,7 +21,12 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import { dashboard, home } from '@/routes';
import { edit as companyEdit } from '@/routes/admin/company';
import { index as consultationsIndex } from '@/routes/admin/consultations';
import { index as projectsIndex } from '@/routes/admin/projects';
import { index as testimonialsIndex } from '@/routes/admin/testimonials';
import { index as usersIndex } from '@/routes/admin/users';
import type { NavItem } from '@/types';
const mainNavItems: NavItem[] = [
@@ -22,22 +35,50 @@ const mainNavItems: NavItem[] = [
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Inbox Konsultasi',
href: consultationsIndex(),
icon: Inbox,
},
{
title: 'Portofolio',
href: projectsIndex(),
icon: FolderKanban,
},
{
title: 'Info Perusahaan',
href: companyEdit(),
icon: Building2,
},
{
title: 'Testimoni',
href: testimonialsIndex(),
icon: MessageSquareQuote,
},
];
const adminNavItems: NavItem[] = [
{
title: 'Pengguna',
href: usersIndex(),
icon: Users,
},
];
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/react-starter-kit',
icon: FolderGit2,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#react',
icon: BookOpen,
title: 'Lihat Website',
href: home(),
icon: Globe,
},
];
export function AppSidebar() {
const { can } = usePage().props;
const navItems = can.manageUsers
? [...mainNavItems, ...adminNavItems]
: mainNavItems;
return (
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
@@ -53,7 +94,7 @@ export function AppSidebar() {
</SidebarHeader>
<SidebarContent>
<NavMain items={mainNavItems} />
<NavMain items={navItems} />
</SidebarContent>
<SidebarFooter>
+2 -2
View File
@@ -12,8 +12,8 @@ export default function Heading({
<h2
className={
variant === 'small'
? 'mb-0.5 text-base font-medium'
: 'text-xl font-semibold tracking-tight'
? 'font-display mb-0.5 text-base font-semibold'
: 'font-display text-xl font-bold tracking-tight'
}
>
{title}
+1 -1
View File
@@ -27,7 +27,7 @@ export function NavFooter({
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
className="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100"
className="text-sidebar-foreground/70 hover:text-sidebar-foreground"
>
<a
href={toUrl(item.href)}
@@ -1,30 +1,32 @@
import { Form } from '@inertiajs/react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Button } from '@/components/site/button';
import {
Field,
FieldError,
FormSuccess,
Honeypot,
SelectInput,
TextInput,
} from '@/components/site/form-field';
import { common, serviceOptions } from '@/content/common';
import { useContent } from '@/hooks/use-locale';
import { store } from '@/routes/consultations';
/**
* Compact consultation request form.
*
* TODO: submissions are not sent anywhere yet; wire this to a Laravel
* endpoint before going live.
*/
export default function ConsultationForm({ idPrefix }: { idPrefix: string }) {
type ConsultationFormProps = {
idPrefix: string;
/** Which page the request came from, shown in the admin inbox. */
source: 'home' | 'services';
};
/** Compact consultation request form, saved to the admin inbox. */
export default function ConsultationForm({
idPrefix,
source,
}: ConsultationFormProps) {
const t = useContent(common);
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitted(true);
};
return (
<div className="border-outline-variant/60 bg-surface-container-high flex flex-col gap-4 rounded-2xl border p-6 shadow-xl lg:p-7">
<div className="flex flex-col">
@@ -42,14 +44,28 @@ export default function ConsultationForm({ idPrefix }: { idPrefix: string }) {
message={t.form.successMessage}
/>
) : (
<form className="flex flex-col gap-3" onSubmit={handleSubmit}>
<Field id={`${idPrefix}-name`} label={t.form.name} required>
<Form
{...store.form()}
options={{ preserveScroll: true }}
onSuccess={() => setSubmitted(true)}
className="relative flex flex-col gap-3"
>
{({ processing, errors }) => (
<>
<input type="hidden" name="source" value={source} />
<Honeypot />
<Field
id={`${idPrefix}-name`}
label={t.form.name}
required
>
<TextInput
id={`${idPrefix}-name`}
name="name"
placeholder={t.form.namePlaceholder}
required
/>
<FieldError message={errors.name} />
</Field>
<Field
id={`${idPrefix}-email`}
@@ -63,6 +79,7 @@ export default function ConsultationForm({ idPrefix }: { idPrefix: string }) {
placeholder={t.form.emailPlaceholder}
required
/>
<FieldError message={errors.email} />
</Field>
<Field
id={`${idPrefix}-phone`}
@@ -76,8 +93,12 @@ export default function ConsultationForm({ idPrefix }: { idPrefix: string }) {
placeholder="+62 811 xxxx xxxx"
required
/>
<FieldError message={errors.phone} />
</Field>
<Field id={`${idPrefix}-service`} label={t.form.service}>
<Field
id={`${idPrefix}-service`}
label={t.form.service}
>
<SelectInput
id={`${idPrefix}-service`}
name="service"
@@ -87,7 +108,8 @@ export default function ConsultationForm({ idPrefix }: { idPrefix: string }) {
<Button
type="submit"
size="block"
icon="send"
icon={processing ? 'progress_activity' : 'send'}
disabled={processing}
className="mt-2"
>
{t.form.submit}
@@ -95,7 +117,9 @@ export default function ConsultationForm({ idPrefix }: { idPrefix: string }) {
<p className="text-on-surface-variant text-center text-[11px]">
{t.form.nda}
</p>
</form>
</>
)}
</Form>
)}
</div>
);
@@ -39,6 +39,32 @@ export function Field({
);
}
/** Validation message under a field. */
export function FieldError({ message }: { message?: string }) {
if (!message) {
return null;
}
return <p className="text-primary text-xs font-medium">{message}</p>;
}
/**
* Hidden field only bots fill in; the server rejects any submission that
* has a value here.
*/
export function Honeypot() {
return (
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="absolute -left-[9999px] h-0 w-0 opacity-0"
/>
);
}
export function TextInput({
className,
...props
@@ -4,11 +4,12 @@ import Icon from '@/components/site/icon';
import Pill from '@/components/site/pill';
import Section from '@/components/site/section';
import { common } from '@/content/common';
import { company, offices } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { useSiteInfo } from '@/hooks/use-site-info';
type ConsultationSectionProps = {
id: string;
source: 'home' | 'services';
title: string;
description: string;
};
@@ -16,18 +17,20 @@ type ConsultationSectionProps = {
/** Closing call-to-action with contact details and the quick form. */
export default function ConsultationSection({
id,
source,
title,
description,
}: ConsultationSectionProps) {
const site = useSiteInfo();
const t = useContent(common);
const contacts = offices.map((office) => ({
const contacts = site.offices.map((office) => ({
icon: office.icon,
title: office.name,
lines: [
office.building,
`${office.street}, ${office.area}`,
office.city,
`${t.consultation.tel}: ${office.phone.label}`,
`${t.consultation.tel}: ${office.phone}`,
],
}));
@@ -74,18 +77,18 @@ export default function ConsultationSection({
))}
</div>
<a
href={`mailto:${company.email.busdev}`}
href={`mailto:${site.email.busdev}`}
className="text-on-surface-variant hover:text-primary flex items-center gap-2 text-xs"
>
<Icon
name="mail"
className="text-primary text-base"
/>
{company.email.busdev}
{site.email.busdev}
</a>
</div>
<div className="w-full lg:col-span-5">
<ConsultationForm idPrefix={id} />
<ConsultationForm idPrefix={id} source={source} />
</div>
</div>
</div>
@@ -3,7 +3,7 @@ import Glow from '@/components/site/glow';
import Icon from '@/components/site/icon';
import Pill from '@/components/site/pill';
import Section from '@/components/site/section';
import { company } from '@/data/site';
import { useSiteInfo } from '@/hooks/use-site-info';
type CtaBannerProps = {
badge: string;
@@ -22,9 +22,10 @@ export default function CtaBanner({
actions,
showContacts = true,
}: CtaBannerProps) {
const site = useSiteInfo();
const contacts = [
{ icon: 'call', label: company.phone.label },
{ icon: 'mail', label: company.email.busdev },
{ icon: 'call', label: site.phone.label },
{ icon: 'mail', label: site.email.busdev },
{ icon: 'location_on', label: 'Bintaro & Plaza Simatupang, Jakarta' },
];
+10 -7
View File
@@ -2,9 +2,10 @@ import { Link } from '@inertiajs/react';
import Container from '@/components/site/container';
import Icon from '@/components/site/icon';
import { common } from '@/content/common';
import { company, consultationUrl, officeAddress, offices } from '@/data/site';
import { company, consultationUrl, officeAddress } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { about, clients, contact, services } from '@/routes';
import { useSiteInfo } from '@/hooks/use-site-info';
import { about, clients, contact, login, services } from '@/routes';
function FooterHeading({ children }: { children: string }) {
return (
@@ -15,6 +16,7 @@ function FooterHeading({ children }: { children: string }) {
}
export default function SiteFooter() {
const site = useSiteInfo();
const t = useContent(common);
const companyLinks = [
@@ -27,6 +29,7 @@ export default function SiteFooter() {
{ label: t.footer.links.clients, href: clients.url() },
{ label: t.footer.links.audit, href: consultationUrl },
{ label: t.footer.links.offices, href: `${contact.url()}#lokasi` },
{ label: t.footer.links.admin, href: login.url() },
];
return (
@@ -50,7 +53,7 @@ export default function SiteFooter() {
{t.footer.tagline}
</p>
<div className="text-on-surface-variant mt-2 flex flex-col gap-4 text-xs">
{offices.map((office) => (
{site.offices.map((office) => (
<div
key={office.name}
className="flex items-start gap-2"
@@ -65,23 +68,23 @@ export default function SiteFooter() {
</span>
<span>{officeAddress(office)}</span>
<a
href={office.phone.href}
href={office.phoneHref}
className="hover:text-primary font-mono transition-colors"
>
{t.footer.tel} {office.phone.label}
{t.footer.tel} {office.phone}
</a>
</div>
</div>
))}
<a
href={`mailto:${company.email.busdev}`}
href={`mailto:${site.email.busdev}`}
className="hover:text-primary flex items-center gap-2 font-mono transition-colors"
>
<Icon
name="mail"
className="text-primary text-base"
/>
{company.email.busdev}
{site.email.busdev}
</a>
</div>
</div>
+7 -5
View File
@@ -7,12 +7,14 @@ import Icon from '@/components/site/icon';
import LanguageToggle from '@/components/site/language-toggle';
import ThemeToggle from '@/components/site/theme-toggle';
import { common } from '@/content/common';
import { company, consultationUrl, navigation, offices } from '@/data/site';
import { consultationUrl, navigation } from '@/data/site';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { useContent } from '@/hooks/use-locale';
import { useSiteInfo } from '@/hooks/use-site-info';
import { cn } from '@/lib/utils';
export default function SiteHeader() {
const site = useSiteInfo();
const t = useContent(common);
const { isCurrentUrl } = useCurrentUrl();
const [menuOpen, setMenuOpen] = useState(false);
@@ -46,20 +48,20 @@ export default function SiteHeader() {
<div className="flex items-center gap-2 sm:gap-3">
<div className="text-on-surface-variant hidden flex-col text-right font-mono text-[11px] 2xl:flex">
<a
href={offices[0].phone.href}
href={site.phone.href}
className="text-on-surface hover:text-primary flex items-center justify-end gap-1 font-semibold"
>
<Icon
name="call"
className="text-primary text-xs"
/>
{offices[0].phone.label}
{site.phone.label}
</a>
<a
href={`mailto:${company.email.busdev}`}
href={`mailto:${site.email.busdev}`}
className="hover:text-primary"
>
{company.email.busdev}
{site.email.busdev}
</a>
</div>
<LanguageToggle />
+10 -7
View File
@@ -1,4 +1,3 @@
import { clientCount, projects } from '@/data/projects';
import type { ProjectSector } from '@/data/projects';
import type { Localized } from '@/hooks/use-locale';
@@ -25,10 +24,11 @@ const id = {
'Berdiri tahun 1985 sebagai perusahaan trading terkemuka di sektor Oil & Gas dan Industri Baja (Steel), lalu sejak 2002 bertransformasi menjadi konsultan IT.',
},
{
value: `${clientCount}`,
value: '{clients}',
unit: 'Klien',
label: 'Klien • Portofolio',
description: `${projects.length} proyek sistem informasi untuk instansi pemerintahan, BUMN, dan perusahaan swasta.`,
description:
'{projects} proyek sistem informasi untuk instansi pemerintahan, BUMN, dan perusahaan swasta.',
},
],
},
@@ -54,7 +54,8 @@ const id = {
year: '2002',
icon: 'precision_manufacturing',
title: 'Evolusi Teknologi Informasi (TI)',
description: `Pada tahun 2002 perusahaan bertransformasi menjadi konsultan Sistem Informasi dan Teknologi Informasi, dengan fokus pada perancangan, pembangunan, dan implementasi aplikasi bisnis. Sejak itu PAJ telah menangani ${projects.length} proyek untuk ${clientCount} klien pemerintahan, BUMN, dan swasta — mulai dari sistem pemantauan harga Kementerian Perdagangan hingga layanan perizinan investasi BKPM.`,
description:
'Pada tahun 2002 perusahaan bertransformasi menjadi konsultan Sistem Informasi dan Teknologi Informasi, dengan fokus pada perancangan, pembangunan, dan implementasi aplikasi bisnis. Sejak itu PAJ telah menangani {projects} proyek untuk {clients} klien pemerintahan, BUMN, dan swasta — mulai dari sistem pemantauan harga Kementerian Perdagangan hingga layanan perizinan investasi BKPM.',
note: {
icon: 'data_object',
label: 'Automated Business Applications',
@@ -215,10 +216,11 @@ const en: typeof id = {
'Founded in 1985 as a leading trading company in the Oil & Gas and Steel industries, and an IT consultant since its transformation in 2002.',
},
{
value: `${clientCount}`,
value: '{clients}',
unit: 'Clients',
label: 'Clients • Portfolio',
description: `${projects.length} information systems projects for government, state-owned, and private organizations.`,
description:
'{projects} information systems projects for government, state-owned, and private organizations.',
},
],
},
@@ -244,7 +246,8 @@ const en: typeof id = {
year: '2002',
icon: 'precision_manufacturing',
title: 'Evolution into Information Technology',
description: `In 2002, the company transformed into an Information Systems and Information Technology consultant, focused on designing, building, and implementing business applications. Since then, PAJ has delivered ${projects.length} projects for ${clientCount} government, state-owned, and private clients — from the Ministry of Trade’s price monitoring systems to BKPM’s investment licensing services.`,
description:
'In 2002, the company transformed into an Information Systems and Information Technology consultant, focused on designing, building, and implementing business applications. Since then, PAJ has delivered {projects} projects for {clients} government, state-owned, and private clients — from the Ministry of Trade’s price monitoring systems to BKPM’s investment licensing services.',
note: {
icon: 'data_object',
label: 'Automated Business Applications',
+8 -19
View File
@@ -1,5 +1,4 @@
import type { ProjectProgram } from '@/data/projects';
import { clientCount, projects } from '@/data/projects';
import type { Localized } from '@/hooks/use-locale';
const id = {
@@ -11,8 +10,8 @@ const id = {
'“Kami mengucapkan terima kasih atas kepercayaan dan dukungan Anda sebagai klien kami. Sebagai tim konsultan IT di PT Probindo Artika Jaya (PAJ), kami berkomitmen untuk menyediakan solusi teknologi informasi yang berkualitas tinggi, tailor-fit, dan memberikan nilai tambah nyata bagi kesinambungan bisnis Anda.”',
stats: [
{
value: `${clientCount} Klien`,
caption: `${projects.length} Proyek Pemerintahan, BUMN & Swasta`,
value: '{clients} Klien',
caption: '{projects} Proyek Pemerintahan, BUMN & Swasta',
},
{ value: '20+ Thn', caption: 'Konsultan IT Sejak 2002' },
{ value: '99.8%', caption: 'Tingkat Retensi & Kepuasan' },
@@ -22,7 +21,8 @@ const id = {
heritage: {
eyebrow: 'Konsultan IT Sejak 2002',
title: 'Fondasi Sistem Informasi untuk Pemerintahan, BUMN & Swasta',
description: `Sejak bertransformasi menjadi konsultan sistem informasi pada 2002, PAJ telah menangani ${projects.length} proyek untuk ${clientCount} klien — mulai dari sistem pemantauan harga dan layanan perizinan investasi di kementerian, hingga implementasi ERP dan sistem akuntansi di perusahaan swasta. Setiap solusi dirancang mengikuti alur kerja riil klien, bukan sekadar perangkat lunak generik.`,
description:
'Sejak bertransformasi menjadi konsultan sistem informasi pada 2002, PAJ telah menangani {projects} proyek untuk {clients} klien — mulai dari sistem pemantauan harga dan layanan perizinan investasi di kementerian, hingga implementasi ERP dan sistem akuntansi di perusahaan swasta. Setiap solusi dirancang mengikuti alur kerja riil klien, bukan sekadar perangkat lunak generik.',
scsrTitle: 'Pendampingan Berkelanjutan',
scsrText:
'Banyak sistem kami dikembangkan, dipelihara, dan diperbarui selama bertahun-tahun bersama klien yang sama — seperti sistem pemantauan harga bahan pokok (SP2KP) yang kami tangani sejak 2013.',
@@ -136,12 +136,6 @@ const id = {
description:
'Apresiasi nyata atas komitmen tanpa henti dari tim konsultan teknis PT Probindo Artika Jaya.',
rating: 'Rating 5 dari 5',
items: [] as {
initials: string;
quote: string;
role: string;
organization: string;
}[],
},
reliability: {
eyebrow: 'Reliability Index & Audit Trail',
@@ -181,8 +175,8 @@ const en: typeof id = {
'“Thank you for your trust and support as our client. As the IT consulting team at PT Probindo Artika Jaya (PAJ), we are committed to delivering high-quality, tailor-fit information technology solutions that add real value to the continuity of your business.”',
stats: [
{
value: `${clientCount} Clients`,
caption: `${projects.length} Government, SOE & Private Projects`,
value: '{clients} Clients',
caption: '{projects} Government, SOE & Private Projects',
},
{
value: '20+ Yrs',
@@ -195,7 +189,8 @@ const en: typeof id = {
heritage: {
eyebrow: 'IT Consultant Since 2002',
title: 'Information Systems Foundations for Government, SOEs & Private Companies',
description: `Since becoming an information systems consultant in 2002, PAJ has delivered ${projects.length} projects for ${clientCount} clients — from price monitoring and investment licensing systems for ministries to ERP and accounting systems for private companies. Every solution follows the client’s real workflow, not generic software.`,
description:
'Since becoming an information systems consultant in 2002, PAJ has delivered {projects} projects for {clients} clients — from price monitoring and investment licensing systems for ministries to ERP and accounting systems for private companies. Every solution follows the client’s real workflow, not generic software.',
scsrTitle: 'Long-Term Support',
scsrText:
'Many of our systems have been developed, maintained, and upgraded for years with the same clients — such as the staple goods price monitoring system (SP2KP) we have worked on since 2013.',
@@ -303,12 +298,6 @@ const en: typeof id = {
description:
'Genuine appreciation for the unwavering commitment of PT Probindo Artika Jaya’s technical consultants.',
rating: 'Rated 5 out of 5',
items: [] as {
initials: string;
quote: string;
role: string;
organization: string;
}[],
},
reliability: {
eyebrow: 'Reliability Index & Audit Trail',
+40 -8
View File
@@ -40,6 +40,7 @@ const id = {
clients: 'Daftar Klien Industri',
audit: 'Permintaan Audit Sistem',
offices: 'Lokasi Kantor PAJ',
admin: 'Admin Panel',
},
rights: 'Hak Cipta Dilindungi Undang-Undang.',
location: 'Jakarta Selatan, Indonesia',
@@ -142,6 +143,19 @@ const id = {
badge: 'KONSULTASI TEKNIS & AUDIT INFRASTRUKTUR',
tel: 'Telp',
},
login: {
pageTitle: 'Masuk Admin Panel',
title: 'Masuk ke Admin Panel',
description: 'Masukkan email dan password akun admin Anda.',
email: 'Email',
emailPlaceholder: 'nama@probindo.com',
password: 'Password',
passwordPlaceholder: 'Password',
forgot: 'Lupa password?',
remember: 'Ingat saya',
submit: 'Masuk',
back: 'Kembali ke website',
},
};
const en: typeof id = {
@@ -172,6 +186,7 @@ const en: typeof id = {
clients: 'Industry Clients',
audit: 'Request a System Audit',
offices: 'PAJ Office Locations',
admin: 'Admin Panel',
},
rights: 'All rights reserved.',
location: 'South Jakarta, Indonesia',
@@ -274,15 +289,32 @@ const en: typeof id = {
badge: 'TECHNICAL CONSULTATION & INFRASTRUCTURE AUDIT',
tel: 'Tel',
},
login: {
pageTitle: 'Admin Panel Login',
title: 'Log in to the Admin Panel',
description: 'Enter the email and password of your admin account.',
email: 'Email',
emailPlaceholder: 'name@probindo.com',
password: 'Password',
passwordPlaceholder: 'Password',
forgot: 'Forgot password?',
remember: 'Remember me',
submit: 'Log in',
back: 'Back to website',
},
};
export const common: Localized<typeof id> = { id, en };
/** Options for the "service needed" select in every consultation form. */
export const serviceOptions = (content: typeof id) => [
...content.services.map((service, index) => ({
value: `service-${index + 1}`,
label: `${index + 1}. ${service.expansion ? `${service.title} (${service.expansion})` : service.title}`,
})),
...content.extraServiceOptions,
];
/**
* Options for the "service needed" select in every consultation form. The
* submitted value is the label itself, so the admin inbox reads naturally.
*/
export const serviceOptions = (content: typeof id) =>
[
...content.services.map(
(service, index) =>
`${index + 1}. ${service.expansion ? `${service.title} (${service.expansion})` : service.title}`,
),
...content.extraServiceOptions.map((option) => option.label),
].map((label) => ({ value: label, label }));
+10 -9
View File
@@ -1,4 +1,3 @@
import { clientCount, projects } from '@/data/projects';
import type { Localized } from '@/hooks/use-locale';
const id = {
@@ -11,7 +10,7 @@ const id = {
primaryAction: 'Jadwalkan Konsultasi',
secondaryAction: 'Lihat Layanan Kami',
trustBefore: 'Dipercaya oleh',
trustLink: `${clientCount} klien`,
trustLink: '{clients} klien',
trustAfter: 'dari instansi pemerintahan, BUMN, dan perusahaan swasta.',
},
console: {
@@ -59,9 +58,10 @@ const id = {
},
{
label: 'Klien • Portofolio',
value: `${clientCount}`,
value: '{clients}',
unit: 'Klien',
description: `${projects.length} proyek sistem informasi untuk instansi pemerintahan, BUMN, dan perusahaan swasta.`,
description:
'{projects} proyek sistem informasi untuk instansi pemerintahan, BUMN, dan perusahaan swasta.',
},
{
label: 'Layanan • Keandalan',
@@ -82,7 +82,7 @@ const id = {
eyebrow: 'Klien & Rekam Jejak Proyek',
description:
'Dipercaya instansi pemerintahan, BUMN, dan perusahaan swasta untuk membangun dan memelihara sistem informasinya.',
since: `${projects.length} PROYEK • ${clientCount} KLIEN • SEJAK 2002`,
since: '{projects} PROYEK • {clients} KLIEN • SEJAK 2002',
more: 'Lihat semua klien & proyek',
},
profile: {
@@ -147,7 +147,7 @@ const en: typeof id = {
primaryAction: 'Schedule a Consultation',
secondaryAction: 'Explore Our Services',
trustBefore: 'Trusted by',
trustLink: `${clientCount} clients`,
trustLink: '{clients} clients',
trustAfter:
'across government, state-owned, and private organizations.',
},
@@ -196,9 +196,10 @@ const en: typeof id = {
},
{
label: 'Clients • Portfolio',
value: `${clientCount}`,
value: '{clients}',
unit: 'Clients',
description: `${projects.length} information systems projects for government, state-owned, and private organizations.`,
description:
'{projects} information systems projects for government, state-owned, and private organizations.',
},
{
label: 'Services • Reliability',
@@ -219,7 +220,7 @@ const en: typeof id = {
eyebrow: 'Clients & Project Track Record',
description:
'Trusted by government institutions, state-owned enterprises, and private companies to build and maintain their information systems.',
since: `${projects.length} PROJECTS • ${clientCount} CLIENTS • SINCE 2002`,
since: '{projects} PROJECTS • {clients} CLIENTS • SINCE 2002',
more: 'See all clients & projects',
},
profile: {
+58 -396
View File
@@ -1,386 +1,11 @@
import type { Project, ProjectProgram, ProjectSector } from '@/types';
export type { Project, ProjectProgram, ProjectSector } from '@/types';
/**
* PAJ's IT project track record. Project and client names are official
* Indonesian names, so they are shown as-is in both languages.
* Portfolio helpers. The projects themselves are managed in the admin panel
* and arrive as a page prop, already sorted newest first.
*/
export type ProjectSector = 'government' | 'private' | 'soe';
/** Long-running programs highlighted as featured case studies. */
export type ProjectProgram = 'price-monitoring' | 'investment' | 'erp';
export type Project = {
title: string;
client: string;
year: number;
sector: ProjectSector;
program?: ProjectProgram;
/** Optional detail of what the project covered. */
scope?: string;
};
const government: Omit<Project, 'sector'>[] = [
{
title: 'Penyusunan dan Pembuatan SOP Direktorat Data dan Informasi',
client: 'Kementerian Investasi / Badan Koordinasi Penanaman Modal (BKPM)',
year: 2025,
program: 'investment',
},
{
title: 'Perencanaan Peta Jalan Direktorat Data dan Informasi TA 2024',
client: 'Kementerian Investasi / Badan Koordinasi Penanaman Modal (BKPM)',
year: 2024,
program: 'investment',
},
{
title: 'Modul Pelayanan Metrologi Legal Submodul Verifikasi (Sistem Informasi Ketertelusuran Standar (SIKS))',
client: 'Kementerian Perdagangan',
year: 2024,
},
{
title: 'Penyediaan Data Harga dan Pasokan Cabe dan Bawang di Pasar Induk Wilayah Barat',
client: 'Kementerian Perdagangan',
year: 2024,
program: 'price-monitoring',
},
{
title: 'Pemeliharaan Sistem Pelaporan Harga (SP2KP), Pusat Data dan Sistem Informasi',
client: 'Kementerian Perdagangan',
year: 2023,
program: 'price-monitoring',
},
{
title: 'Penyediaan Data Harga dan Pasokan Cabe dan Bawang di Pasar Induk Wilayah Barat',
client: 'Kementerian Perdagangan',
year: 2023,
program: 'price-monitoring',
},
{
title: 'Pembangunan Aplikasi Sertifikasi Prasarana Perkeretaapian Dengan Memanfaatkan Teknologi Informasi',
client: 'Kementerian Perhubungan',
year: 2022,
},
{
title: 'Pengembangan Aplikasi LDH',
client: 'Badan Informasi Geospasial (BIG)',
year: 2022,
program: 'erp',
scope: 'OpenERP Community: manajemen dokumen dan custom development',
},
{
title: 'Perawatan Sistem Pelaporan Harga',
client: 'Kementerian Perdagangan',
year: 2022,
program: 'price-monitoring',
},
{
title: 'Pengembangan Aplikasi Sistem Informasi Ketertelusuran Standar',
client: 'Kementerian Perdagangan',
year: 2021,
},
{
title: 'Pengembangan Modul Master Data Aplikasi SIREBEKA (Sistem Informasi Remunerasi Berbasis Kinerja)',
client: 'Kementerian Perhubungan',
year: 2021,
},
{
title: 'Pengelolaan Sistem Pelaporan Harga',
client: 'Kementerian Perdagangan',
year: 2020,
program: 'price-monitoring',
},
{
title: 'Pengadaan Metrologi Aplikasi Mobile: Pendataan dan Pemetaan Kondisi UTTP dan BDKT dalam Kerangka Pengawasan Kemetrologian',
client: 'Kementerian Perdagangan',
year: 2020,
},
{
title: 'Penyediaan Data Harga dan Pasokan Cabe dan Bawang di Pasar Induk Wilayah Barat',
client: 'Kementerian Perdagangan',
year: 2020,
program: 'price-monitoring',
},
{
title: 'Penyediaan Data Harga dan Pasokan Cabe dan Bawang di Pasar Induk Wilayah Timur',
client: 'Kementerian Perdagangan',
year: 2020,
program: 'price-monitoring',
},
{
title: 'Perawatan Sistem Pelaporan Harga',
client: 'Kementerian Perdagangan',
year: 2020,
program: 'price-monitoring',
},
{
title: 'Perancangan Antar Muka Sistem Informasi Ketertelusuran Standar',
client: 'Kementerian Perdagangan',
year: 2019,
},
{
title: 'Pengembangan dan Pemeliharaan Portal NSWI TA 2019',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2019,
program: 'investment',
},
{
title: 'Pengembangan Aplikasi Website Intranet',
client: 'Kementerian Perdagangan',
year: 2018,
},
{
title: 'Pemantauan Harga Bapok 34 Ibukota Provinsi pada Sabtu atau Minggu, Libur Nasional dan HBKN, Pasar Kab/Kota, Daerah TTT dan Pasar Ritel Modern Tahun 2018',
client: 'Kementerian Perdagangan',
year: 2018,
program: 'price-monitoring',
},
{
title: 'Pengembangan Business Intelligence (BI) TA 2018',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2018,
program: 'investment',
},
{
title: 'Pengembangan Dashboard Produk Wisata MICE',
client: 'Kementerian Pariwisata',
year: 2017,
},
{
title: 'Pembuatan Sistem Informasi Manajemen Harga dan Pasokan',
client: 'Kementerian Perdagangan',
year: 2017,
program: 'price-monitoring',
},
{
title: 'Penerapan Model Pembangunan dan Revitalisasi Pasar Rakyat Dengan Pendekatan Teknologi Informasi Berbasis Mobile',
client: 'Kementerian Perdagangan',
year: 2017,
},
{
title: 'Penyusunan Pemutakhiran (Updating) Cetak Biru Pengembangan Sistem Pelayanan Informasi dan Perizinan',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2017,
program: 'investment',
},
{
title: 'Pemantauan Stok dan Pasokan Barang Kebutuhan Pokok 100 Titik di Pasar Rakyat (Tradisional), Pasar Induk dan Sentra Produksi',
client: 'Kementerian Perdagangan',
year: 2017,
program: 'price-monitoring',
},
{
title: 'Verifikasi Pelaporan Harga Barang Pokok pada SP2KP',
client: 'Kementerian Perdagangan',
year: 2016,
program: 'price-monitoring',
},
{
title: 'Pengembangan Program Aplikasi Harga Kabupaten Kota',
client: 'Kementerian Perdagangan',
year: 2016,
program: 'price-monitoring',
},
{
title: 'Updating Program Penilaian Pasar',
client: 'Kementerian Perdagangan',
year: 2016,
},
{
title: 'Pengembangan Sistem Aplikasi Jaringan Informasi Dokumentasi Hukum',
client: 'Badan Informasi Geospasial (BIG)',
year: 2016,
program: 'erp',
scope: 'OpenERP Community: manajemen dokumen dan custom development',
},
{
title: 'Verifikasi Pelaporan Harga Barang Pokok pada Sistem Pemantauan Pasar Kebutuhan Pokok (SP2KP)',
client: 'Kementerian Perdagangan',
year: 2016,
program: 'price-monitoring',
},
{
title: 'Pekerjaan Pengembangan Program Aplikasi Harga Kabupaten Kota',
client: 'Kementerian Perdagangan',
year: 2016,
program: 'price-monitoring',
},
{
title: 'Pembuatan Software pada Kegiatan Penguatan Regional Climate Change Center',
client: 'Badan Meteorologi Klimatologi dan Geofisika (BMKG)',
year: 2015,
},
{
title: 'Kajian Tata Kelola Teknologi Informasi BKPM TA 2014',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2014,
program: 'investment',
},
{
title: 'Sistem Informasi Monitoring Updating Harga dan Pasokan Bawang Merah',
client: 'Kementerian Perdagangan',
year: 2014,
program: 'price-monitoring',
},
{
title: 'Pemetaan Indikasi dan Variabel Penentu Kenaikan Harga Bahan Pokok (Komoditas Cabe Merah, Bawang Merah dan Beras)',
client: 'Kementerian Perdagangan',
year: 2014,
program: 'price-monitoring',
},
{
title: 'Pembangunan Sistem Informasi Pelayanan Dokumen Hukum',
client: 'Badan Informasi Geospasial (BIG)',
year: 2014,
program: 'erp',
scope: 'OpenERP Community: manajemen dokumen dan custom development',
},
{
title: 'Pendampingan Helpdesk Implementasi Sistem Pelayanan Informasi dan Perizinan Investasi Secara Elektronik (SPIPISE)',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2013,
program: 'investment',
},
{
title: 'Pengadaan Jasa Konsultasi Pekerjaan Pembangunan Sub Sistem Bidang Ketahanan Lembaga Usaha Ekonomi',
client: 'Kementerian Perdagangan',
year: 2012,
},
{
title: 'Pendampingan Helpdesk Implementasi Sistem Pelayanan Informasi dan Perizinan Investasi Secara Elektronik (SPIPISE)',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2012,
program: 'investment',
},
{
title: 'Pembangunan Aplikasi Jaringan Dokumentasi dan Informasi Hukum',
client: 'Badan Informasi Geospasial (BIG)',
year: 2012,
program: 'erp',
scope: 'OpenERP Community: manajemen dokumen dan custom development',
},
{
title: 'Pengembangan Portal National Single Window for Investment (NSWI) untuk Mendukung SPIPISE BKPM',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2010,
program: 'investment',
},
{
title: 'Pembuatan Database Eksportir Importir, Perusahaan, Asosiasi dan Pelaku Industri Kreatif serta Lembaga Pendidikan Formal/Non Formal',
client: 'Departemen Perdagangan Republik Indonesia',
year: 2009,
},
{
title: 'Pembangunan Sistem Informasi Tracking Promosi Investasi Tahun Anggaran 2006',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2006,
program: 'investment',
},
];
const privateSector: Omit<Project, 'sector'>[] = [
{
title: 'Implementasi Odoo ERP',
client: 'Marawah Beach Club',
year: 2022,
program: 'erp',
scope: 'Odoo Enterprise: POS, event, pembelian, penjualan, website, dan custom development',
},
{
title: 'Enhancement Manajemen Sistem',
client: 'PT Cipta Kridatama',
year: 2022,
},
{
title: 'Implementasi OpenERP Manufacturing System',
client: 'CV Mujur Jaya Abadi',
year: 2015,
program: 'erp',
scope: 'OpenERP Community: akunting, manufaktur, pembelian, penjualan, inventory, dan custom development',
},
{
title: 'Pengembangan Reswara Integrated Management System',
client: 'PT Reswara Minergi Hartama',
year: 2013,
},
{
title: 'Kegiatan Pengembangan dan Operasionalisasi Sistem Pemantauan Pasar Kebutuhan Pokok (SP2KP)',
client: 'PT LAPI ITB (Bapokstra)',
year: 2013,
program: 'price-monitoring',
},
{
title: 'Pengembangan Aplikasi GIS Pelaporan Online Harga',
client: 'PT DKN',
year: 2011,
program: 'price-monitoring',
},
{
title: 'Pengembangan Portal Ekonomi Kreatif Indonesia (www.indonesiakreatif.net)',
client: 'CV Multi Sarana Putra',
year: 2010,
},
{
title: 'Pengembangan Cetak Biru Teknologi Informasi Pusat Pengendalian Online Harga dan Stok',
client: 'PT Pranala Nitisala',
year: 2010,
program: 'price-monitoring',
},
{
title: 'Pembuatan Sistem Informasi Monitoring Penerimaan dan Pengeluaran BBM di Setiap Fasilitas Rantai Pasok BBM',
client: 'KSO LAPI – Pranala',
year: 2008,
},
{
title: 'Validasi dan Koreksi Data Akunting',
client: 'PT Lautan Berlian Utama Motor',
year: 2008,
},
{
title: 'Pengembangan, Pemutakhiran dan Pemeliharaan Website Tahun Anggaran 2007',
client: 'CV Multi Sarana Putra',
year: 2007,
},
{
title: 'Pembuatan Compact Disc (CD) Profil Wilayah, Sektor dan Komoditi',
client: 'PT Qualita Prima Pariwara',
year: 2007,
},
{
title: 'Pengembangan Aplikasi Titis Sampurna Accounting Information System',
client: 'PT Titis Sampurna',
year: 2006,
program: 'erp',
},
{
title: 'Jasa Konsultasi Pengembangan Sistem Informasi Eksekutif Keuangan',
client: 'CV Multi Sarana Putra',
year: 2006,
},
{
title: 'Desain Sistem Informasi Museum Bank Indonesia',
client: 'PT Global Media Inline',
year: 2006,
},
];
const soe: Omit<Project, 'sector'>[] = [
{
title: 'Pembangunan Sistem Aplikasi',
client: 'PT Sucofindo (Persero)',
year: 2015,
},
{
title: 'Jasa Pemeliharaan Sistem Merpati Operation System Infrastructure (MOSI), Mobile Flight Log (MFL), Schedule Messaging System (SMS)',
client: 'PT Merpati Nusantara Airlines',
year: 2006,
},
];
const newestFirst = (a: Project, b: Project) => b.year - a.year;
export const projects: Project[] = [
...government.map((p) => ({ ...p, sector: 'government' as const })),
...privateSector.map((p) => ({ ...p, sector: 'private' as const })),
...soe.map((p) => ({ ...p, sector: 'soe' as const })),
].sort(newestFirst);
/** Different names used over the years for the same institution. */
const clientAliases: Record<string, string> = {
@@ -392,11 +17,8 @@ const clientAliases: Record<string, string> = {
const clientName = (project: Project) =>
clientAliases[project.client] ?? project.client;
/** Number of distinct clients across all projects. */
export const clientCount = new Set(projects.map(clientName)).size;
/** Distinct clients, most frequent first. */
const rankClients = (items: Project[]) => {
export const rankClients = (items: Project[]) => {
const counts = new Map<string, number>();
items.forEach((project) => {
@@ -409,24 +31,64 @@ const rankClients = (items: Project[]) => {
.map(([name]) => name);
};
export const clientsInSector = (sector: ProjectSector) =>
rankClients(projects.filter((project) => project.sector === sector));
export const bySector = (items: Project[], sector: ProjectSector) =>
items.filter((project) => project.sector === sector);
/** Clients with the most projects across every sector. */
export const topClients = (limit: number) =>
rankClients(projects).slice(0, limit);
export const projectsBySector = (sector: ProjectSector) =>
projects.filter((project) => project.sector === sector);
export const projectsInProgram = (program: ProjectProgram) =>
projects.filter((project) => project.program === program);
export const inProgram = (items: Project[], program: ProjectProgram) =>
items.filter((project) => project.program === program);
/** e.g. "2006 – 2025", or a single year when all projects share it. */
export const yearRange = (items: Project[]) => {
if (items.length === 0) {
return '–';
}
const years = items.map((project) => project.year);
const first = Math.min(...years);
const last = Math.max(...years);
return first === last ? `${first}` : `${first} – ${last}`;
};
/** All portfolio numbers and lists derived from one set of projects. */
export const portfolio = (projects: Project[]) => ({
projects,
projectCount: projects.length,
clientCount: rankClients(projects).length,
projectsBySector: (sector: ProjectSector) => bySector(projects, sector),
projectsInProgram: (program: ProjectProgram) =>
inProgram(projects, program),
clientsInSector: (sector: ProjectSector) =>
rankClients(bySector(projects, sector)),
topClients: (limit: number) => rankClients(projects).slice(0, limit),
});
export type Portfolio = ReturnType<typeof portfolio>;
/** Fills `{projects}` and `{clients}` placeholders in content strings. */
export const withCounts = (text: string, stats: Portfolio) =>
text
.replaceAll('{projects}', String(stats.projectCount))
.replaceAll('{clients}', String(stats.clientCount));
/** Applies `withCounts` to every string in a content object. */
export function fillCounts<T>(value: T, stats: Portfolio): T {
if (typeof value === 'string') {
return withCounts(value, stats) as T;
}
if (Array.isArray(value)) {
return value.map((item: unknown) => fillCounts(item, stats)) as T;
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
fillCounts(item, stats),
]),
) as T;
}
return value;
}
+12 -51
View File
@@ -1,8 +1,9 @@
import { about, clients, contact, services } from '@/routes';
import type { Office } from '@/types';
/**
* Language-independent company facts. Anything visitors read as prose lives
* in `resources/js/content` in both languages.
* Fixed company facts. Editable contact details and office locations come
* from the admin panel through the shared `site` prop (see `useSiteInfo`).
*/
export const company = {
name: 'PT Probindo Artika Jaya',
@@ -12,59 +13,19 @@ export const company = {
/** Year PAJ became an IT consultant; shown as "Est." on the site. */
established: 2002,
logo: '/images/probindo/logo-paj.png',
phone: { label: '(021) 7266-3353', href: 'tel:+622172663353' },
whatsapp: {
label: '0818-0218-9425',
href: 'https://wa.me/6281802189425',
},
email: {
support: 'support@probindo.com',
busdev: 'busdev@probindo.com',
},
} as const;
export type Office = {
name: string;
icon: string;
building: string;
street: string;
area: string;
city: string;
/** Compact location used in chips and one-line summaries. */
district: string;
phone: { label: string; href: string };
mapsUrl: string;
/** Indonesian phone number → international digits, e.g. "622172663353". */
const internationalDigits = (phone: string) => {
const digits = phone.replace(/\D/g, '');
return digits.startsWith('0') ? `62${digits.slice(1)}` : digits;
};
/**
* PAJ works from two locations; most of the team is based at the Software
* Engineering Center, so it is listed first.
*/
export const offices: Office[] = [
{
name: 'PAJ Software Engineering Center',
icon: 'code_blocks',
building: 'Anindhaloka',
street: 'Jl. YRS No.20, RW.1',
area: 'Bintaro, Kec. Pesanggrahan',
city: 'Kota Jakarta Selatan 12330',
district: 'Bintaro, Jakarta Selatan',
phone: { label: '(021) 7266-3353', href: 'tel:+622172663353' },
mapsUrl:
'https://maps.google.com/?q=Jl.+YRS+No.20+Bintaro+Pesanggrahan+Jakarta+Selatan',
},
{
name: 'PAJ Business Development Center',
icon: 'apartment',
building: 'Plaza Simatupang Lt. 6 Unit 3',
street: 'Jl. TB. Simatupang Kav. IS No. 01',
area: 'Pondok Pinang, Kebayoran Lama',
city: 'Jakarta Selatan 12310',
district: 'Plaza Simatupang, Jakarta Selatan',
phone: { label: '+62 21 5789 5927', href: 'tel:+622157895927' },
mapsUrl: 'https://maps.google.com/?q=Plaza+Simatupang+Jakarta',
},
];
export const telHref = (phone: string) => `tel:+${internationalDigits(phone)}`;
export const whatsappHref = (phone: string) =>
`https://wa.me/${internationalDigits(phone)}`;
export const officeAddress = (office: Office) =>
[office.building, office.street, office.area, office.city].join(', ');
+10
View File
@@ -0,0 +1,10 @@
import { usePage } from '@inertiajs/react';
import { portfolio } from '@/data/projects';
import type { Project } from '@/types';
/** Portfolio helpers for pages that receive a `projects` prop. */
export function usePortfolio() {
const { projects } = usePage<{ projects: Project[] }>().props;
return portfolio(projects ?? []);
}
+26
View File
@@ -0,0 +1,26 @@
import { usePage } from '@inertiajs/react';
import { telHref, whatsappHref } from '@/data/site';
/**
* Editable company contact details and office locations (admin panel →
* Info Perusahaan), with ready-to-use link targets.
*/
export function useSiteInfo() {
const { contact, offices } = usePage().props.site;
return {
phone: { label: contact.phone, href: telHref(contact.phone) },
whatsapp: {
label: contact.whatsapp,
href: whatsappHref(contact.whatsapp),
},
email: {
busdev: contact.email_busdev,
support: contact.email_support,
},
offices: offices.map((office) => ({
...office,
phoneHref: telHref(office.phone),
})),
};
}
+57
View File
@@ -0,0 +1,57 @@
import type {
ConsultationStatus,
ProjectProgram,
ProjectSector,
UserRole,
} from '@/types';
export const sectorLabels: Record<ProjectSector, string> = {
government: 'Pemerintahan',
private: 'Swasta',
soe: 'BUMN',
};
export const programLabels: Record<ProjectProgram, string> = {
'price-monitoring': 'Pemantauan Harga',
investment: 'Investasi',
erp: 'ERP',
};
export const statusLabels: Record<ConsultationStatus, string> = {
new: 'Baru',
contacted: 'Dihubungi',
closed: 'Selesai',
};
export const statusBadgeClasses: Record<ConsultationStatus, string> = {
new: 'bg-primary text-primary-foreground border-transparent',
contacted:
'border-transparent bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-300',
closed: 'border-transparent bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-300',
};
export const sourceLabels = {
home: 'Beranda',
services: 'Jasa',
contact: 'Kontak',
} as const;
export const roleLabels: Record<UserRole, string> = {
admin: 'Admin',
editor: 'Editor',
};
/** `{ value, label }` pairs for a select, in declaration order. */
export function optionsOf<T extends string>(labels: Record<T, string>) {
return (Object.keys(labels) as T[]).map((value) => ({
value,
label: labels[value],
}));
}
export function formatDate(value: string, withTime = false): string {
return new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium',
...(withTime ? { timeStyle: 'short' } : {}),
}).format(new Date(value));
}
+267
View File
@@ -0,0 +1,267 @@
import { Form, Head } from '@inertiajs/react';
import { ExternalLink, MapPin, Pencil, Plus, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import {
ConfirmDelete,
FormDialog,
FormField,
PageHeader,
} from '@/components/admin/form-controls';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { officeAddress } from '@/data/site';
import { edit } from '@/routes/admin/company';
import { update as updateContact } from '@/routes/admin/company/contact';
import { destroy, store, update } from '@/routes/admin/offices';
import type { Office, SiteInfo } from '@/types';
export default function CompanyEdit({ contact, offices }: SiteInfo) {
return (
<>
<Head title="Info Perusahaan" />
<div className="flex flex-col gap-8 p-4 md:p-6">
<PageHeader
title="Info Perusahaan"
description="Kontak dan alamat kantor yang tampil di header, footer, dan halaman Kontak."
/>
<section className="bg-card flex flex-col gap-6 rounded-xl border p-5">
<Heading
variant="small"
title="Kontak"
description="Email busdev juga menjadi tujuan notifikasi konsultasi baru."
/>
<Form
{...updateContact.form()}
options={{ preserveScroll: true }}
className="grid gap-4 sm:grid-cols-2"
>
{({ processing, errors }) => (
<>
<FormField
id="phone"
label="Telepon kantor"
error={errors.phone}
>
<Input
id="phone"
name="phone"
defaultValue={contact.phone}
required
/>
</FormField>
<FormField
id="whatsapp"
label="WhatsApp konsultasi"
error={errors.whatsapp}
>
<Input
id="whatsapp"
name="whatsapp"
defaultValue={contact.whatsapp}
required
/>
</FormField>
<FormField
id="email_busdev"
label="Email business development"
error={errors.email_busdev}
>
<Input
id="email_busdev"
name="email_busdev"
type="email"
defaultValue={contact.email_busdev}
required
/>
</FormField>
<FormField
id="email_support"
label="Email support"
error={errors.email_support}
>
<Input
id="email_support"
name="email_support"
type="email"
defaultValue={contact.email_support}
required
/>
</FormField>
<div className="flex justify-end sm:col-span-2">
<Button type="submit" disabled={processing}>
Simpan Kontak
</Button>
</div>
</>
)}
</Form>
</section>
<section className="flex flex-col gap-4">
<div className="flex items-end justify-between gap-3">
<Heading
variant="small"
title="Kantor"
description="Diurutkan berdasarkan nomor urut."
/>
<OfficeDialog
nextOrder={offices.length + 1}
trigger={
<Button>
<Plus /> Tambah Kantor
</Button>
}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
{offices.map((office) => (
<div
key={office.id}
className="bg-card flex flex-col gap-3 rounded-xl border p-5"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col">
<span className="font-display font-bold">
{office.name}
</span>
<span className="text-muted-foreground text-xs">
{office.district} · {office.phone}
</span>
</div>
<div className="flex gap-1">
<OfficeDialog
office={office}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Ubah"
>
<Pencil />
</Button>
}
/>
<ConfirmDelete
title="Hapus kantor ini?"
description={`${office.name} tidak akan tampil lagi di website.`}
form={destroy.form(office.id)}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Hapus"
className="text-destructive"
>
<Trash2 />
</Button>
}
/>
</div>
</div>
<p className="flex gap-2 text-sm">
<MapPin className="text-primary mt-0.5 size-4 shrink-0" />
{officeAddress(office)}
</p>
{office.maps_url && (
<a
href={office.maps_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary flex items-center gap-1 text-xs font-semibold hover:underline"
>
Buka di Google Maps
<ExternalLink className="size-3.5" />
</a>
)}
</div>
))}
</div>
</section>
</div>
</>
);
}
function OfficeDialog({
office,
nextOrder = 1,
trigger,
}: {
office?: Office;
nextOrder?: number;
trigger: ReactNode;
}) {
const id = (field: string) => `office-${office?.id ?? 'new'}-${field}`;
const text = (
field: keyof Office,
label: string,
errors: Partial<Record<string, string>>,
extra: { placeholder?: string; required?: boolean } = {
required: true,
},
) => (
<FormField id={id(field)} label={label} error={errors[field]}>
<Input
id={id(field)}
name={field}
defaultValue={office?.[field] ?? ''}
{...extra}
/>
</FormField>
);
return (
<FormDialog
trigger={trigger}
title={office ? 'Ubah Kantor' : 'Tambah Kantor'}
form={office ? update.form(office.id) : store.form()}
>
{(errors) => (
<>
{text('name', 'Nama kantor', errors)}
{text('building', 'Gedung / nomor', errors)}
{text('street', 'Jalan', errors)}
<div className="grid gap-4 sm:grid-cols-2">
{text('area', 'Kelurahan / kecamatan', errors)}
{text('city', 'Kota & kode pos', errors)}
{text('district', 'Label wilayah', errors, {
placeholder: 'Mis. Bintaro',
required: true,
})}
{text('phone', 'Telepon', errors)}
</div>
{text('maps_url', 'Link Google Maps (opsional)', errors, {
placeholder: 'https://maps.app.goo.gl/…',
})}
<div className="grid gap-4 sm:grid-cols-2">
{text('icon', 'Ikon (Material Symbols)', errors, {
placeholder: 'apartment',
required: true,
})}
<FormField
id={id('sort_order')}
label="Nomor urut"
error={errors.sort_order}
>
<Input
id={id('sort_order')}
name="sort_order"
type="number"
min={0}
defaultValue={office?.sort_order ?? nextOrder}
required
/>
</FormField>
</div>
</>
)}
</FormDialog>
);
}
CompanyEdit.layout = {
breadcrumbs: [{ title: 'Info Perusahaan', href: edit() }],
};
@@ -0,0 +1,327 @@
import { Form, Head, Link } from '@inertiajs/react';
import { Mail, MessageCircle, Phone, Trash2 } from 'lucide-react';
import { useState } from 'react';
import {
ConfirmDelete,
FormField,
NativeSelect,
PageHeader,
Textarea,
} from '@/components/admin/form-controls';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { telHref, whatsappHref } from '@/data/site';
import {
formatDate,
optionsOf,
sourceLabels,
statusBadgeClasses,
statusLabels,
} from '@/lib/admin-labels';
import { cn } from '@/lib/utils';
import { destroy, index, update } from '@/routes/admin/consultations';
import type { Consultation, ConsultationStatus, Paginated } from '@/types';
type Props = {
consultations: Paginated<Consultation>;
status: ConsultationStatus | null;
counts: Partial<Record<ConsultationStatus, number>>;
};
const timelineLabels: Record<string, string> = {
immediate: 'Segera (< 1 bulan)',
quarter: '1–3 bulan',
midterm: '3–6 bulan',
feasibility: 'Masih studi kelayakan',
};
export default function ConsultationsIndex({
consultations,
status,
counts,
}: Props) {
const [selectedId, setSelectedId] = useState<number | null>(
consultations.data[0]?.id ?? null,
);
const selected =
consultations.data.find((item) => item.id === selectedId) ??
consultations.data[0];
const total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
const filters: {
value: ConsultationStatus | null;
label: string;
count: number;
}[] = [
{ value: null, label: 'Semua', count: total },
...optionsOf(statusLabels).map(({ value, label }) => ({
value,
label,
count: counts[value] ?? 0,
})),
];
return (
<>
<Head title="Inbox Konsultasi" />
<div className="flex flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Inbox Konsultasi"
description="Permintaan dari form konsultasi di website. Notifikasi juga dikirim ke email busdev."
/>
<div className="flex flex-wrap gap-2">
{filters.map((filter) => (
<Link
key={filter.label}
href={index({
query: filter.value
? { status: filter.value }
: {},
})}
preserveScroll
className={cn(
'flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors',
status === filter.value
? 'bg-primary text-primary-foreground border-transparent'
: 'hover:bg-accent',
)}
>
{filter.label}
<span className="font-mono text-xs opacity-75">
{filter.count}
</span>
</Link>
))}
</div>
{consultations.data.length === 0 ? (
<div className="text-muted-foreground rounded-xl border border-dashed p-12 text-center text-sm">
Belum ada permintaan konsultasi di sini.
</div>
) : (
<div className="grid gap-4 lg:grid-cols-5">
<div className="bg-card flex flex-col rounded-xl border lg:col-span-2">
<ul className="divide-border divide-y">
{consultations.data.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() =>
setSelectedId(item.id)
}
className={cn(
'flex w-full flex-col gap-1 px-4 py-3 text-left transition-colors',
selected?.id === item.id
? 'bg-accent'
: 'hover:bg-accent/50',
)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold">
{item.name}
</span>
<Badge
className={
statusBadgeClasses[
item.status
]
}
>
{statusLabels[item.status]}
</Badge>
</div>
<span className="text-muted-foreground truncate text-xs">
{item.company ?? item.email}
</span>
<span className="text-muted-foreground text-xs">
{formatDate(
item.created_at,
true,
)}
{' · '}
{sourceLabels[item.source]}
</span>
</button>
</li>
))}
</ul>
{consultations.last_page > 1 && (
<div className="mt-auto flex items-center justify-between border-t px-4 py-3 text-sm">
<PageLink
href={consultations.prev_page_url}
label="Sebelumnya"
/>
<span className="text-muted-foreground text-xs">
Halaman {consultations.current_page} /{' '}
{consultations.last_page}
</span>
<PageLink
href={consultations.next_page_url}
label="Berikutnya"
/>
</div>
)}
</div>
{selected && (
<ConsultationDetail
key={selected.id}
consultation={selected}
/>
)}
</div>
)}
</div>
</>
);
}
function PageLink({ href, label }: { href: string | null; label: string }) {
if (!href) {
return <span className="text-muted-foreground/50">{label}</span>;
}
return (
<Link href={href} preserveScroll className="text-primary font-medium">
{label}
</Link>
);
}
function ConsultationDetail({ consultation }: { consultation: Consultation }) {
const details: [string, string | null][] = [
['Perusahaan', consultation.company],
['Jabatan', consultation.job_title],
['Industri', consultation.industry],
['Layanan', consultation.service],
[
'Target waktu',
consultation.timeline
? (timelineLabels[consultation.timeline] ??
consultation.timeline)
: null,
],
['Halaman asal', sourceLabels[consultation.source]],
['Bahasa', consultation.locale === 'en' ? 'English' : 'Indonesia'],
['Setuju NDA', consultation.nda_agreed ? 'Ya' : 'Tidak'],
];
return (
<div className="bg-card flex flex-col gap-6 rounded-xl border p-5 lg:col-span-3">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
Masuk {formatDate(consultation.created_at, true)}
</span>
<ConfirmDelete
title="Hapus permintaan ini?"
description={`Data konsultasi dari ${consultation.name} akan dihapus permanen.`}
form={destroy.form(consultation.id)}
trigger={
<Button
variant="ghost"
size="sm"
className="text-destructive"
>
<Trash2 /> Hapus
</Button>
}
/>
</div>
<h2 className="font-display text-lg font-bold">
{consultation.name}
</h2>
<div className="mt-2 flex flex-wrap gap-2">
<Button asChild size="sm" variant="outline">
<a href={`mailto:${consultation.email}`}>
<Mail /> {consultation.email}
</a>
</Button>
<Button asChild size="sm" variant="outline">
<a href={telHref(consultation.phone)}>
<Phone /> {consultation.phone}
</a>
</Button>
<Button asChild size="sm" variant="outline">
<a
href={whatsappHref(consultation.phone)}
target="_blank"
rel="noopener noreferrer"
>
<MessageCircle /> WhatsApp
</a>
</Button>
</div>
</div>
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
{details
.filter(([, value]) => value)
.map(([label, value]) => (
<div key={label} className="flex flex-col">
<dt className="text-muted-foreground text-xs">
{label}
</dt>
<dd className="text-sm font-medium">{value}</dd>
</div>
))}
</dl>
{consultation.details && (
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">
Kebutuhan
</span>
<p className="bg-muted/50 rounded-lg p-3 text-sm whitespace-pre-line">
{consultation.details}
</p>
</div>
)}
<Form
{...update.form(consultation.id)}
options={{ preserveScroll: true }}
className="flex flex-col gap-4 border-t pt-5"
>
{({ processing, errors }) => (
<>
<FormField
id="status"
label="Status tindak lanjut"
error={errors.status}
>
<NativeSelect
id="status"
name="status"
defaultValue={consultation.status}
options={optionsOf(statusLabels)}
/>
</FormField>
<FormField
id="admin_notes"
label="Catatan internal"
error={errors.admin_notes}
>
<Textarea
id="admin_notes"
name="admin_notes"
defaultValue={consultation.admin_notes ?? ''}
placeholder="Mis. sudah ditelepon, jadwal meeting 3 Okt."
/>
</FormField>
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
);
}
ConsultationsIndex.layout = {
breadcrumbs: [{ title: 'Inbox Konsultasi', href: index() }],
};
+290
View File
@@ -0,0 +1,290 @@
import { Head } from '@inertiajs/react';
import { Pencil, Plus, Search, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import {
ConfirmDelete,
FormDialog,
FormField,
NativeSelect,
PageHeader,
Textarea,
} from '@/components/admin/form-controls';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { portfolio } from '@/data/projects';
import { optionsOf, programLabels, sectorLabels } from '@/lib/admin-labels';
import { destroy, index, store, update } from '@/routes/admin/projects';
import type { Project, ProjectSector } from '@/types';
export default function ProjectsIndex({ projects }: { projects: Project[] }) {
const [query, setQuery] = useState('');
const [sector, setSector] = useState<ProjectSector | ''>('');
const stats = portfolio(projects);
const visible = useMemo(() => {
const needle = query.trim().toLowerCase();
return projects.filter(
(project) =>
(!sector || project.sector === sector) &&
(!needle ||
`${project.title} ${project.client} ${project.year}`
.toLowerCase()
.includes(needle)),
);
}, [projects, query, sector]);
return (
<>
<Head title="Portofolio" />
<div className="flex flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Portofolio Proyek"
description={`${stats.projectCount} proyek dari ${stats.clientCount} klien. Angka di website dihitung otomatis dari daftar ini.`}
action={
<ProjectDialog
trigger={
<Button>
<Plus /> Tambah Proyek
</Button>
}
/>
}
/>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Cari judul, klien, atau tahun…"
className="pl-9"
/>
</div>
<NativeSelect
value={sector}
onChange={(event) =>
setSector(event.target.value as ProjectSector | '')
}
options={[
{ value: '', label: 'Semua sektor' },
...optionsOf(sectorLabels),
]}
className="sm:w-48"
/>
</div>
<div className="bg-card overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="text-muted-foreground border-b text-left text-xs">
<tr>
<th className="px-4 py-3 font-medium">Tahun</th>
<th className="px-4 py-3 font-medium">
Proyek
</th>
<th className="px-4 py-3 font-medium">
Sektor
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-border divide-y">
{visible.map((project) => (
<tr key={project.id} className="align-top">
<td className="px-4 py-3 font-mono text-xs font-bold">
{project.year}
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<span className="font-semibold">
{project.title}
</span>
<span className="text-muted-foreground text-xs">
{project.client}
</span>
{project.scope && (
<span className="text-muted-foreground text-xs italic">
{project.scope}
</span>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
<Badge variant="outline">
{sectorLabels[project.sector]}
</Badge>
{project.program && (
<Badge variant="secondary">
{
programLabels[
project.program
]
}
</Badge>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<ProjectDialog
project={project}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Ubah"
>
<Pencil />
</Button>
}
/>
<ConfirmDelete
title="Hapus proyek ini?"
description={`"${project.title}" akan dihapus dari portofolio website.`}
form={destroy.form(project.id)}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Hapus"
className="text-destructive"
>
<Trash2 />
</Button>
}
/>
</div>
</td>
</tr>
))}
{visible.length === 0 && (
<tr>
<td
colSpan={4}
className="text-muted-foreground px-4 py-12 text-center"
>
Tidak ada proyek yang cocok.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}
function ProjectDialog({
project,
trigger,
}: {
project?: Project;
trigger: ReactNode;
}) {
const id = (field: string) => `project-${project?.id ?? 'new'}-${field}`;
return (
<FormDialog
trigger={trigger}
title={project ? 'Ubah Proyek' : 'Tambah Proyek'}
form={project ? update.form(project.id) : store.form()}
>
{(errors) => (
<>
<FormField
id={id('title')}
label="Judul proyek"
error={errors.title}
>
<Textarea
id={id('title')}
name="title"
rows={2}
defaultValue={project?.title}
required
/>
</FormField>
<FormField
id={id('client')}
label="Klien"
error={errors.client}
>
<Input
id={id('client')}
name="client"
defaultValue={project?.client}
required
/>
</FormField>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
id={id('year')}
label="Tahun"
error={errors.year}
>
<Input
id={id('year')}
name="year"
type="number"
min={1985}
max={new Date().getFullYear() + 1}
defaultValue={
project?.year ?? new Date().getFullYear()
}
required
/>
</FormField>
<FormField
id={id('sector')}
label="Sektor"
error={errors.sector}
>
<NativeSelect
id={id('sector')}
name="sector"
defaultValue={project?.sector ?? 'government'}
options={optionsOf(sectorLabels)}
/>
</FormField>
</div>
<FormField
id={id('program')}
label="Program unggulan (opsional)"
error={errors.program}
>
<NativeSelect
id={id('program')}
name="program"
defaultValue={project?.program ?? ''}
options={[
{ value: '', label: '— Tidak ada —' },
...optionsOf(programLabels),
]}
/>
</FormField>
<FormField
id={id('scope')}
label="Cakupan (opsional)"
error={errors.scope}
>
<Input
id={id('scope')}
name="scope"
defaultValue={project?.scope ?? ''}
placeholder="Mis. Odoo Enterprise → POS, Penjualan → Custom Development"
/>
</FormField>
</>
)}
</FormDialog>
);
}
ProjectsIndex.layout = {
breadcrumbs: [{ title: 'Portofolio', href: index() }],
};
@@ -0,0 +1,244 @@
import { Head } from '@inertiajs/react';
import { Pencil, Plus, Quote, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import {
ConfirmDelete,
FormDialog,
FormField,
PageHeader,
Textarea,
} from '@/components/admin/form-controls';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { destroy, index, store, update } from '@/routes/admin/testimonials';
import type { Testimonial } from '@/types';
export default function TestimonialsIndex({
testimonials,
}: {
testimonials: Testimonial[];
}) {
return (
<>
<Head title="Testimoni" />
<div className="flex flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Testimoni"
description="Bagian testimoni di halaman Klien hanya muncul jika ada testimoni yang dipublikasikan. Gunakan hanya testimoni asli yang sudah disetujui klien."
action={
<TestimonialDialog
nextOrder={testimonials.length + 1}
trigger={
<Button>
<Plus /> Tambah Testimoni
</Button>
}
/>
}
/>
{testimonials.length === 0 ? (
<div className="text-muted-foreground rounded-xl border border-dashed p-12 text-center text-sm">
Belum ada testimoni. Bagian testimoni sedang
disembunyikan di website.
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{testimonials.map((testimonial) => (
<div
key={testimonial.id}
className="bg-card flex flex-col gap-3 rounded-xl border p-5"
>
<div className="flex items-start justify-between gap-2">
<Quote className="text-primary size-5" />
<div className="flex items-center gap-1">
<Badge
variant={
testimonial.is_published
? 'default'
: 'secondary'
}
>
{testimonial.is_published
? 'Tayang'
: 'Draf'}
</Badge>
<TestimonialDialog
testimonial={testimonial}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Ubah"
>
<Pencil />
</Button>
}
/>
<ConfirmDelete
title="Hapus testimoni ini?"
description={`Testimoni dari ${testimonial.organization_id} akan dihapus permanen.`}
form={destroy.form(testimonial.id)}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Hapus"
className="text-destructive"
>
<Trash2 />
</Button>
}
/>
</div>
</div>
<p className="text-sm italic">
“{testimonial.quote_id}”
</p>
<span className="text-muted-foreground text-xs">
{testimonial.role_id} ·{' '}
{testimonial.organization_id}
</span>
</div>
))}
</div>
)}
</div>
</>
);
}
function TestimonialDialog({
testimonial,
nextOrder = 1,
trigger,
}: {
testimonial?: Testimonial;
nextOrder?: number;
trigger: ReactNode;
}) {
const id = (field: string) =>
`testimonial-${testimonial?.id ?? 'new'}-${field}`;
return (
<FormDialog
trigger={trigger}
title={testimonial ? 'Ubah Testimoni' : 'Tambah Testimoni'}
description="Isi versi Bahasa Indonesia dan English; website menampilkan sesuai bahasa pengunjung."
form={testimonial ? update.form(testimonial.id) : store.form()}
className="sm:max-w-2xl"
>
{(errors) => (
<>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
id={id('quote_id')}
label="Kutipan (ID)"
error={errors.quote_id}
>
<Textarea
id={id('quote_id')}
name="quote_id"
defaultValue={testimonial?.quote_id}
required
/>
</FormField>
<FormField
id={id('quote_en')}
label="Quote (EN)"
error={errors.quote_en}
>
<Textarea
id={id('quote_en')}
name="quote_en"
defaultValue={testimonial?.quote_en}
required
/>
</FormField>
<FormField
id={id('role_id')}
label="Jabatan (ID)"
error={errors.role_id}
>
<Input
id={id('role_id')}
name="role_id"
defaultValue={testimonial?.role_id}
required
/>
</FormField>
<FormField
id={id('role_en')}
label="Role (EN)"
error={errors.role_en}
>
<Input
id={id('role_en')}
name="role_en"
defaultValue={testimonial?.role_en}
required
/>
</FormField>
<FormField
id={id('organization_id')}
label="Organisasi (ID)"
error={errors.organization_id}
>
<Input
id={id('organization_id')}
name="organization_id"
defaultValue={testimonial?.organization_id}
required
/>
</FormField>
<FormField
id={id('organization_en')}
label="Organization (EN)"
error={errors.organization_en}
>
<Input
id={id('organization_en')}
name="organization_en"
defaultValue={testimonial?.organization_en}
required
/>
</FormField>
<FormField
id={id('sort_order')}
label="Nomor urut"
error={errors.sort_order}
>
<Input
id={id('sort_order')}
name="sort_order"
type="number"
min={0}
defaultValue={
testimonial?.sort_order ?? nextOrder
}
required
/>
</FormField>
<label className="flex items-center gap-2 self-end pb-2 text-sm font-medium">
<input
type="checkbox"
name="is_published"
value="1"
defaultChecked={
testimonial?.is_published ?? true
}
className="accent-primary size-4"
/>
Tampilkan di website
</label>
</div>
</>
)}
</FormDialog>
);
}
TestimonialsIndex.layout = {
breadcrumbs: [{ title: 'Testimoni', href: index() }],
};
+266
View File
@@ -0,0 +1,266 @@
import { Head, usePage } from '@inertiajs/react';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import {
ConfirmDelete,
FormDialog,
FormField,
NativeSelect,
PageHeader,
} from '@/components/admin/form-controls';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { formatDate, optionsOf, roleLabels } from '@/lib/admin-labels';
import { destroy, index, store, update } from '@/routes/admin/users';
import type { UserRole } from '@/types';
type PanelUser = {
id: number;
name: string;
email: string;
role: UserRole;
created_at: string;
};
const roleDescriptions: Record<UserRole, string> = {
admin: 'Semua menu, termasuk kelola pengguna.',
editor: 'Konten website dan inbox konsultasi.',
};
export default function UsersIndex({ users }: { users: PanelUser[] }) {
const { auth } = usePage().props;
return (
<>
<Head title="Pengguna" />
<div className="flex flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Pengguna"
description="Akun yang dapat masuk ke admin panel. Registrasi publik dinonaktifkan."
action={
<UserDialog
trigger={
<Button>
<Plus /> Tambah Pengguna
</Button>
}
/>
}
/>
<div className="bg-card overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="text-muted-foreground border-b text-left text-xs">
<tr>
<th className="px-4 py-3 font-medium">Nama</th>
<th className="px-4 py-3 font-medium">Peran</th>
<th className="px-4 py-3 font-medium">
Dibuat
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-border divide-y">
{users.map((user) => {
const isSelf = user.id === auth.user?.id;
return (
<tr key={user.id}>
<td className="px-4 py-3">
<div className="flex flex-col">
<span className="font-semibold">
{user.name}
{isSelf && (
<span className="text-muted-foreground font-normal">
{' '}
(Anda)
</span>
)}
</span>
<span className="text-muted-foreground text-xs">
{user.email}
</span>
</div>
</td>
<td className="px-4 py-3">
<Badge
variant={
user.role === 'admin'
? 'default'
: 'secondary'
}
>
{roleLabels[user.role]}
</Badge>
</td>
<td className="text-muted-foreground px-4 py-3 text-xs">
{formatDate(user.created_at)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<UserDialog
user={user}
isSelf={isSelf}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Ubah"
>
<Pencil />
</Button>
}
/>
{!isSelf && (
<ConfirmDelete
title="Hapus pengguna ini?"
description={`${user.name} tidak akan bisa masuk ke admin panel lagi.`}
form={destroy.form(
user.id,
)}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Hapus"
className="text-destructive"
>
<Trash2 />
</Button>
}
/>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{optionsOf(roleLabels).map(({ value, label }) => (
<div
key={value}
className="text-muted-foreground rounded-lg border border-dashed p-4 text-sm"
>
<span className="text-foreground font-semibold">
{label}:
</span>{' '}
{roleDescriptions[value]}
</div>
))}
</div>
</div>
</>
);
}
function UserDialog({
user,
isSelf = false,
trigger,
}: {
user?: PanelUser;
isSelf?: boolean;
trigger: ReactNode;
}) {
const id = (field: string) => `user-${user?.id ?? 'new'}-${field}`;
return (
<FormDialog
trigger={trigger}
title={user ? 'Ubah Pengguna' : 'Tambah Pengguna'}
form={user ? update.form(user.id) : store.form()}
>
{(errors) => (
<>
<FormField id={id('name')} label="Nama" error={errors.name}>
<Input
id={id('name')}
name="name"
defaultValue={user?.name}
required
/>
</FormField>
<FormField
id={id('email')}
label="Email"
error={errors.email}
>
<Input
id={id('email')}
name="email"
type="email"
defaultValue={user?.email}
required
/>
</FormField>
<FormField
id={id('role')}
label="Peran"
error={errors.role}
>
<NativeSelect
id={id('role')}
name="role"
defaultValue={user?.role ?? 'editor'}
options={optionsOf(roleLabels)}
disabled={isSelf}
/>
{isSelf && (
<>
<input
type="hidden"
name="role"
value={user?.role}
/>
<p className="text-muted-foreground text-xs">
Anda tidak dapat mengubah peran akun
sendiri.
</p>
</>
)}
</FormField>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
id={id('password')}
label={user ? 'Password baru' : 'Password'}
error={errors.password}
>
<Input
id={id('password')}
name="password"
type="password"
autoComplete="new-password"
placeholder={
user ? 'Kosongkan jika tidak diubah' : ''
}
required={!user}
/>
</FormField>
<FormField
id={id('password_confirmation')}
label="Ulangi password"
error={errors.password_confirmation}
>
<Input
id={id('password_confirmation')}
name="password_confirmation"
type="password"
autoComplete="new-password"
required={!user}
/>
</FormField>
</div>
</>
)}
</FormDialog>
);
}
UsersIndex.layout = {
breadcrumbs: [{ title: 'Pengguna', href: index() }],
};
+112 -60
View File
@@ -1,117 +1,169 @@
import { Form, Head } from '@inertiajs/react';
import { Form, Head, Link } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { register } from '@/routes';
import { Button } from '@/components/site/button';
import Card from '@/components/site/card';
import { TextInput } from '@/components/site/form-field';
import Glow, { GridPattern } from '@/components/site/glow';
import Icon from '@/components/site/icon';
import LanguageToggle from '@/components/site/language-toggle';
import ThemeToggle from '@/components/site/theme-toggle';
import { common } from '@/content/common';
import { company } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { home } from '@/routes';
import { store } from '@/routes/login';
import { request } from '@/routes/password';
import PasskeyVerify from '@/components/passkey-verify';
type Props = {
status?: string;
canResetPassword: boolean;
};
/**
* Admin panel sign-in. Public registration is disabled; admin accounts are
* created with `php artisan admin:create`.
*/
export default function Login({ status, canResetPassword }: Props) {
return (
<>
<Head title="Log in" />
const t = useContent(common).login;
<PasskeyVerify />
return (
<div className="probindo-site bg-surface-container-lowest text-on-surface relative flex min-h-svh flex-col items-center justify-center overflow-hidden p-6 font-sans antialiased">
<Head title={t.pageTitle} />
<GridPattern />
<Glow className="-top-40 -left-20 h-[500px] w-[640px]" />
<Glow className="bg-primary/15 -right-20 bottom-0 h-[420px] w-[520px]" />
<div className="absolute top-5 right-5 flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
</div>
<div className="relative flex w-full max-w-md flex-col gap-6">
<Link
href={home()}
className="flex flex-col items-center gap-3 self-center"
>
<div className="border-outline-variant/50 dark:bg-surface-container-low rounded-2xl border bg-white p-3">
<img
src={company.logo}
alt={company.name}
className="h-12 w-auto object-contain"
/>
</div>
<span className="text-on-surface-variant font-mono text-xs font-medium">
{company.name}
</span>
</Link>
<Card padding="lg" className="shadow-xl">
<div className="mb-6 flex flex-col gap-1 text-center">
<h1 className="font-display text-on-surface text-2xl font-extrabold">
{t.title}
</h1>
<p className="text-on-surface-variant text-sm">
{t.description}
</p>
</div>
{status && (
<div
role="status"
className="border-primary/40 text-on-surface mb-4 rounded-lg border p-3 text-center text-sm"
>
{status}
</div>
)}
<Form
{...store.form()}
resetOnSuccess={['password']}
className="flex flex-col gap-6"
className="flex flex-col gap-5"
>
{({ processing, errors }) => (
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
<div className="flex flex-col gap-1.5">
<label
htmlFor="email"
className="text-on-surface font-mono text-xs font-medium"
>
{t.email}
</label>
<TextInput
id="email"
type="email"
name="email"
required
autoFocus
tabIndex={1}
autoComplete="email"
placeholder="email@example.com"
placeholder={t.emailPlaceholder}
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
{canResetPassword && (
<TextLink
href={request()}
className="ml-auto text-sm"
tabIndex={5}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label
htmlFor="password"
className="text-on-surface font-mono text-xs font-medium"
>
Forgot your password?
</TextLink>
{t.password}
</label>
{canResetPassword && (
<Link
href={request()}
className="text-primary text-xs font-semibold hover:underline"
>
{t.forgot}
</Link>
)}
</div>
<PasswordInput
id="password"
name="password"
required
tabIndex={2}
autoComplete="current-password"
placeholder="Password"
placeholder={t.passwordPlaceholder}
className="border-outline-variant/60 bg-surface-container-lowest text-on-surface focus-visible:border-primary h-auto rounded-xl px-4 py-2.5 text-sm"
/>
<InputError message={errors.password} />
</div>
<div className="flex items-center space-x-3">
<Checkbox
id="remember"
<label className="text-on-surface-variant flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
name="remember"
tabIndex={3}
className="h-4 w-4"
/>
<Label htmlFor="remember">Remember me</Label>
</div>
{t.remember}
</label>
<Button
type="submit"
className="mt-4 w-full"
tabIndex={4}
size="block"
icon={
processing
? 'progress_activity'
: 'login'
}
disabled={processing}
data-test="login-button"
>
{processing && <Spinner />}
Log in
{t.submit}
</Button>
</div>
<div className="text-muted-foreground text-center text-sm">
Don't have an account?{' '}
<TextLink href={register()} tabIndex={5}>
Sign up
</TextLink>
</div>
</>
)}
</Form>
</Card>
{status && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
{status}
<Link
href={home()}
className="text-on-surface-variant hover:text-primary flex items-center gap-1.5 self-center text-sm"
>
<Icon name="arrow_back" className="text-base" />
{t.back}
</Link>
</div>
</div>
)}
</>
);
}
Login.layout = {
title: 'Log in to your account',
description: 'Enter your email and password below to log in',
};
-120
View File
@@ -1,120 +0,0 @@
import { Form, Head } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { login } from '@/routes';
import { store } from '@/routes/register';
type Props = {
passwordRules: string;
};
export default function Register({ passwordRules }: Props) {
return (
<>
<Head title="Register" />
<Form
{...store.form()}
resetOnSuccess={['password', 'password_confirmation']}
disableWhileProcessing
className="flex flex-col gap-6"
>
{({ processing, errors }) => (
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
required
autoFocus
tabIndex={1}
autoComplete="name"
name="name"
placeholder="Full name"
/>
<InputError
message={errors.name}
className="mt-2"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
required
tabIndex={2}
autoComplete="email"
name="email"
placeholder="email@example.com"
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
required
tabIndex={3}
autoComplete="new-password"
name="password"
placeholder="Password"
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
</Label>
<PasswordInput
id="password_confirmation"
required
tabIndex={4}
autoComplete="new-password"
name="password_confirmation"
placeholder="Confirm password"
passwordrules={passwordRules}
/>
<InputError
message={errors.password_confirmation}
/>
</div>
<Button
type="submit"
className="mt-2 w-full"
tabIndex={5}
data-test="register-user-button"
>
{processing && <Spinner />}
Create account
</Button>
</div>
<div className="text-muted-foreground text-center text-sm">
Already have an account?{' '}
<TextLink href={login()} tabIndex={6}>
Log in
</TextLink>
</div>
</>
)}
</Form>
</>
);
}
Register.layout = {
title: 'Create an account',
description: 'Enter your details below to create your account',
};
+283 -14
View File
@@ -1,25 +1,294 @@
import { Head } from '@inertiajs/react';
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
import { dashboard } from '@/routes';
import { Head, Link, usePage } from '@inertiajs/react';
import {
ArrowUpRight,
Building2,
FolderKanban,
Inbox,
Layers,
Users,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { portfolio, yearRange } from '@/data/projects';
import {
formatDate,
sectorLabels,
statusBadgeClasses,
statusLabels,
} from '@/lib/admin-labels';
import { about, clients, contact, dashboard, home, services } from '@/routes';
import { index as consultationsIndex } from '@/routes/admin/consultations';
import { index as projectsIndex } from '@/routes/admin/projects';
import type { Consultation, Project, ProjectSector } from '@/types';
type DashboardProps = {
projects: Project[];
newConsultations: number;
latestConsultations: Pick<
Consultation,
'id' | 'name' | 'company' | 'service' | 'status' | 'created_at'
>[];
};
const sitePages = [
{ title: 'Beranda', href: home() },
{ title: 'Tentang Kami', href: about() },
{ title: 'Jasa', href: services() },
{ title: 'Klien & Portofolio', href: clients() },
{ title: 'Kontak', href: contact() },
];
function StatCard({
icon: Icon,
label,
value,
note,
}: {
icon: LucideIcon;
label: string;
value: string | number;
note: string;
}) {
return (
<div className="bg-card border-sidebar-border/70 flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-mono text-xs font-semibold tracking-wider uppercase">
{label}
</span>
<span className="bg-accent text-accent-foreground flex size-9 items-center justify-center rounded-lg">
<Icon className="size-4" />
</span>
</div>
<span className="font-display text-3xl font-extrabold">
{value}
</span>
<span className="text-muted-foreground text-xs">{note}</span>
</div>
);
}
export default function Dashboard({
projects,
newConsultations,
latestConsultations,
}: DashboardProps) {
const { auth } = usePage().props;
const {
clientCount,
clientsInSector,
projectsBySector,
projectsInProgram,
} = portfolio(projects);
const sectors: ProjectSector[] = ['government', 'private', 'soe'];
const erpProjects = projectsInProgram('erp');
const latest = projects.slice(0, 5);
export default function Dashboard() {
return (
<>
<Head title="Dashboard" />
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="from-primary relative overflow-hidden rounded-xl bg-gradient-to-r to-[#c81e2a] p-6 text-white md:p-8">
<div className="pointer-events-none absolute -top-16 -right-16 size-56 rounded-full bg-white/10 blur-2xl" />
<div className="relative flex flex-col gap-2">
<span className="font-mono text-xs font-semibold tracking-wider text-white/80 uppercase">
Admin Panel · PT Probindo Artika Jaya
</span>
<h1 className="font-display text-2xl font-extrabold md:text-3xl">
Selamat datang, {auth.user?.name}
</h1>
<p className="max-w-2xl text-sm text-white/85">
Ringkasan permintaan konsultasi dan rekam jejak
proyek yang ditampilkan di website.
</p>
</div>
<div className="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<div className="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
icon={FolderKanban}
label="Total Proyek"
value={projects.length}
note={`Periode ${yearRange(projects)}`}
/>
<StatCard
icon={Users}
label="Total Klien"
value={clientCount}
note="Pemerintahan, BUMN & swasta"
/>
<StatCard
icon={Layers}
label="Pengalaman ERP"
value={erpProjects.length}
note={`Periode ${yearRange(erpProjects)}`}
/>
<StatCard
icon={Inbox}
label="Konsultasi Baru"
value={newConsultations}
note="Belum ditindaklanjuti"
/>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-2">
<div className="bg-card border-sidebar-border/70 rounded-xl border p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-display text-base font-bold">
Konsultasi Terbaru
</h2>
<Link
href={consultationsIndex()}
className="text-primary flex items-center gap-1 text-xs font-semibold hover:underline"
>
Buka inbox
<ArrowUpRight className="size-3.5" />
</Link>
</div>
{latestConsultations.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Belum ada permintaan konsultasi.
</p>
) : (
<ul className="divide-border divide-y">
{latestConsultations.map((item) => (
<li
key={item.id}
className="flex items-center gap-4 py-3"
>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold">
{item.name}
{item.company &&
` · ${item.company}`}
</span>
<span className="text-muted-foreground truncate text-xs">
{formatDate(
item.created_at,
)}
{item.service &&
` · ${item.service}`}
</span>
</div>
<Badge
className={
statusBadgeClasses[
item.status
]
}
>
{statusLabels[item.status]}
</Badge>
</li>
))}
</ul>
)}
</div>
<div className="bg-card border-sidebar-border/70 rounded-xl border p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-display text-base font-bold">
Proyek Terbaru
</h2>
<Link
href={projectsIndex()}
className="text-primary flex items-center gap-1 text-xs font-semibold hover:underline"
>
Kelola portofolio
<ArrowUpRight className="size-3.5" />
</Link>
</div>
<ul className="divide-border divide-y">
{latest.map((project) => (
<li
key={project.id}
className="flex items-start gap-4 py-3"
>
<span className="bg-accent text-accent-foreground shrink-0 rounded-md px-2 py-0.5 font-mono text-xs font-bold">
{project.year}
</span>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold">
{project.title}
</span>
<span className="text-muted-foreground truncate text-xs">
{project.client} ·{' '}
{sectorLabels[project.sector]}
</span>
</div>
</li>
))}
</ul>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="bg-card border-sidebar-border/70 rounded-xl border p-5">
<h2 className="font-display mb-4 text-base font-bold">
Proyek per Sektor
</h2>
<div className="flex flex-col gap-4">
{sectors.map((sector) => {
const count =
projectsBySector(sector).length;
const percent = projects.length
? Math.round(
(count / projects.length) * 100,
)
: 0;
return (
<div
key={sector}
className="flex flex-col gap-1.5"
>
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2 font-medium">
<Building2 className="text-primary size-4" />
{sectorLabels[sector]}
</span>
<span className="text-muted-foreground font-mono text-xs">
{count} proyek ·{' '}
{
clientsInSector(sector)
.length
}{' '}
klien
</span>
</div>
<div className="bg-muted h-2 overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full"
style={{
width: `${percent}%`,
}}
/>
</div>
</div>
);
})}
</div>
</div>
<div className="bg-card border-sidebar-border/70 rounded-xl border p-5">
<h2 className="font-display mb-3 text-base font-bold">
Halaman Website
</h2>
<div className="flex flex-col">
{sitePages.map((page) => (
<a
key={page.title}
href={page.href.url}
target="_blank"
rel="noopener noreferrer"
className="hover:bg-accent hover:text-accent-foreground flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors"
>
{page.title}
<ArrowUpRight className="size-4 opacity-60" />
</a>
))}
</div>
</div>
</div>
<div className="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border md:min-h-min">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
</div>
</>
+10 -5
View File
@@ -14,13 +14,15 @@ import SectionHeader from '@/components/site/section-header';
import CtaBanner from '@/components/site/sections/cta-banner';
import StatCard from '@/components/site/stat-card';
import { aboutContent } from '@/content/about';
import { clientsInSector, projectsBySector } from '@/data/projects';
import { fillCounts } from '@/data/projects';
import { consultationUrl } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { usePortfolio } from '@/hooks/use-portfolio';
import { clients, services } from '@/routes';
export default function About() {
const t = useContent(aboutContent);
const stats = usePortfolio();
const t = fillCounts(useContent(aboutContent), stats);
return (
<>
@@ -178,7 +180,9 @@ export default function About() {
/>
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
{t.industries.items.map((item) => {
const clientNames = clientsInSector(item.sector);
const clientNames = stats.clientsInSector(
item.sector,
);
return (
<Card
@@ -196,8 +200,9 @@ export default function About() {
{clientNames.length}{' '}
{t.industries.clients} ·{' '}
{
projectsBySector(item.sector)
.length
stats.projectsBySector(
item.sector,
).length
}{' '}
{t.industries.projects}
</span>
+40 -27
View File
@@ -1,4 +1,4 @@
import { Head } from '@inertiajs/react';
import { Head, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { Button, ButtonLink } from '@/components/site/button';
import Card from '@/components/site/card';
@@ -13,21 +13,22 @@ import Section from '@/components/site/section';
import SectionHeader from '@/components/site/section-header';
import CtaBanner from '@/components/site/sections/cta-banner';
import { clientsContent } from '@/content/clients';
import type { ProjectSector } from '@/data/projects';
import {
projectsBySector,
projectsInProgram,
yearRange,
} from '@/data/projects';
import { company, consultationUrl, officeAddress, offices } from '@/data/site';
import type { Portfolio } from '@/data/projects';
import { fillCounts, yearRange } from '@/data/projects';
import { company, consultationUrl, officeAddress } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { usePortfolio } from '@/hooks/use-portfolio';
import { useSiteInfo } from '@/hooks/use-site-info';
import { cn } from '@/lib/utils';
import { contact } from '@/routes';
import type { LocalizedTestimonial, ProjectSector } from '@/types';
type Content = typeof clientsContent.id;
export default function Clients() {
const t = useContent(clientsContent);
const stats = usePortfolio();
const t = fillCounts(useContent(clientsContent), stats);
const site = useSiteInfo();
return (
<>
@@ -94,9 +95,9 @@ export default function Clients() {
</div>
</Section>
<SectorPortfolio t={t} />
<SectorPortfolio t={t} stats={stats} />
<FeaturedPrograms t={t} />
<FeaturedPrograms t={t} stats={stats} />
<Section tone="muted">
<div className="flex flex-col gap-12">
@@ -194,28 +195,28 @@ export default function Clients() {
{t.office.description}
</p>
<div className="text-on-surface mt-2 flex flex-col gap-2 font-mono text-sm">
{offices.map((office) => (
{site.offices.map((office) => (
<a
key={office.name}
href={office.phone.href}
href={office.phoneHref}
className="hover:text-primary flex items-center gap-2"
>
<Icon
name="call"
className="text-primary text-base"
/>
{office.phone.label}
{office.phone}
</a>
))}
<a
href={`mailto:${company.email.busdev}`}
href={`mailto:${site.email.busdev}`}
className="hover:text-primary flex items-center gap-2"
>
<Icon
name="mail"
className="text-primary text-base"
/>
{company.email.busdev}
{site.email.busdev}
</a>
</div>
</div>
@@ -227,7 +228,7 @@ export default function Clients() {
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="relative z-10 grid w-full grid-cols-1 gap-3 sm:grid-cols-2">
{offices.map((office) => (
{site.offices.map((office) => (
<div
key={office.name}
className="bg-surface-container-lowest/90 rounded-xl p-4 shadow-lg backdrop-blur-md"
@@ -250,11 +251,11 @@ export default function Clients() {
const PREVIEW_COUNT = 8;
function SectorPortfolio({ t }: { t: Content }) {
function SectorPortfolio({ t, stats }: { t: Content; stats: Portfolio }) {
const sectors: ProjectSector[] = ['government', 'private', 'soe'];
const [sector, setSector] = useState<ProjectSector>('government');
const [expanded, setExpanded] = useState(false);
const items = projectsBySector(sector);
const items = stats.projectsBySector(sector);
const visible = expanded ? items : items.slice(0, PREVIEW_COUNT);
const selectSector = (next: ProjectSector) => {
@@ -289,7 +290,7 @@ function SectorPortfolio({ t }: { t: Content }) {
)}
>
{t.portfolio.sectors[option]} (
{projectsBySector(option).length})
{stats.projectsBySector(option).length})
</button>
))}
</div>
@@ -360,7 +361,7 @@ function SectorPortfolio({ t }: { t: Content }) {
const RELATED_PREVIEW = 4;
function FeaturedPrograms({ t }: { t: Content }) {
function FeaturedPrograms({ t, stats }: { t: Content; stats: Portfolio }) {
return (
<Section>
<div className="flex flex-col gap-12">
@@ -371,7 +372,7 @@ function FeaturedPrograms({ t }: { t: Content }) {
/>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{t.cases.items.map((item, index) => {
const related = projectsInProgram(item.program);
const related = stats.projectsInProgram(item.program);
const remaining = related.length - RELATED_PREVIEW;
return (
@@ -456,9 +457,13 @@ function FeaturedPrograms({ t }: { t: Content }) {
);
}
/** Hidden until real client testimonials are added to the content file. */
/** Shown only once published testimonials exist (admin → Testimoni). */
function Testimonials({ t }: { t: Content }) {
if (t.testimonials.items.length === 0) {
const { testimonials } = usePage<{
testimonials: LocalizedTestimonial[];
}>().props;
if (testimonials.length === 0) {
return null;
}
@@ -472,9 +477,9 @@ function Testimonials({ t }: { t: Content }) {
description={t.testimonials.description}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{t.testimonials.items.map((testimonial) => (
{testimonials.map((testimonial) => (
<Card
key={testimonial.initials}
key={testimonial.id}
className="flex flex-col justify-between gap-6"
>
<div className="space-y-4">
@@ -492,7 +497,7 @@ function Testimonials({ t }: { t: Content }) {
</div>
<div className="flex items-center gap-3">
<div className="bg-surface-container text-primary flex h-10 w-10 items-center justify-center rounded-full font-mono font-bold">
{testimonial.initials}
{initials(testimonial.role)}
</div>
<div>
<div className="text-on-surface text-sm font-bold">
@@ -511,6 +516,14 @@ function Testimonials({ t }: { t: Content }) {
);
}
const initials = (text: string) =>
text
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join('');
function RetentionGauge({ t }: { t: Content }) {
return (
<div className="bg-surface-container flex w-full flex-col items-center justify-center rounded-xl p-6 lg:w-96">
+91 -36
View File
@@ -1,11 +1,11 @@
import { Head } from '@inertiajs/react';
import { Form, Head } from '@inertiajs/react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Button, ButtonLink } from '@/components/site/button';
import Card from '@/components/site/card';
import Eyebrow from '@/components/site/eyebrow';
import {
Field,
Honeypot,
SelectInput,
TextArea,
TextInput,
@@ -19,13 +19,16 @@ import SectionHeader from '@/components/site/section-header';
import StepCard from '@/components/site/step-card';
import { common, serviceOptions } from '@/content/common';
import { contactContent } from '@/content/contact';
import { company, officeAddress, offices } from '@/data/site';
import { officeAddress } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { useSiteInfo } from '@/hooks/use-site-info';
import { cn } from '@/lib/utils';
import { store } from '@/routes/consultations';
type Content = typeof contactContent.id;
export default function Contact() {
const site = useSiteInfo();
const t = useContent(contactContent);
return (
@@ -186,19 +189,19 @@ export default function Contact() {
</p>
<div className="flex flex-wrap items-center justify-center gap-4 pt-2">
<ButtonLink
href={company.whatsapp.href}
href={site.whatsapp.href}
size="sm"
icon="chat"
>
{t.faq.whatsapp} ({company.whatsapp.label})
{t.faq.whatsapp} ({site.whatsapp.label})
</ButtonLink>
<ButtonLink
href={company.phone.href}
href={site.phone.href}
variant="secondary"
size="sm"
icon="call"
>
{t.faq.phone} ({company.phone.label})
{t.faq.phone} ({site.phone.label})
</ButtonLink>
</div>
</div>
@@ -217,6 +220,7 @@ function CardLabel({ children }: { children: string }) {
}
function OfficeCard({ t }: { t: Content }) {
const site = useSiteInfo();
return (
<Card padding="lg">
<div className="pb-4">
@@ -226,7 +230,7 @@ function OfficeCard({ t }: { t: Content }) {
</h3>
</div>
<div className="flex flex-col gap-3">
{offices.map((office) => (
{site.offices.map((office) => (
<div
key={office.name}
className="bg-surface-container flex items-start gap-3 rounded-lg p-3.5"
@@ -240,10 +244,10 @@ function OfficeCard({ t }: { t: Content }) {
{officeAddress(office)}
</span>
<a
href={office.phone.href}
href={office.phoneHref}
className="text-on-surface hover:text-primary font-mono font-semibold"
>
{t.offices.tel} {office.phone.label}
{t.offices.tel} {office.phone}
</a>
</div>
</div>
@@ -275,31 +279,32 @@ function OfficeCard({ t }: { t: Content }) {
}
function ChannelsCard({ t }: { t: Content }) {
const site = useSiteInfo();
const channels = [
{
icon: 'call',
label: t.channels.phone,
value: company.phone.label,
href: company.phone.href,
value: site.phone.label,
href: site.phone.href,
},
{
icon: 'chat',
label: t.channels.whatsapp,
value: company.whatsapp.label,
href: `${company.whatsapp.href}?text=${encodeURIComponent(t.channels.whatsappMessage)}`,
value: site.whatsapp.label,
href: `${site.whatsapp.href}?text=${encodeURIComponent(t.channels.whatsappMessage)}`,
badge: t.channels.whatsappBadge,
},
{
icon: 'mail',
label: t.channels.email,
value: company.email.busdev,
href: `mailto:${company.email.busdev}`,
value: site.email.busdev,
href: `mailto:${site.email.busdev}`,
},
{
icon: 'headset_mic',
label: t.channels.support,
value: company.email.support,
href: `mailto:${company.email.support}`,
value: site.email.support,
href: `mailto:${site.email.support}`,
badge: t.channels.supportBadge,
},
];
@@ -398,12 +403,6 @@ function ConsultationRequestForm({ t }: { t: Content }) {
const [submitted, setSubmitted] = useState(false);
const form = t.form;
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitted(true);
event.currentTarget.reset();
};
return (
<Card padding="lg" className="shadow-xl lg:p-10">
<div className="border-outline-variant/30 border-b pb-6">
@@ -416,7 +415,17 @@ function ConsultationRequestForm({ t }: { t: Content }) {
</p>
</div>
<form className="flex flex-col gap-6 pt-6" onSubmit={handleSubmit}>
<Form
{...store.form()}
options={{ preserveScroll: true }}
resetOnSuccess
onSuccess={() => setSubmitted(true)}
className="relative flex flex-col gap-6 pt-6"
>
{({ processing, errors }) => (
<>
<input type="hidden" name="source" value="contact" />
<Honeypot />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field id="full-name" label={form.name} required>
<TextInput
@@ -426,7 +435,11 @@ function ConsultationRequestForm({ t }: { t: Content }) {
required
/>
</Field>
<Field id="company-name" label={form.company} required>
<Field
id="company-name"
label={form.company}
required
>
<TextInput
id="company-name"
name="company"
@@ -434,7 +447,11 @@ function ConsultationRequestForm({ t }: { t: Content }) {
required
/>
</Field>
<Field id="job-title" label={form.jobTitle} required>
<Field
id="job-title"
label={form.jobTitle}
required
>
<TextInput
id="job-title"
name="job_title"
@@ -451,7 +468,11 @@ function ConsultationRequestForm({ t }: { t: Content }) {
required
/>
</Field>
<Field id="phone-number" label={form.phone} required>
<Field
id="phone-number"
label={form.phone}
required
>
<TextInput
id="phone-number"
name="phone"
@@ -460,12 +481,21 @@ function ConsultationRequestForm({ t }: { t: Content }) {
required
/>
</Field>
<Field id="industry-sector" label={form.industry} required>
<Field
id="industry-sector"
label={form.industry}
required
>
<SelectInput
id="industry-sector"
name="industry"
placeholder={form.industryPlaceholder}
options={form.industries}
options={form.industries.map(
({ label }) => ({
value: label,
label,
}),
)}
required
/>
</Field>
@@ -525,7 +555,8 @@ function ConsultationRequestForm({ t }: { t: Content }) {
<div className="bg-surface-container-lowest flex items-start gap-3 rounded-lg p-3.5">
<input
id="nda-consent"
name="nda"
name="nda_agreed"
value="1"
type="checkbox"
required
className="mt-0.5 h-4 w-4 shrink-0"
@@ -538,7 +569,23 @@ function ConsultationRequestForm({ t }: { t: Content }) {
</label>
</div>
<Button type="submit" size="block" trailingIcon="arrow_forward">
{Object.keys(errors).length > 0 && (
<ul
role="alert"
className="border-primary/40 text-primary flex flex-col gap-1 rounded-lg border p-3 text-sm"
>
{Object.values(errors).map((message) => (
<li key={message}>{message}</li>
))}
</ul>
)}
<Button
type="submit"
size="block"
trailingIcon="arrow_forward"
disabled={processing}
>
{form.submit}
</Button>
@@ -563,15 +610,21 @@ function ConsultationRequestForm({ t }: { t: Content }) {
)}
<div className="text-on-surface-variant flex items-center justify-center gap-2 pt-2 text-center font-mono text-[11px]">
<Icon name="lock" className="text-primary text-sm" />
<Icon
name="lock"
className="text-primary text-sm"
/>
{form.privacy}
</div>
</form>
</>
)}
</Form>
</Card>
);
}
function OfficeMap({ t }: { t: Content }) {
const site = useSiteInfo();
return (
<div className="bg-surface-container relative flex min-h-96 w-full flex-col justify-end overflow-hidden rounded-2xl p-4 shadow-xl sm:p-6 lg:min-h-[480px]">
<img
@@ -582,7 +635,7 @@ function OfficeMap({ t }: { t: Content }) {
/>
<div className="from-surface via-surface/40 absolute inset-0 bg-gradient-to-t to-transparent" />
<div className="relative mt-40 grid grid-cols-1 gap-4 md:grid-cols-2">
{offices.map((office) => (
{site.offices.map((office) => (
<div
key={office.name}
className="border-outline-variant/40 bg-surface-container/95 flex flex-col gap-3 rounded-xl border p-5 shadow-2xl backdrop-blur-md"
@@ -601,8 +654,9 @@ function OfficeMap({ t }: { t: Content }) {
<p className="text-on-surface-variant text-sm leading-relaxed">
{officeAddress(office)}
</p>
{office.maps_url && (
<ButtonLink
href={office.mapsUrl}
href={office.maps_url}
variant="secondary"
size="sm"
icon="directions"
@@ -610,6 +664,7 @@ function OfficeMap({ t }: { t: Content }) {
>
{t.location.directions}
</ButtonLink>
)}
</div>
))}
</div>
+6 -3
View File
@@ -14,14 +14,16 @@ import ConsultationSection from '@/components/site/sections/consultation-section
import ServiceGrid from '@/components/site/sections/service-grid';
import StatCard from '@/components/site/stat-card';
import { homeContent } from '@/content/home';
import { topClients } from '@/data/projects';
import { fillCounts } from '@/data/projects';
import { useContent } from '@/hooks/use-locale';
import { usePortfolio } from '@/hooks/use-portfolio';
import { about, clients, services } from '@/routes';
type Content = typeof homeContent.id;
export default function Home() {
const t = useContent(homeContent);
const stats = usePortfolio();
const t = fillCounts(useContent(homeContent), stats);
return (
<>
@@ -48,7 +50,7 @@ export default function Home() {
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
{topClients(10).map((client) => (
{stats.topClients(10).map((client) => (
<div
key={client}
className="border-outline-variant/40 bg-surface-container text-on-surface hover:border-primary/50 hover:bg-surface-container-high flex min-h-16 items-center justify-center rounded-xl border px-4 py-3 text-center font-mono text-xs font-medium transition-all"
@@ -140,6 +142,7 @@ export default function Home() {
<ConsultationSection
id="konsultasi"
source="home"
title={t.consultation.title}
description={t.consultation.description}
/>
+1
View File
@@ -185,6 +185,7 @@ export default function Services() {
<ConsultationSection
id="konsultasi"
source="services"
title={t.consultation.title}
description={t.consultation.description}
/>
+1
View File
@@ -2,6 +2,7 @@ export type User = {
id: number;
name: string;
email: string;
role: 'admin' | 'editor';
avatar?: string;
email_verified_at: string | null;
two_factor_enabled?: boolean;
+3
View File
@@ -1,4 +1,5 @@
import type { Auth } from '@/types/auth';
import type { SiteInfo } from '@/types/site';
declare module 'react' {
interface InputHTMLAttributes<T> {
@@ -12,6 +13,8 @@ declare module '@inertiajs/core' {
name: string;
locale: 'id' | 'en';
auth: Auth;
site: SiteInfo;
can: { manageUsers: boolean };
sidebarOpen: boolean;
[key: string]: unknown;
};
+1
View File
@@ -1,3 +1,4 @@
export type * from './auth';
export type * from './navigation';
export type * from './site';
export type * from './ui';
+95
View File
@@ -0,0 +1,95 @@
export type ProjectSector = 'government' | 'private' | 'soe';
/** Long-running programs highlighted as featured case studies. */
export type ProjectProgram = 'price-monitoring' | 'investment' | 'erp';
/** A portfolio entry, as stored in the `projects` table. */
export type Project = {
id: number;
title: string;
client: string;
year: number;
sector: ProjectSector;
program: ProjectProgram | null;
/** Optional detail of what the project covered. */
scope: string | null;
};
export type Office = {
id: number;
name: string;
icon: string;
building: string;
street: string;
area: string;
city: string;
district: string;
phone: string;
maps_url: string | null;
sort_order: number;
};
export type SiteContact = {
phone: string;
whatsapp: string;
email_busdev: string;
email_support: string;
};
/** Editable company details shared with every page (see `SiteInfo`). */
export type SiteInfo = {
contact: SiteContact;
offices: Office[];
};
export type ConsultationStatus = 'new' | 'contacted' | 'closed';
export type Consultation = {
id: number;
source: 'home' | 'services' | 'contact';
name: string;
company: string | null;
job_title: string | null;
email: string;
phone: string;
industry: string | null;
service: string | null;
timeline: string | null;
details: string | null;
nda_agreed: boolean;
locale: 'id' | 'en';
status: ConsultationStatus;
admin_notes: string | null;
created_at: string;
};
export type Testimonial = {
id: number;
quote_id: string;
quote_en: string;
role_id: string;
role_en: string;
organization_id: string;
organization_en: string;
is_published: boolean;
sort_order: number;
};
/** A testimonial in the visitor's language, as sent to the site. */
export type LocalizedTestimonial = {
id: number;
quote: string;
role: string;
organization: string;
};
export type UserRole = 'admin' | 'editor';
export type Paginated<T> = {
data: T[];
current_page: number;
last_page: number;
total: number;
prev_page_url: string | null;
next_page_url: string | null;
};
+2 -2
View File
@@ -22,11 +22,11 @@
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
<style>
html {
background-color: oklch(1 0 0);
background-color: #f6f7f9;
}
html.dark {
background-color: oklch(0.145 0 0);
background-color: #0b0f17;
}
</style>
+31
View File
@@ -1,8 +1,39 @@
<?php
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use function Laravel\Prompts\password;
use function Laravel\Prompts\text;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Artisan::command('admin:create', function () {
$name = text('Name', required: true);
$email = text(
'Email',
required: true,
validate: fn (string $value) => match (true) {
! filter_var($value, FILTER_VALIDATE_EMAIL) => 'Enter a valid email address.',
User::where('email', $value)->exists() => 'A user with this email already exists.',
default => null,
},
);
$password = password('Password', required: true, validate: fn (string $value) => strlen($value) < 8
? 'The password must be at least 8 characters.'
: null);
$user = User::create([
'name' => $name,
'email' => $email,
'role' => UserRole::Admin,
'password' => $password,
]);
$user->forceFill(['email_verified_at' => now()])->save();
$this->info("Admin user {$email} created.");
})->purpose('Create an admin user (public registration is disabled)');
+39 -6
View File
@@ -1,15 +1,48 @@
<?php
use App\Http\Controllers\Admin\CompanyController;
use App\Http\Controllers\Admin\ConsultationController as AdminConsultationController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\OfficeController;
use App\Http\Controllers\Admin\ProjectController;
use App\Http\Controllers\Admin\TestimonialController;
use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\ConsultationController;
use App\Http\Controllers\SitePageController;
use Illuminate\Support\Facades\Route;
Route::inertia('/', 'site/home')->name('home');
Route::inertia('tentang-kami', 'site/about')->name('about');
Route::inertia('jasa', 'site/services')->name('services');
Route::inertia('klien', 'site/clients')->name('clients');
Route::inertia('kontak', 'site/contact')->name('contact');
Route::controller(SitePageController::class)->group(function () {
Route::get('/', 'home')->name('home');
Route::get('tentang-kami', 'about')->name('about');
Route::get('jasa', 'services')->name('services');
Route::get('klien', 'clients')->name('clients');
Route::get('kontak', 'contact')->name('contact');
});
Route::post('konsultasi', [ConsultationController::class, 'store'])
->middleware('throttle:5,1')
->name('consultations.store');
Route::middleware(['auth', 'verified'])->group(function () {
Route::inertia('dashboard', 'dashboard')->name('dashboard');
Route::get('dashboard', DashboardController::class)->name('dashboard');
Route::prefix('admin')->name('admin.')->group(function () {
Route::resource('consultations', AdminConsultationController::class)
->only(['index', 'update', 'destroy']);
Route::resource('projects', ProjectController::class)
->only(['index', 'store', 'update', 'destroy']);
Route::resource('testimonials', TestimonialController::class)
->only(['index', 'store', 'update', 'destroy']);
Route::resource('offices', OfficeController::class)
->only(['store', 'update', 'destroy']);
Route::get('company', [CompanyController::class, 'edit'])->name('company.edit');
Route::put('company/contact', [CompanyController::class, 'updateContact'])->name('company.contact.update');
Route::resource('users', UserController::class)
->only(['index', 'store', 'update', 'destroy'])
->middleware('can:manage-users');
});
});
require __DIR__.'/settings.php';
+185
View File
@@ -0,0 +1,185 @@
<?php
namespace Tests\Feature;
use App\Enums\ConsultationStatus;
use App\Enums\UserRole;
use App\Models\Consultation;
use App\Models\Office;
use App\Models\Project;
use App\Models\Testimonial;
use App\Models\User;
use App\Support\SiteInfo;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class AdminCmsTest extends TestCase
{
use RefreshDatabase;
public function test_guests_cannot_open_admin_pages()
{
$this->get(route('admin.projects.index'))->assertRedirect(route('login'));
$this->get(route('admin.consultations.index'))->assertRedirect(route('login'));
}
public function test_editor_can_manage_content_but_not_users()
{
$editor = User::factory()->create();
$this->actingAs($editor);
$this->get(route('admin.consultations.index'))->assertOk();
$this->get(route('admin.projects.index'))->assertOk();
$this->get(route('admin.company.edit'))->assertOk();
$this->get(route('admin.testimonials.index'))->assertOk();
$this->get(route('admin.users.index'))->assertForbidden();
$this->post(route('admin.users.store'), [])->assertForbidden();
}
public function test_admin_can_create_users()
{
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post(route('admin.users.store'), [
'name' => 'Staf Marketing',
'email' => 'marketing@probindo.com',
'role' => 'editor',
'password' => 'Rahasia#2026',
'password_confirmation' => 'Rahasia#2026',
])
->assertSessionHasNoErrors();
$user = User::query()->where('email', 'marketing@probindo.com')->sole();
$this->assertSame(UserRole::Editor, $user->role);
$this->assertNotNull($user->email_verified_at);
}
public function test_admin_cannot_delete_or_demote_themselves()
{
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->put(route('admin.users.update', $admin), [
'name' => $admin->name,
'email' => $admin->email,
'role' => 'editor',
])
->assertSessionHasNoErrors();
$this->assertSame(UserRole::Admin, $admin->fresh()->role);
$this->delete(route('admin.users.destroy', $admin))->assertForbidden();
}
public function test_projects_can_be_managed_and_appear_on_the_site()
{
$this->actingAs(User::factory()->create())
->post(route('admin.projects.store'), [
'title' => 'Implementasi Odoo',
'client' => 'PT Contoh',
'year' => 2026,
'sector' => 'private',
'program' => 'erp',
])
->assertSessionHasNoErrors();
$project = Project::query()->sole();
$this->get(route('clients'))->assertInertia(fn (Assert $page) => $page
->has('projects', 1)
->where('projects.0.title', 'Implementasi Odoo'));
$this->put(route('admin.projects.update', $project), [
'title' => 'Implementasi Odoo Enterprise',
'client' => 'PT Contoh',
'year' => 2026,
'sector' => 'private',
'program' => '',
])->assertSessionHasNoErrors();
$this->assertNull($project->fresh()->program);
$this->delete(route('admin.projects.destroy', $project));
$this->assertDatabaseCount('projects', 0);
}
public function test_consultation_status_can_be_updated()
{
$consultation = Consultation::query()->create([
'source' => 'home',
'name' => 'Budi',
'email' => 'budi@example.com',
'phone' => '0811',
]);
$this->actingAs(User::factory()->create())
->put(route('admin.consultations.update', $consultation), [
'status' => 'contacted',
'admin_notes' => 'Sudah ditelepon.',
])
->assertSessionHasNoErrors();
$consultation->refresh();
$this->assertSame(ConsultationStatus::Contacted, $consultation->status);
$this->assertSame('Sudah ditelepon.', $consultation->admin_notes);
}
public function test_company_changes_refresh_the_shared_site_info()
{
$this->assertSame('busdev@probindo.com', SiteInfo::get()['contact']['email_busdev']);
$this->actingAs(User::factory()->create())
->put(route('admin.company.contact.update'), [
'phone' => '(021) 1111-2222',
'whatsapp' => '0811-1111-2222',
'email_busdev' => 'sales@probindo.com',
'email_support' => 'support@probindo.com',
])
->assertSessionHasNoErrors();
$this->post(route('admin.offices.store'), [
'name' => 'Kantor Baru',
'icon' => 'apartment',
'building' => 'Gedung A',
'street' => 'Jl. Contoh',
'area' => 'Kebayoran',
'city' => 'Jakarta 12345',
'district' => 'Jakarta Selatan',
'phone' => '(021) 1111-2222',
'sort_order' => 1,
])->assertSessionHasNoErrors();
$site = SiteInfo::get();
$this->assertSame('sales@probindo.com', $site['contact']['email_busdev']);
$this->assertSame('Kantor Baru', $site['offices'][0]['name']);
$this->assertSame(1, Office::query()->count());
}
public function test_only_published_testimonials_reach_the_site()
{
$testimonial = Testimonial::query()->create([
'quote_id' => 'Bagus', 'quote_en' => 'Great',
'role_id' => 'Direktur', 'role_en' => 'Director',
'organization_id' => 'PT A', 'organization_en' => 'PT A',
'sort_order' => 1,
]);
$this->get(route('clients'))->assertInertia(fn (Assert $page) => $page->has('testimonials', 1));
// An unchecked "publish" box is not submitted at all.
$this->actingAs(User::factory()->create())
->put(route('admin.testimonials.update', $testimonial), [
'quote_id' => 'Bagus', 'quote_en' => 'Great',
'role_id' => 'Direktur', 'role_en' => 'Director',
'organization_id' => 'PT A', 'organization_en' => 'PT A',
'sort_order' => 1,
])
->assertSessionHasNoErrors();
$this->assertFalse($testimonial->fresh()->is_published);
$this->get(route('clients'))->assertInertia(fn (Assert $page) => $page->has('testimonials', 0));
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace Tests\Feature;
use App\Enums\ConsultationStatus;
use App\Models\Consultation;
use App\Notifications\NewConsultationRequest;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class ConsultationRequestTest extends TestCase
{
use RefreshDatabase;
/**
* @return array<string, mixed>
*/
private function payload(array $overrides = []): array
{
return [
'source' => 'home',
'name' => 'Budi Santoso',
'email' => 'budi@example.com',
'phone' => '0811 2345 678',
'service' => 'Implementasi Odoo ERP',
...$overrides,
];
}
public function test_visitor_request_is_saved_and_busdev_is_notified()
{
Notification::fake();
$this->from(route('home'))
->post(route('consultations.store'), $this->payload())
->assertRedirect(route('home'))
->assertSessionHasNoErrors();
$consultation = Consultation::query()->sole();
$this->assertSame('Budi Santoso', $consultation->name);
$this->assertSame(ConsultationStatus::New, $consultation->status);
$this->assertSame('id', $consultation->locale);
Notification::assertSentTo(
new AnonymousNotifiable,
NewConsultationRequest::class,
fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'busdev@probindo.com',
);
}
public function test_required_fields_are_validated()
{
Notification::fake();
$this->post(route('consultations.store'), $this->payload(['name' => '', 'email' => 'bukan-email']))
->assertSessionHasErrors(['name', 'email']);
$this->assertDatabaseCount('consultations', 0);
Notification::assertNothingSent();
}
public function test_contact_form_requires_nda_consent()
{
$this->post(route('consultations.store'), $this->payload(['source' => 'contact']))
->assertSessionHasErrors('nda_agreed');
$this->post(route('consultations.store'), $this->payload(['source' => 'contact', 'nda_agreed' => '1']))
->assertSessionHasNoErrors();
$this->assertTrue(Consultation::query()->sole()->nda_agreed);
}
public function test_honeypot_submissions_are_rejected()
{
$this->post(route('consultations.store'), $this->payload(['website' => 'http://spam.test']))
->assertSessionHasErrors('website');
$this->assertDatabaseCount('consultations', 0);
}
}
+15
View File
@@ -2,12 +2,15 @@
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class SitePagesTest extends TestCase
{
use RefreshDatabase;
/**
* @return array<string, array{string, string}>
*/
@@ -30,6 +33,18 @@ class SitePagesTest extends TestCase
->assertInertia(fn (Assert $page) => $page->component($component));
}
public function test_admin_login_page_renders()
{
$this->get(route('login'))
->assertOk()
->assertInertia(fn (Assert $page) => $page->component('auth/login'));
}
public function test_public_registration_is_disabled()
{
$this->get('/register')->assertNotFound();
}
public function test_site_defaults_to_indonesian()
{
$this->get(route('home'))
-3
View File
@@ -13,9 +13,6 @@ export default defineConfig({
input: ['resources/css/app.css', 'resources/js/app.tsx'],
refresh: true,
fonts: [
bunny('Instrument Sans', {
weights: [400, 500, 600],
}),
bunny('Plus Jakarta Sans', {
weights: [400, 500, 600, 700, 800],
}),