From 8c6dad2a31bf0dc2bedc298a4e33800717557d42 Mon Sep 17 00:00:00 2001 From: ahmadafriadi Date: Tue, 29 Sep 2026 14:57:37 +0700 Subject: [PATCH] Admin Panel --- .env.example | 4 + README.md | 77 ++- app/Enums/ConsultationStatus.php | 10 + app/Enums/ProjectProgram.php | 11 + app/Enums/ProjectSector.php | 10 + app/Enums/UserRole.php | 12 + .../Controllers/Admin/CompanyController.php | 31 ++ .../Admin/ConsultationController.php | 51 ++ .../Controllers/Admin/DashboardController.php | 29 ++ .../Controllers/Admin/OfficeController.php | 39 ++ .../Controllers/Admin/ProjectController.php | 47 ++ .../Admin/TestimonialController.php | 47 ++ app/Http/Controllers/Admin/UserController.php | 66 +++ .../Controllers/ConsultationController.php | 30 ++ app/Http/Controllers/SitePageController.php | 59 +++ app/Http/Middleware/HandleInertiaRequests.php | 5 + .../Requests/Admin/ContactSettingsRequest.php | 22 + app/Http/Requests/Admin/OfficeRequest.php | 28 ++ app/Http/Requests/Admin/ProjectRequest.php | 27 ++ .../Requests/Admin/TestimonialRequest.php | 34 ++ .../Admin/UpdateConsultationRequest.php | 22 + app/Http/Requests/Admin/UserRequest.php | 50 ++ .../Requests/StoreConsultationRequest.php | 35 ++ app/Models/Consultation.php | 47 ++ app/Models/Office.php | 40 ++ app/Models/Project.php | 46 ++ app/Models/SiteSetting.php | 50 ++ app/Models/Testimonial.php | 63 +++ app/Models/User.php | 13 +- app/Notifications/NewConsultationRequest.php | 56 +++ app/Providers/AppServiceProvider.php | 4 + app/Providers/FortifyServiceProvider.php | 4 - app/Support/SiteInfo.php | 50 ++ config/app.php | 17 + config/fortify.php | 1 - database/factories/UserFactory.php | 12 + ...6_09_29_062120_add_role_to_users_table.php | 32 ++ ...9_29_062121_create_consultations_table.php | 42 ++ ...026_09_29_062122_create_projects_table.php | 33 ++ ...2026_09_29_062123_create_offices_table.php | 39 ++ ...09_29_062124_create_testimonials_table.php | 36 ++ ...9_29_062125_create_site_settings_table.php | 29 ++ database/seeders/AdminUserSeeder.php | 43 ++ database/seeders/DatabaseSeeder.php | 12 +- database/seeders/SiteContentSeeder.php | 136 ++++++ probindo_test | 0 resources/css/app.css | 136 +++--- resources/js/app.tsx | 3 + .../js/components/admin-theme-toggle.tsx | 23 + .../js/components/admin/form-controls.tsx | 216 +++++++++ resources/js/components/app-logo.tsx | 22 +- .../js/components/app-sidebar-header.tsx | 4 + resources/js/components/app-sidebar.tsx | 65 ++- resources/js/components/heading.tsx | 4 +- resources/js/components/nav-footer.tsx | 2 +- .../js/components/site/consultation-form.tsx | 158 +++--- resources/js/components/site/form-field.tsx | 26 + .../site/sections/consultation-section.tsx | 15 +- .../components/site/sections/cta-banner.tsx | 7 +- resources/js/components/site/site-footer.tsx | 17 +- resources/js/components/site/site-header.tsx | 12 +- resources/js/content/about.ts | 17 +- resources/js/content/clients.ts | 27 +- resources/js/content/common.ts | 48 +- resources/js/content/home.ts | 19 +- resources/js/data/projects.ts | 454 +++--------------- resources/js/data/site.ts | 63 +-- resources/js/hooks/use-portfolio.ts | 10 + resources/js/hooks/use-site-info.ts | 26 + resources/js/lib/admin-labels.ts | 57 +++ resources/js/pages/admin/company/edit.tsx | 267 ++++++++++ .../js/pages/admin/consultations/index.tsx | 327 +++++++++++++ resources/js/pages/admin/projects/index.tsx | 290 +++++++++++ .../js/pages/admin/testimonials/index.tsx | 244 ++++++++++ resources/js/pages/admin/users/index.tsx | 266 ++++++++++ resources/js/pages/auth/login.tsx | 242 ++++++---- resources/js/pages/auth/register.tsx | 120 ----- resources/js/pages/dashboard.tsx | 301 +++++++++++- resources/js/pages/site/about.tsx | 15 +- resources/js/pages/site/clients.tsx | 67 +-- resources/js/pages/site/contact.tsx | 419 +++++++++------- resources/js/pages/site/home.tsx | 9 +- resources/js/pages/site/services.tsx | 1 + resources/js/types/auth.ts | 1 + resources/js/types/global.d.ts | 3 + resources/js/types/index.ts | 1 + resources/js/types/site.ts | 95 ++++ resources/views/app.blade.php | 4 +- routes/console.php | 31 ++ routes/web.php | 45 +- tests/Feature/AdminCmsTest.php | 185 +++++++ tests/Feature/ConsultationRequestTest.php | 82 ++++ tests/Feature/SitePagesTest.php | 15 + vite.config.ts | 3 - 94 files changed, 4864 insertions(+), 1151 deletions(-) create mode 100644 app/Enums/ConsultationStatus.php create mode 100644 app/Enums/ProjectProgram.php create mode 100644 app/Enums/ProjectSector.php create mode 100644 app/Enums/UserRole.php create mode 100644 app/Http/Controllers/Admin/CompanyController.php create mode 100644 app/Http/Controllers/Admin/ConsultationController.php create mode 100644 app/Http/Controllers/Admin/DashboardController.php create mode 100644 app/Http/Controllers/Admin/OfficeController.php create mode 100644 app/Http/Controllers/Admin/ProjectController.php create mode 100644 app/Http/Controllers/Admin/TestimonialController.php create mode 100644 app/Http/Controllers/Admin/UserController.php create mode 100644 app/Http/Controllers/ConsultationController.php create mode 100644 app/Http/Controllers/SitePageController.php create mode 100644 app/Http/Requests/Admin/ContactSettingsRequest.php create mode 100644 app/Http/Requests/Admin/OfficeRequest.php create mode 100644 app/Http/Requests/Admin/ProjectRequest.php create mode 100644 app/Http/Requests/Admin/TestimonialRequest.php create mode 100644 app/Http/Requests/Admin/UpdateConsultationRequest.php create mode 100644 app/Http/Requests/Admin/UserRequest.php create mode 100644 app/Http/Requests/StoreConsultationRequest.php create mode 100644 app/Models/Consultation.php create mode 100644 app/Models/Office.php create mode 100644 app/Models/Project.php create mode 100644 app/Models/SiteSetting.php create mode 100644 app/Models/Testimonial.php create mode 100644 app/Notifications/NewConsultationRequest.php create mode 100644 app/Support/SiteInfo.php create mode 100644 database/migrations/2026_09_29_062120_add_role_to_users_table.php create mode 100644 database/migrations/2026_09_29_062121_create_consultations_table.php create mode 100644 database/migrations/2026_09_29_062122_create_projects_table.php create mode 100644 database/migrations/2026_09_29_062123_create_offices_table.php create mode 100644 database/migrations/2026_09_29_062124_create_testimonials_table.php create mode 100644 database/migrations/2026_09_29_062125_create_site_settings_table.php create mode 100644 database/seeders/AdminUserSeeder.php create mode 100644 database/seeders/SiteContentSeeder.php create mode 100644 probindo_test create mode 100644 resources/js/components/admin-theme-toggle.tsx create mode 100644 resources/js/components/admin/form-controls.tsx create mode 100644 resources/js/hooks/use-portfolio.ts create mode 100644 resources/js/hooks/use-site-info.ts create mode 100644 resources/js/lib/admin-labels.ts create mode 100644 resources/js/pages/admin/company/edit.tsx create mode 100644 resources/js/pages/admin/consultations/index.tsx create mode 100644 resources/js/pages/admin/projects/index.tsx create mode 100644 resources/js/pages/admin/testimonials/index.tsx create mode 100644 resources/js/pages/admin/users/index.tsx delete mode 100644 resources/js/pages/auth/register.tsx create mode 100644 resources/js/types/site.ts create mode 100644 tests/Feature/AdminCmsTest.php create mode 100644 tests/Feature/ConsultationRequestTest.php diff --git a/.env.example b/.env.example index a0a3938..3bfa554 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 67b649a..e7a34c0 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/app/Enums/ConsultationStatus.php b/app/Enums/ConsultationStatus.php new file mode 100644 index 0000000..6b32311 --- /dev/null +++ b/app/Enums/ConsultationStatus.php @@ -0,0 +1,10 @@ +validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Kontak perusahaan diperbarui.']); + + return back(); + } +} diff --git a/app/Http/Controllers/Admin/ConsultationController.php b/app/Http/Controllers/Admin/ConsultationController.php new file mode 100644 index 0000000..e32eb2e --- /dev/null +++ b/app/Http/Controllers/Admin/ConsultationController.php @@ -0,0 +1,51 @@ +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(); + } +} diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php new file mode 100644 index 0000000..ddd7b2e --- /dev/null +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -0,0 +1,29 @@ + 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']), + ]); + } +} diff --git a/app/Http/Controllers/Admin/OfficeController.php b/app/Http/Controllers/Admin/OfficeController.php new file mode 100644 index 0000000..e42258b --- /dev/null +++ b/app/Http/Controllers/Admin/OfficeController.php @@ -0,0 +1,39 @@ +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(); + } +} diff --git a/app/Http/Controllers/Admin/ProjectController.php b/app/Http/Controllers/Admin/ProjectController.php new file mode 100644 index 0000000..6f6944f --- /dev/null +++ b/app/Http/Controllers/Admin/ProjectController.php @@ -0,0 +1,47 @@ + 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(); + } +} diff --git a/app/Http/Controllers/Admin/TestimonialController.php b/app/Http/Controllers/Admin/TestimonialController.php new file mode 100644 index 0000000..2f9c0b9 --- /dev/null +++ b/app/Http/Controllers/Admin/TestimonialController.php @@ -0,0 +1,47 @@ + 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(); + } +} diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..f6b011d --- /dev/null +++ b/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,66 @@ + 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(); + } +} diff --git a/app/Http/Controllers/ConsultationController.php b/app/Http/Controllers/ConsultationController.php new file mode 100644 index 0000000..7666c72 --- /dev/null +++ b/app/Http/Controllers/ConsultationController.php @@ -0,0 +1,30 @@ +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(); + } +} diff --git a/app/Http/Controllers/SitePageController.php b/app/Http/Controllers/SitePageController.php new file mode 100644 index 0000000..9ec1329 --- /dev/null +++ b/app/Http/Controllers/SitePageController.php @@ -0,0 +1,59 @@ + $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 + */ + private function projects(): Collection + { + return Project::query() + ->latestFirst() + ->get(['id', 'title', 'client', 'year', 'sector', 'program', 'scope']); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index c60b03c..8541a27 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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(), ], diff --git a/app/Http/Requests/Admin/ContactSettingsRequest.php b/app/Http/Requests/Admin/ContactSettingsRequest.php new file mode 100644 index 0000000..39bf788 --- /dev/null +++ b/app/Http/Requests/Admin/ContactSettingsRequest.php @@ -0,0 +1,22 @@ +|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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/OfficeRequest.php b/app/Http/Requests/Admin/OfficeRequest.php new file mode 100644 index 0000000..3743a2b --- /dev/null +++ b/app/Http/Requests/Admin/OfficeRequest.php @@ -0,0 +1,28 @@ +|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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/ProjectRequest.php b/app/Http/Requests/Admin/ProjectRequest.php new file mode 100644 index 0000000..f91bc16 --- /dev/null +++ b/app/Http/Requests/Admin/ProjectRequest.php @@ -0,0 +1,27 @@ +|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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/TestimonialRequest.php b/app/Http/Requests/Admin/TestimonialRequest.php new file mode 100644 index 0000000..f22722c --- /dev/null +++ b/app/Http/Requests/Admin/TestimonialRequest.php @@ -0,0 +1,34 @@ +merge(['is_published' => $this->boolean('is_published')]); + } + + /** + * @return array|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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/UpdateConsultationRequest.php b/app/Http/Requests/Admin/UpdateConsultationRequest.php new file mode 100644 index 0000000..8f7f311 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateConsultationRequest.php @@ -0,0 +1,22 @@ +|string> + */ + public function rules(): array + { + return [ + 'status' => ['required', Rule::enum(ConsultationStatus::class)], + 'admin_notes' => ['nullable', 'string', 'max:5000'], + ]; + } +} diff --git a/app/Http/Requests/Admin/UserRequest.php b/app/Http/Requests/Admin/UserRequest.php new file mode 100644 index 0000000..f4f0216 --- /dev/null +++ b/app/Http/Requests/Admin/UserRequest.php @@ -0,0 +1,50 @@ +|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 + */ + private function passwordRulesWithoutRequired(): array + { + return array_values(array_filter( + $this->passwordRules(), + fn ($rule) => $rule !== 'required', + )); + } +} diff --git a/app/Http/Requests/StoreConsultationRequest.php b/app/Http/Requests/StoreConsultationRequest.php new file mode 100644 index 0000000..3e3da0a --- /dev/null +++ b/app/Http/Requests/StoreConsultationRequest.php @@ -0,0 +1,35 @@ +|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'], + ]; + } +} diff --git a/app/Models/Consultation.php b/app/Models/Consultation.php new file mode 100644 index 0000000..f3427af --- /dev/null +++ b/app/Models/Consultation.php @@ -0,0 +1,47 @@ + + */ + protected function casts(): array + { + return [ + 'nda_agreed' => 'boolean', + 'status' => ConsultationStatus::class, + ]; + } +} diff --git a/app/Models/Office.php b/app/Models/Office.php new file mode 100644 index 0000000..dcd13cd --- /dev/null +++ b/app/Models/Office.php @@ -0,0 +1,40 @@ + SiteInfo::forget()); + static::deleted(fn () => SiteInfo::forget()); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'sort_order' => 'integer', + ]; + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php new file mode 100644 index 0000000..2d0e705 --- /dev/null +++ b/app/Models/Project.php @@ -0,0 +1,46 @@ + + */ + protected function casts(): array + { + return [ + 'year' => 'integer', + 'sector' => ProjectSector::class, + 'program' => ProjectProgram::class, + ]; + } + + /** + * Newest projects first, as the site lists them. + * + * @param Builder $query + */ + public function scopeLatestFirst(Builder $query): void + { + $query->orderByDesc('year')->orderBy('id'); + } +} diff --git a/app/Models/SiteSetting.php b/app/Models/SiteSetting.php new file mode 100644 index 0000000..9c6724e --- /dev/null +++ b/app/Models/SiteSetting.php @@ -0,0 +1,50 @@ + $value + */ +#[Fillable(['key', 'value'])] +class SiteSetting extends Model +{ + protected static function booted(): void + { + static::saved(fn () => SiteInfo::forget()); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'value' => 'array', + ]; + } + + /** + * @param array $default + * @return array + */ + public static function get(string $key, array $default = []): array + { + return static::query()->where('key', $key)->first()?->value ?? $default; + } + + /** + * @param array $value + */ + public static function put(string $key, array $value): void + { + static::query()->updateOrCreate(['key' => $key], ['value' => $value]); + } +} diff --git a/app/Models/Testimonial.php b/app/Models/Testimonial.php new file mode 100644 index 0000000..d25634e --- /dev/null +++ b/app/Models/Testimonial.php @@ -0,0 +1,63 @@ + + */ + protected function casts(): array + { + return [ + 'is_published' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + /** + * @param Builder $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}"}, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e67ee72..5280432 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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; + } } diff --git a/app/Notifications/NewConsultationRequest.php b/app/Notifications/NewConsultationRequest.php new file mode 100644 index 0000000..a30aec7 --- /dev/null +++ b/app/Notifications/NewConsultationRequest.php @@ -0,0 +1,56 @@ + + */ + 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')); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1525e9..654909e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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()); } /** diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 0215f0c..b7c4e23 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -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')); diff --git a/app/Support/SiteInfo.php b/app/Support/SiteInfo.php new file mode 100644 index 0000000..0a60e4d --- /dev/null +++ b/app/Support/SiteInfo.php @@ -0,0 +1,50 @@ + '(021) 7266-3353', + 'whatsapp' => '0818-0218-9425', + 'email_busdev' => 'busdev@probindo.com', + 'email_support' => 'support@probindo.com', + ]; + + /** + * @return array{contact: array, offices: list>} + */ + 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); + } +} diff --git a/config/app.php b/config/app.php index 1f8dd27..0cf1125 100644 --- a/config/app.php +++ b/config/app.php @@ -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'), + ], + ]; diff --git a/config/fortify.php b/config/fortify.php index cedebce..8bfe991 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -161,7 +161,6 @@ return [ */ 'features' => [ - Features::registration(), Features::resetPasswords(), Features::emailVerification(), Features::twoFactorAuthentication([ diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 98825c8..819e5eb 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -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. */ diff --git a/database/migrations/2026_09_29_062120_add_role_to_users_table.php b/database/migrations/2026_09_29_062120_add_role_to_users_table.php new file mode 100644 index 0000000..cb653c1 --- /dev/null +++ b/database/migrations/2026_09_29_062120_add_role_to_users_table.php @@ -0,0 +1,32 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_09_29_062121_create_consultations_table.php b/database/migrations/2026_09_29_062121_create_consultations_table.php new file mode 100644 index 0000000..c870e0b --- /dev/null +++ b/database/migrations/2026_09_29_062121_create_consultations_table.php @@ -0,0 +1,42 @@ +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'); + } +}; diff --git a/database/migrations/2026_09_29_062122_create_projects_table.php b/database/migrations/2026_09_29_062122_create_projects_table.php new file mode 100644 index 0000000..52e0c6f --- /dev/null +++ b/database/migrations/2026_09_29_062122_create_projects_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/migrations/2026_09_29_062123_create_offices_table.php b/database/migrations/2026_09_29_062123_create_offices_table.php new file mode 100644 index 0000000..8f15d5d --- /dev/null +++ b/database/migrations/2026_09_29_062123_create_offices_table.php @@ -0,0 +1,39 @@ +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'); + } +}; diff --git a/database/migrations/2026_09_29_062124_create_testimonials_table.php b/database/migrations/2026_09_29_062124_create_testimonials_table.php new file mode 100644 index 0000000..02d56f9 --- /dev/null +++ b/database/migrations/2026_09_29_062124_create_testimonials_table.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/database/migrations/2026_09_29_062125_create_site_settings_table.php b/database/migrations/2026_09_29_062125_create_site_settings_table.php new file mode 100644 index 0000000..994ddbe --- /dev/null +++ b/database/migrations/2026_09_29_062125_create_site_settings_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('key')->unique(); + $table->json('value'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('site_settings'); + } +}; diff --git a/database/seeders/AdminUserSeeder.php b/database/seeders/AdminUserSeeder.php new file mode 100644 index 0000000..eed93e0 --- /dev/null +++ b/database/seeders/AdminUserSeeder.php @@ -0,0 +1,43 @@ +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}"); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..c0d67ee 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -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, ]); } } diff --git a/database/seeders/SiteContentSeeder.php b/database/seeders/SiteContentSeeder.php new file mode 100644 index 0000000..92ba51d --- /dev/null +++ b/database/seeders/SiteContentSeeder.php @@ -0,0 +1,136 @@ +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> + */ + 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> + */ + 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], + ]; + } +} diff --git a/probindo_test b/probindo_test new file mode 100644 index 0000000..e69de29 diff --git a/resources/css/app.css b/resources/css/app.css index e4e6c82..eb8cb16 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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 { diff --git a/resources/js/app.tsx b/resources/js/app.tsx index e7c45c5..84b26aa 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -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/'): diff --git a/resources/js/components/admin-theme-toggle.tsx b/resources/js/components/admin-theme-toggle.tsx new file mode 100644 index 0000000..7b671a0 --- /dev/null +++ b/resources/js/components/admin-theme-toggle.tsx @@ -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 ( + + ); +} diff --git a/resources/js/components/admin/form-controls.tsx b/resources/js/components/admin/form-controls.tsx new file mode 100644 index 0000000..9b4337a --- /dev/null +++ b/resources/js/components/admin/form-controls.tsx @@ -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 ( +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {action} +
+ ); +} + +export function FormField({ + id, + label, + error, + className, + children, +}: { + id: string; + label: string; + error?: string; + className?: string; + children: ReactNode; +}) { + return ( +
+ + {children} + +
+ ); +} + +export function NativeSelect({ + options, + className, + ...props +}: ComponentProps<'select'> & { + options: ReadonlyArray<{ value: string; label: string }>; +}) { + return ( + + ); +} + +export function Textarea({ className, ...props }: ComponentProps<'textarea'>) { + return ( +