@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ConsultationStatus: string
|
||||
{
|
||||
case New = 'new';
|
||||
case Contacted = 'contacted';
|
||||
case Closed = 'closed';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/** Long-running programs highlighted as featured case studies on the site. */
|
||||
enum ProjectProgram: string
|
||||
{
|
||||
case PriceMonitoring = 'price-monitoring';
|
||||
case Investment = 'investment';
|
||||
case Erp = 'erp';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ProjectSector: string
|
||||
{
|
||||
case Government = 'government';
|
||||
case Private = 'private';
|
||||
case Soe = 'soe';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum UserRole: string
|
||||
{
|
||||
/** Manages users plus everything an editor can do. */
|
||||
case Admin = 'admin';
|
||||
|
||||
/** Manages site content and the consultation inbox. */
|
||||
case Editor = 'editor';
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\ContactSettingsRequest;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Support\SiteInfo;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* Company contact details and office locations shown across the site.
|
||||
*/
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
public function edit(): Response
|
||||
{
|
||||
return Inertia::render('admin/company/edit', SiteInfo::get());
|
||||
}
|
||||
|
||||
public function updateContact(ContactSettingsRequest $request): RedirectResponse
|
||||
{
|
||||
SiteSetting::put(SiteInfo::CONTACT_KEY, $request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kontak perusahaan diperbarui.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\ConsultationStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateConsultationRequest;
|
||||
use App\Models\Consultation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ConsultationController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$status = ConsultationStatus::tryFrom((string) $request->query('status'));
|
||||
|
||||
return Inertia::render('admin/consultations/index', [
|
||||
'consultations' => Consultation::query()
|
||||
->when($status, fn ($query) => $query->where('status', $status))
|
||||
->latest()
|
||||
->paginate(20)
|
||||
->withQueryString(),
|
||||
'status' => $status?->value,
|
||||
'counts' => Consultation::query()
|
||||
->selectRaw('status, count(*) as total')
|
||||
->groupBy('status')
|
||||
->pluck('total', 'status'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateConsultationRequest $request, Consultation $consultation): RedirectResponse
|
||||
{
|
||||
$consultation->update($request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Permintaan konsultasi diperbarui.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Consultation $consultation): RedirectResponse
|
||||
{
|
||||
$consultation->delete();
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Permintaan konsultasi dihapus.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\ConsultationStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\Project;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(): Response
|
||||
{
|
||||
return Inertia::render('dashboard', [
|
||||
'projects' => Project::query()
|
||||
->latestFirst()
|
||||
->get(['id', 'title', 'client', 'year', 'sector', 'program', 'scope']),
|
||||
'newConsultations' => Consultation::query()
|
||||
->where('status', ConsultationStatus::New)
|
||||
->count(),
|
||||
'latestConsultations' => Consultation::query()
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get(['id', 'name', 'company', 'service', 'status', 'created_at']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\OfficeRequest;
|
||||
use App\Models\Office;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class OfficeController extends Controller
|
||||
{
|
||||
public function store(OfficeRequest $request): RedirectResponse
|
||||
{
|
||||
Office::query()->create($request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kantor ditambahkan.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function update(OfficeRequest $request, Office $office): RedirectResponse
|
||||
{
|
||||
$office->update($request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kantor diperbarui.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Office $office): RedirectResponse
|
||||
{
|
||||
$office->delete();
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kantor dihapus.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\ProjectRequest;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/projects/index', [
|
||||
'projects' => Project::query()->latestFirst()->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(ProjectRequest $request): RedirectResponse
|
||||
{
|
||||
Project::query()->create($request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Proyek ditambahkan.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function update(ProjectRequest $request, Project $project): RedirectResponse
|
||||
{
|
||||
$project->update($request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Proyek diperbarui.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Project $project): RedirectResponse
|
||||
{
|
||||
$project->delete();
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Proyek dihapus.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\TestimonialRequest;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class TestimonialController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/testimonials/index', [
|
||||
'testimonials' => Testimonial::query()->orderBy('sort_order')->orderBy('id')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(TestimonialRequest $request): RedirectResponse
|
||||
{
|
||||
Testimonial::query()->create($request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Testimoni ditambahkan.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function update(TestimonialRequest $request, Testimonial $testimonial): RedirectResponse
|
||||
{
|
||||
$testimonial->update($request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Testimoni diperbarui.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Testimonial $testimonial): RedirectResponse
|
||||
{
|
||||
$testimonial->delete();
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Testimoni dihapus.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UserRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* Admin-only management of panel accounts (registration is disabled).
|
||||
*/
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/users/index', [
|
||||
'users' => User::query()->orderBy('name')->get(['id', 'name', 'email', 'role', 'created_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(UserRequest $request): RedirectResponse
|
||||
{
|
||||
$user = User::query()->create($request->safe()->only(['name', 'email', 'role', 'password']));
|
||||
// Accounts are created by an admin, so there is nothing left to verify.
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengguna ditambahkan.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function update(UserRequest $request, User $user): RedirectResponse
|
||||
{
|
||||
$data = $request->safe()->only(['name', 'email', 'role']);
|
||||
|
||||
if ($request->filled('password')) {
|
||||
$data['password'] = $request->validated('password');
|
||||
}
|
||||
|
||||
// Keep at least one admin: nobody may demote themselves.
|
||||
if ($user->is($request->user())) {
|
||||
unset($data['role']);
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengguna diperbarui.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
abort_if($user->is($request->user()), 403, 'Anda tidak dapat menghapus akun sendiri.');
|
||||
|
||||
$user->delete();
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengguna dihapus.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreConsultationRequest;
|
||||
use App\Models\Consultation;
|
||||
use App\Notifications\NewConsultationRequest;
|
||||
use App\Support\SiteInfo;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class ConsultationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Save a consultation request from the public site and notify the team.
|
||||
*/
|
||||
public function store(StoreConsultationRequest $request): RedirectResponse
|
||||
{
|
||||
$consultation = Consultation::query()->create([
|
||||
...$request->safe()->except(['website', 'nda_agreed']),
|
||||
'nda_agreed' => $request->boolean('nda_agreed'),
|
||||
'locale' => app()->getLocale(),
|
||||
]);
|
||||
|
||||
Notification::route('mail', SiteInfo::get()['contact']['email_busdev'])
|
||||
->notify(new NewConsultationRequest($consultation));
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Support\Collection;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* Public company-profile pages. Page copy lives in the frontend content
|
||||
* files; editable data (portfolio, testimonials) comes from the database.
|
||||
*/
|
||||
class SitePageController extends Controller
|
||||
{
|
||||
public function home(): Response
|
||||
{
|
||||
return Inertia::render('site/home', ['projects' => $this->projects()]);
|
||||
}
|
||||
|
||||
public function about(): Response
|
||||
{
|
||||
return Inertia::render('site/about', ['projects' => $this->projects()]);
|
||||
}
|
||||
|
||||
public function services(): Response
|
||||
{
|
||||
return Inertia::render('site/services');
|
||||
}
|
||||
|
||||
public function clients(): Response
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return Inertia::render('site/clients', [
|
||||
'projects' => $this->projects(),
|
||||
'testimonials' => Testimonial::query()
|
||||
->published()
|
||||
->get()
|
||||
->map(fn (Testimonial $testimonial) => $testimonial->localized($locale)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function contact(): Response
|
||||
{
|
||||
return Inertia::render('site/contact');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
private function projects(): Collection
|
||||
{
|
||||
return Project::query()
|
||||
->latestFirst()
|
||||
->get(['id', 'title', 'client', 'year', 'sector', 'program', 'scope']);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\SiteInfo;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
@@ -39,6 +40,10 @@ class HandleInertiaRequests extends Middleware
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'locale' => app()->getLocale(),
|
||||
'site' => fn () => SiteInfo::get(),
|
||||
'can' => [
|
||||
'manageUsers' => (bool) $request->user()?->can('manage-users'),
|
||||
],
|
||||
'auth' => [
|
||||
'user' => $request->user(),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ContactSettingsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'phone' => ['required', 'string', 'max:50'],
|
||||
'whatsapp' => ['required', 'string', 'max:50'],
|
||||
'email_busdev' => ['required', 'email', 'max:255'],
|
||||
'email_support' => ['required', 'email', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class OfficeRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'icon' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9_]+$/'],
|
||||
'building' => ['required', 'string', 'max:255'],
|
||||
'street' => ['required', 'string', 'max:255'],
|
||||
'area' => ['required', 'string', 'max:255'],
|
||||
'city' => ['required', 'string', 'max:255'],
|
||||
'district' => ['required', 'string', 'max:255'],
|
||||
'phone' => ['required', 'string', 'max:50'],
|
||||
'maps_url' => ['nullable', 'url', 'max:500'],
|
||||
'sort_order' => ['required', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Enums\ProjectProgram;
|
||||
use App\Enums\ProjectSector;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProjectRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'client' => ['required', 'string', 'max:255'],
|
||||
'year' => ['required', 'integer', 'min:1985', 'max:'.(now()->year + 1)],
|
||||
'sector' => ['required', Rule::enum(ProjectSector::class)],
|
||||
'program' => ['nullable', Rule::enum(ProjectProgram::class)],
|
||||
'scope' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TestimonialRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* An unchecked checkbox is not submitted, so treat it as unpublished.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge(['is_published' => $this->boolean('is_published')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'quote_id' => ['required', 'string', 'max:2000'],
|
||||
'quote_en' => ['required', 'string', 'max:2000'],
|
||||
'role_id' => ['required', 'string', 'max:255'],
|
||||
'role_en' => ['required', 'string', 'max:255'],
|
||||
'organization_id' => ['required', 'string', 'max:255'],
|
||||
'organization_en' => ['required', 'string', 'max:255'],
|
||||
'is_published' => ['boolean'],
|
||||
'sort_order' => ['required', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Enums\ConsultationStatus;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateConsultationRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', Rule::enum(ConsultationStatus::class)],
|
||||
'admin_notes' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Create or update a panel user. On update the password is optional.
|
||||
*/
|
||||
class UserRequest extends FormRequest
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = $this->route('user');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required', 'email', 'max:255',
|
||||
Rule::unique(User::class)->ignore($user?->id),
|
||||
],
|
||||
'role' => ['required', Rule::enum(UserRole::class)],
|
||||
'password' => $user
|
||||
? ['nullable', ...$this->passwordRulesWithoutRequired()]
|
||||
: $this->passwordRules(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function passwordRulesWithoutRequired(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->passwordRules(),
|
||||
fn ($rule) => $rule !== 'required',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* A consultation request from one of the public site forms.
|
||||
*/
|
||||
class StoreConsultationRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'source' => ['required', 'in:home,services,contact'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'company' => ['nullable', 'string', 'max:255'],
|
||||
'job_title' => ['nullable', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'phone' => ['required', 'string', 'max:50'],
|
||||
'industry' => ['nullable', 'string', 'max:100'],
|
||||
'service' => ['nullable', 'string', 'max:100'],
|
||||
'timeline' => ['nullable', 'in:immediate,quarter,midterm,feasibility'],
|
||||
'details' => ['nullable', 'string', 'max:5000'],
|
||||
// The full contact form requires agreeing to an NDA first.
|
||||
'nda_agreed' => ['accepted_if:source,contact'],
|
||||
// Honeypot: hidden from people, so only bots fill it in.
|
||||
'website' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ConsultationStatus;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* A consultation request submitted through one of the site's forms.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $source
|
||||
* @property string $name
|
||||
* @property string|null $company
|
||||
* @property string|null $job_title
|
||||
* @property string $email
|
||||
* @property string $phone
|
||||
* @property string|null $industry
|
||||
* @property string|null $service
|
||||
* @property string|null $timeline
|
||||
* @property string|null $details
|
||||
* @property bool $nda_agreed
|
||||
* @property string $locale
|
||||
* @property ConsultationStatus $status
|
||||
* @property string|null $admin_notes
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
#[Fillable([
|
||||
'source', 'name', 'company', 'job_title', 'email', 'phone', 'industry',
|
||||
'service', 'timeline', 'details', 'nda_agreed', 'locale', 'status', 'admin_notes',
|
||||
])]
|
||||
class Consultation extends Model
|
||||
{
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'nda_agreed' => 'boolean',
|
||||
'status' => ConsultationStatus::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\SiteInfo;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $icon
|
||||
* @property string $building
|
||||
* @property string $street
|
||||
* @property string $area
|
||||
* @property string $city
|
||||
* @property string $district
|
||||
* @property string $phone
|
||||
* @property string|null $maps_url
|
||||
* @property int $sort_order
|
||||
*/
|
||||
#[Fillable(['name', 'icon', 'building', 'street', 'area', 'city', 'district', 'phone', 'maps_url', 'sort_order'])]
|
||||
class Office extends Model
|
||||
{
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(fn () => SiteInfo::forget());
|
||||
static::deleted(fn () => SiteInfo::forget());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ProjectProgram;
|
||||
use App\Enums\ProjectSector;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* An entry in PAJ's IT project track record (the site's portfolio).
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $title
|
||||
* @property string $client
|
||||
* @property int $year
|
||||
* @property ProjectSector $sector
|
||||
* @property ProjectProgram|null $program
|
||||
* @property string|null $scope
|
||||
*/
|
||||
#[Fillable(['title', 'client', 'year', 'sector', 'program', 'scope'])]
|
||||
class Project extends Model
|
||||
{
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'year' => 'integer',
|
||||
'sector' => ProjectSector::class,
|
||||
'program' => ProjectProgram::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest projects first, as the site lists them.
|
||||
*
|
||||
* @param Builder<Project> $query
|
||||
*/
|
||||
public function scopeLatestFirst(Builder $query): void
|
||||
{
|
||||
$query->orderByDesc('year')->orderBy('id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\SiteInfo;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Key/value store for editable site settings (e.g. contact details).
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $key
|
||||
* @property array<string, mixed> $value
|
||||
*/
|
||||
#[Fillable(['key', 'value'])]
|
||||
class SiteSetting extends Model
|
||||
{
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(fn () => SiteInfo::forget());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'value' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $default
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function get(string $key, array $default = []): array
|
||||
{
|
||||
return static::query()->where('key', $key)->first()?->value ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $value
|
||||
*/
|
||||
public static function put(string $key, array $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* A client testimonial, written in both site languages.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $quote_id
|
||||
* @property string $quote_en
|
||||
* @property string $role_id
|
||||
* @property string $role_en
|
||||
* @property string $organization_id
|
||||
* @property string $organization_en
|
||||
* @property bool $is_published
|
||||
* @property int $sort_order
|
||||
*/
|
||||
#[Fillable([
|
||||
'quote_id', 'quote_en', 'role_id', 'role_en',
|
||||
'organization_id', 'organization_en', 'is_published', 'sort_order',
|
||||
])]
|
||||
class Testimonial extends Model
|
||||
{
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_published' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Testimonial> $query
|
||||
*/
|
||||
public function scopePublished(Builder $query): void
|
||||
{
|
||||
$query->where('is_published', true)->orderBy('sort_order')->orderBy('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The testimonial in the visitor's language.
|
||||
*
|
||||
* @return array{id: int, quote: string, role: string, organization: string}
|
||||
*/
|
||||
public function localized(string $locale): array
|
||||
{
|
||||
$suffix = $locale === 'en' ? 'en' : 'id';
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'quote' => $this->{"quote_{$suffix}"},
|
||||
'role' => $this->{"role_{$suffix}"},
|
||||
'organization' => $this->{"organization_{$suffix}"},
|
||||
];
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Enums\UserRole;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
@@ -18,6 +19,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property UserRole $role
|
||||
* @property Carbon|null $email_verified_at
|
||||
* @property string $password
|
||||
* @property string|null $two_factor_secret
|
||||
@@ -27,7 +29,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Fillable(['name', 'email', 'role', 'password'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable implements PasskeyUser
|
||||
{
|
||||
@@ -44,7 +46,16 @@ class User extends Authenticatable implements PasskeyUser
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'role' => UserRole::class,
|
||||
'two_factor_confirmed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Admins manage users on top of everything an editor can do.
|
||||
*/
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === UserRole::Admin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Consultation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
/**
|
||||
* Emails the business development inbox when a consultation request arrives.
|
||||
*/
|
||||
class NewConsultationRequest extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public Consultation $consultation) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$consultation = $this->consultation;
|
||||
|
||||
$message = (new MailMessage)
|
||||
->subject("Permintaan konsultasi baru: {$consultation->name}")
|
||||
->greeting('Permintaan konsultasi baru')
|
||||
->line("Nama: {$consultation->name}")
|
||||
->line("Email: {$consultation->email}")
|
||||
->line("Telepon: {$consultation->phone}");
|
||||
|
||||
foreach ([
|
||||
'Perusahaan' => $consultation->company,
|
||||
'Jabatan' => $consultation->job_title,
|
||||
'Sektor' => $consultation->industry,
|
||||
'Layanan' => $consultation->service,
|
||||
'Linimasa' => $consultation->timeline,
|
||||
'Kebutuhan' => $consultation->details,
|
||||
] as $label => $value) {
|
||||
if ($value) {
|
||||
$message->line("{$label}: {$value}");
|
||||
}
|
||||
}
|
||||
|
||||
return $message
|
||||
->replyTo($consultation->email, $consultation->name)
|
||||
->action('Buka Inbox Konsultasi', route('admin.consultations.index'));
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
@@ -24,6 +26,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureDefaults();
|
||||
|
||||
Gate::define('manage-users', fn (User $user): bool => $user->isAdmin());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,10 +67,6 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
'status' => $request->session()->get('status'),
|
||||
]));
|
||||
|
||||
Fortify::registerView(fn () => Inertia::render('auth/register', [
|
||||
'passwordRules' => Password::defaults()->toPasswordRulesString(),
|
||||
]));
|
||||
|
||||
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge'));
|
||||
|
||||
Fortify::confirmPasswordView(fn () => Inertia::render('auth/confirm-password'));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Office;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Editable company details every public page needs (header, footer, contact
|
||||
* sections). Cached, and cleared whenever offices or settings change.
|
||||
*/
|
||||
class SiteInfo
|
||||
{
|
||||
public const CONTACT_KEY = 'contact';
|
||||
|
||||
private const CACHE_KEY = 'site-info';
|
||||
|
||||
/**
|
||||
* Contact details used until an admin saves their own.
|
||||
*
|
||||
* @var array{phone: string, whatsapp: string, email_busdev: string, email_support: string}
|
||||
*/
|
||||
public const DEFAULT_CONTACT = [
|
||||
'phone' => '(021) 7266-3353',
|
||||
'whatsapp' => '0818-0218-9425',
|
||||
'email_busdev' => 'busdev@probindo.com',
|
||||
'email_support' => 'support@probindo.com',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{contact: array<string, string>, offices: list<array<string, mixed>>}
|
||||
*/
|
||||
public static function get(): array
|
||||
{
|
||||
return Cache::rememberForever(self::CACHE_KEY, fn () => [
|
||||
'contact' => [...self::DEFAULT_CONTACT, ...SiteSetting::get(self::CONTACT_KEY)],
|
||||
'offices' => Office::query()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get(['id', 'name', 'icon', 'building', 'street', 'area', 'city', 'district', 'phone', 'maps_url', 'sort_order'])
|
||||
->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function forget(): void
|
||||
{
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -161,7 +161,6 @@ return [
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
Features::registration(),
|
||||
Features::resetPasswords(),
|
||||
Features::emailVerification(),
|
||||
Features::twoFactorAuthentication([
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
+68
-68
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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,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 />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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 }));
|
||||
|
||||
@@ -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
@@ -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
@@ -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(', ');
|
||||
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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() }],
|
||||
};
|
||||
@@ -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() }],
|
||||
};
|
||||
@@ -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() }],
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -185,6 +185,7 @@ export default function Services() {
|
||||
|
||||
<ConsultationSection
|
||||
id="konsultasi"
|
||||
source="services"
|
||||
title={t.consultation.title}
|
||||
description={t.consultation.description}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+3
@@ -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,3 +1,4 @@
|
||||
export type * from './auth';
|
||||
export type * from './navigation';
|
||||
export type * from './site';
|
||||
export type * from './ui';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user