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

This commit is contained in:
2026-09-29 14:57:37 +07:00
parent 04fbddba13
commit 8c6dad2a31
94 changed files with 4864 additions and 1151 deletions
+3
View File
@@ -15,6 +15,9 @@ void createInertiaApp({
switch (true) {
case name.startsWith('site/'):
return SiteLayout;
// The admin login is a branded full-page screen of its own.
case name === 'auth/login':
return null;
case name.startsWith('auth/'):
return AuthLayout;
case name.startsWith('settings/'):
@@ -0,0 +1,23 @@
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAppearance } from '@/hooks/use-appearance';
/** Light/dark switch for the admin panel header. */
export function AdminThemeToggle() {
const { resolvedAppearance, updateAppearance } = useAppearance();
const isDark = resolvedAppearance === 'dark';
const label = isDark ? 'Ganti ke mode terang' : 'Ganti ke mode gelap';
return (
<Button
variant="outline"
size="icon"
aria-label={label}
title={label}
onClick={() => updateAppearance(isDark ? 'light' : 'dark')}
className="size-9 cursor-pointer"
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
);
}
@@ -0,0 +1,216 @@
import { Form } from '@inertiajs/react';
import type { ComponentProps, ReactNode } from 'react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import type { RouteFormDefinition } from '@/wayfinder';
const controlClasses =
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs outline-none focus-visible:ring-[3px] disabled:opacity-50 md:text-sm';
export function PageHeader({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-1">
<h1 className="font-display text-xl font-bold tracking-tight">
{title}
</h1>
{description && (
<p className="text-muted-foreground text-sm">
{description}
</p>
)}
</div>
{action}
</div>
);
}
export function FormField({
id,
label,
error,
className,
children,
}: {
id: string;
label: string;
error?: string;
className?: string;
children: ReactNode;
}) {
return (
<div className={cn('grid gap-2', className)}>
<Label htmlFor={id}>{label}</Label>
{children}
<InputError message={error} />
</div>
);
}
export function NativeSelect({
options,
className,
...props
}: ComponentProps<'select'> & {
options: ReadonlyArray<{ value: string; label: string }>;
}) {
return (
<select
className={cn(controlClasses, 'h-9 cursor-pointer py-1', className)}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
export function Textarea({ className, ...props }: ComponentProps<'textarea'>) {
return (
<textarea
rows={4}
className={cn(controlClasses, className)}
{...props}
/>
);
}
type Errors = Partial<Record<string, string>>;
/**
* A dialog holding a create/edit form; it closes itself once the server
* accepts the submission.
*/
export function FormDialog({
trigger,
title,
description,
form,
submitLabel = 'Simpan',
className,
children,
}: {
trigger: ReactNode;
title: string;
description?: string;
form: RouteFormDefinition<'get' | 'post'>;
submitLabel?: string;
className?: string;
children: (errors: Errors) => ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent
className={cn(
'max-h-[90vh] overflow-y-auto sm:max-w-lg',
className,
)}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && (
<DialogDescription>{description}</DialogDescription>
)}
</DialogHeader>
<Form
{...form}
options={{ preserveScroll: true }}
onSuccess={() => setOpen(false)}
className="flex flex-col gap-4"
>
{({ processing, errors }) => (
<>
{children(errors)}
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button type="button" variant="secondary">
Batal
</Button>
</DialogClose>
<Button type="submit" disabled={processing}>
{submitLabel}
</Button>
</DialogFooter>
</>
)}
</Form>
</DialogContent>
</Dialog>
);
}
/** Asks for confirmation before sending a delete request. */
export function ConfirmDelete({
trigger,
title,
description,
form,
}: {
trigger: ReactNode;
title: string;
description: string;
form: RouteFormDefinition<'get' | 'post'>;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Form
{...form}
options={{ preserveScroll: true }}
onSuccess={() => setOpen(false)}
>
{({ processing }) => (
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button type="button" variant="secondary">
Batal
</Button>
</DialogClose>
<Button
type="submit"
variant="destructive"
disabled={processing}
>
Hapus
</Button>
</DialogFooter>
)}
</Form>
</DialogContent>
</Dialog>
);
}
+13 -9
View File
@@ -1,18 +1,22 @@
import { usePage } from '@inertiajs/react';
import AppLogoIcon from '@/components/app-logo-icon';
import { company } from '@/data/site';
/** PAJ logo mark with the admin panel name, used in the sidebar header. */
export default function AppLogo() {
const { name } = usePage().props;
return (
<>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md">
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
<div className="border-sidebar-border flex aspect-square size-8 items-center justify-center rounded-md border bg-white p-1">
<img
src={company.logo}
alt={company.name}
className="h-full w-full object-contain"
/>
</div>
<div className="ml-1 grid flex-1 text-left text-sm">
<span className="mb-0.5 truncate leading-tight font-semibold">
{name}
<span className="font-display truncate leading-tight font-bold tracking-wide uppercase">
{company.brand}
</span>
<span className="text-sidebar-foreground/60 truncate font-mono text-[11px]">
Admin Panel
</span>
</div>
</>
@@ -1,3 +1,4 @@
import { AdminThemeToggle } from '@/components/admin-theme-toggle';
import { Breadcrumbs } from '@/components/breadcrumbs';
import { SidebarTrigger } from '@/components/ui/sidebar';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
@@ -13,6 +14,9 @@ export function AppSidebarHeader({
<SidebarTrigger className="-ml-1" />
<Breadcrumbs breadcrumbs={breadcrumbs} />
</div>
<div className="ml-auto">
<AdminThemeToggle />
</div>
</header>
);
}
+53 -12
View File
@@ -1,5 +1,13 @@
import { Link } from '@inertiajs/react';
import { BookOpen, FolderGit2, LayoutGrid } from 'lucide-react';
import { Link, usePage } from '@inertiajs/react';
import {
Building2,
FolderKanban,
Globe,
Inbox,
LayoutGrid,
MessageSquareQuote,
Users,
} from 'lucide-react';
import AppLogo from '@/components/app-logo';
import { NavFooter } from '@/components/nav-footer';
import { NavMain } from '@/components/nav-main';
@@ -13,7 +21,12 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import { dashboard, home } from '@/routes';
import { edit as companyEdit } from '@/routes/admin/company';
import { index as consultationsIndex } from '@/routes/admin/consultations';
import { index as projectsIndex } from '@/routes/admin/projects';
import { index as testimonialsIndex } from '@/routes/admin/testimonials';
import { index as usersIndex } from '@/routes/admin/users';
import type { NavItem } from '@/types';
const mainNavItems: NavItem[] = [
@@ -22,22 +35,50 @@ const mainNavItems: NavItem[] = [
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Inbox Konsultasi',
href: consultationsIndex(),
icon: Inbox,
},
{
title: 'Portofolio',
href: projectsIndex(),
icon: FolderKanban,
},
{
title: 'Info Perusahaan',
href: companyEdit(),
icon: Building2,
},
{
title: 'Testimoni',
href: testimonialsIndex(),
icon: MessageSquareQuote,
},
];
const adminNavItems: NavItem[] = [
{
title: 'Pengguna',
href: usersIndex(),
icon: Users,
},
];
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/react-starter-kit',
icon: FolderGit2,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#react',
icon: BookOpen,
title: 'Lihat Website',
href: home(),
icon: Globe,
},
];
export function AppSidebar() {
const { can } = usePage().props;
const navItems = can.manageUsers
? [...mainNavItems, ...adminNavItems]
: mainNavItems;
return (
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
@@ -53,7 +94,7 @@ export function AppSidebar() {
</SidebarHeader>
<SidebarContent>
<NavMain items={mainNavItems} />
<NavMain items={navItems} />
</SidebarContent>
<SidebarFooter>
+2 -2
View File
@@ -12,8 +12,8 @@ export default function Heading({
<h2
className={
variant === 'small'
? 'mb-0.5 text-base font-medium'
: 'text-xl font-semibold tracking-tight'
? 'font-display mb-0.5 text-base font-semibold'
: 'font-display text-xl font-bold tracking-tight'
}
>
{title}
+1 -1
View File
@@ -27,7 +27,7 @@ export function NavFooter({
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
className="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100"
className="text-sidebar-foreground/70 hover:text-sidebar-foreground"
>
<a
href={toUrl(item.href)}
@@ -1,30 +1,32 @@
import { Form } from '@inertiajs/react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Button } from '@/components/site/button';
import {
Field,
FieldError,
FormSuccess,
Honeypot,
SelectInput,
TextInput,
} from '@/components/site/form-field';
import { common, serviceOptions } from '@/content/common';
import { useContent } from '@/hooks/use-locale';
import { store } from '@/routes/consultations';
/**
* Compact consultation request form.
*
* TODO: submissions are not sent anywhere yet; wire this to a Laravel
* endpoint before going live.
*/
export default function ConsultationForm({ idPrefix }: { idPrefix: string }) {
type ConsultationFormProps = {
idPrefix: string;
/** Which page the request came from, shown in the admin inbox. */
source: 'home' | 'services';
};
/** Compact consultation request form, saved to the admin inbox. */
export default function ConsultationForm({
idPrefix,
source,
}: ConsultationFormProps) {
const t = useContent(common);
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitted(true);
};
return (
<div className="border-outline-variant/60 bg-surface-container-high flex flex-col gap-4 rounded-2xl border p-6 shadow-xl lg:p-7">
<div className="flex flex-col">
@@ -42,60 +44,82 @@ 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>
<TextInput
id={`${idPrefix}-name`}
name="name"
placeholder={t.form.namePlaceholder}
required
/>
</Field>
<Field
id={`${idPrefix}-email`}
label={t.form.email}
required
>
<TextInput
id={`${idPrefix}-email`}
name="email"
type="email"
placeholder={t.form.emailPlaceholder}
required
/>
</Field>
<Field
id={`${idPrefix}-phone`}
label={t.form.phone}
required
>
<TextInput
id={`${idPrefix}-phone`}
name="phone"
type="tel"
placeholder="+62 811 xxxx xxxx"
required
/>
</Field>
<Field id={`${idPrefix}-service`} label={t.form.service}>
<SelectInput
id={`${idPrefix}-service`}
name="service"
options={serviceOptions(t)}
/>
</Field>
<Button
type="submit"
size="block"
icon="send"
className="mt-2"
>
{t.form.submit}
</Button>
<p className="text-on-surface-variant text-center text-[11px]">
{t.form.nda}
</p>
</form>
<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`}
label={t.form.email}
required
>
<TextInput
id={`${idPrefix}-email`}
name="email"
type="email"
placeholder={t.form.emailPlaceholder}
required
/>
<FieldError message={errors.email} />
</Field>
<Field
id={`${idPrefix}-phone`}
label={t.form.phone}
required
>
<TextInput
id={`${idPrefix}-phone`}
name="phone"
type="tel"
placeholder="+62 811 xxxx xxxx"
required
/>
<FieldError message={errors.phone} />
</Field>
<Field
id={`${idPrefix}-service`}
label={t.form.service}
>
<SelectInput
id={`${idPrefix}-service`}
name="service"
options={serviceOptions(t)}
/>
</Field>
<Button
type="submit"
size="block"
icon={processing ? 'progress_activity' : 'send'}
disabled={processing}
className="mt-2"
>
{t.form.submit}
</Button>
<p className="text-on-surface-variant text-center text-[11px]">
{t.form.nda}
</p>
</>
)}
</Form>
)}
</div>
);
@@ -39,6 +39,32 @@ export function Field({
);
}
/** Validation message under a field. */
export function FieldError({ message }: { message?: string }) {
if (!message) {
return null;
}
return <p className="text-primary text-xs font-medium">{message}</p>;
}
/**
* Hidden field only bots fill in; the server rejects any submission that
* has a value here.
*/
export function Honeypot() {
return (
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="absolute -left-[9999px] h-0 w-0 opacity-0"
/>
);
}
export function TextInput({
className,
...props
@@ -4,11 +4,12 @@ import Icon from '@/components/site/icon';
import Pill from '@/components/site/pill';
import Section from '@/components/site/section';
import { common } from '@/content/common';
import { company, offices } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { useSiteInfo } from '@/hooks/use-site-info';
type ConsultationSectionProps = {
id: string;
source: 'home' | 'services';
title: string;
description: string;
};
@@ -16,18 +17,20 @@ type ConsultationSectionProps = {
/** Closing call-to-action with contact details and the quick form. */
export default function ConsultationSection({
id,
source,
title,
description,
}: ConsultationSectionProps) {
const site = useSiteInfo();
const t = useContent(common);
const contacts = offices.map((office) => ({
const contacts = site.offices.map((office) => ({
icon: office.icon,
title: office.name,
lines: [
office.building,
`${office.street}, ${office.area}`,
office.city,
`${t.consultation.tel}: ${office.phone.label}`,
`${t.consultation.tel}: ${office.phone}`,
],
}));
@@ -74,18 +77,18 @@ export default function ConsultationSection({
))}
</div>
<a
href={`mailto:${company.email.busdev}`}
href={`mailto:${site.email.busdev}`}
className="text-on-surface-variant hover:text-primary flex items-center gap-2 text-xs"
>
<Icon
name="mail"
className="text-primary text-base"
/>
{company.email.busdev}
{site.email.busdev}
</a>
</div>
<div className="w-full lg:col-span-5">
<ConsultationForm idPrefix={id} />
<ConsultationForm idPrefix={id} source={source} />
</div>
</div>
</div>
@@ -3,7 +3,7 @@ import Glow from '@/components/site/glow';
import Icon from '@/components/site/icon';
import Pill from '@/components/site/pill';
import Section from '@/components/site/section';
import { company } from '@/data/site';
import { useSiteInfo } from '@/hooks/use-site-info';
type CtaBannerProps = {
badge: string;
@@ -22,9 +22,10 @@ export default function CtaBanner({
actions,
showContacts = true,
}: CtaBannerProps) {
const site = useSiteInfo();
const contacts = [
{ icon: 'call', label: company.phone.label },
{ icon: 'mail', label: company.email.busdev },
{ icon: 'call', label: site.phone.label },
{ icon: 'mail', label: site.email.busdev },
{ icon: 'location_on', label: 'Bintaro & Plaza Simatupang, Jakarta' },
];
+10 -7
View File
@@ -2,9 +2,10 @@ import { Link } from '@inertiajs/react';
import Container from '@/components/site/container';
import Icon from '@/components/site/icon';
import { common } from '@/content/common';
import { company, consultationUrl, officeAddress, offices } from '@/data/site';
import { company, consultationUrl, officeAddress } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { about, clients, contact, services } from '@/routes';
import { useSiteInfo } from '@/hooks/use-site-info';
import { about, clients, contact, login, services } from '@/routes';
function FooterHeading({ children }: { children: string }) {
return (
@@ -15,6 +16,7 @@ function FooterHeading({ children }: { children: string }) {
}
export default function SiteFooter() {
const site = useSiteInfo();
const t = useContent(common);
const companyLinks = [
@@ -27,6 +29,7 @@ export default function SiteFooter() {
{ label: t.footer.links.clients, href: clients.url() },
{ label: t.footer.links.audit, href: consultationUrl },
{ label: t.footer.links.offices, href: `${contact.url()}#lokasi` },
{ label: t.footer.links.admin, href: login.url() },
];
return (
@@ -50,7 +53,7 @@ export default function SiteFooter() {
{t.footer.tagline}
</p>
<div className="text-on-surface-variant mt-2 flex flex-col gap-4 text-xs">
{offices.map((office) => (
{site.offices.map((office) => (
<div
key={office.name}
className="flex items-start gap-2"
@@ -65,23 +68,23 @@ export default function SiteFooter() {
</span>
<span>{officeAddress(office)}</span>
<a
href={office.phone.href}
href={office.phoneHref}
className="hover:text-primary font-mono transition-colors"
>
{t.footer.tel} {office.phone.label}
{t.footer.tel} {office.phone}
</a>
</div>
</div>
))}
<a
href={`mailto:${company.email.busdev}`}
href={`mailto:${site.email.busdev}`}
className="hover:text-primary flex items-center gap-2 font-mono transition-colors"
>
<Icon
name="mail"
className="text-primary text-base"
/>
{company.email.busdev}
{site.email.busdev}
</a>
</div>
</div>
+7 -5
View File
@@ -7,12 +7,14 @@ import Icon from '@/components/site/icon';
import LanguageToggle from '@/components/site/language-toggle';
import ThemeToggle from '@/components/site/theme-toggle';
import { common } from '@/content/common';
import { company, consultationUrl, navigation, offices } from '@/data/site';
import { consultationUrl, navigation } from '@/data/site';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { useContent } from '@/hooks/use-locale';
import { useSiteInfo } from '@/hooks/use-site-info';
import { cn } from '@/lib/utils';
export default function SiteHeader() {
const site = useSiteInfo();
const t = useContent(common);
const { isCurrentUrl } = useCurrentUrl();
const [menuOpen, setMenuOpen] = useState(false);
@@ -46,20 +48,20 @@ export default function SiteHeader() {
<div className="flex items-center gap-2 sm:gap-3">
<div className="text-on-surface-variant hidden flex-col text-right font-mono text-[11px] 2xl:flex">
<a
href={offices[0].phone.href}
href={site.phone.href}
className="text-on-surface hover:text-primary flex items-center justify-end gap-1 font-semibold"
>
<Icon
name="call"
className="text-primary text-xs"
/>
{offices[0].phone.label}
{site.phone.label}
</a>
<a
href={`mailto:${company.email.busdev}`}
href={`mailto:${site.email.busdev}`}
className="hover:text-primary"
>
{company.email.busdev}
{site.email.busdev}
</a>
</div>
<LanguageToggle />
+10 -7
View File
@@ -1,4 +1,3 @@
import { clientCount, projects } from '@/data/projects';
import type { ProjectSector } from '@/data/projects';
import type { Localized } from '@/hooks/use-locale';
@@ -25,10 +24,11 @@ const id = {
'Berdiri tahun 1985 sebagai perusahaan trading terkemuka di sektor Oil & Gas dan Industri Baja (Steel), lalu sejak 2002 bertransformasi menjadi konsultan IT.',
},
{
value: `${clientCount}`,
value: '{clients}',
unit: 'Klien',
label: 'Klien • Portofolio',
description: `${projects.length} proyek sistem informasi untuk instansi pemerintahan, BUMN, dan perusahaan swasta.`,
description:
'{projects} proyek sistem informasi untuk instansi pemerintahan, BUMN, dan perusahaan swasta.',
},
],
},
@@ -54,7 +54,8 @@ const id = {
year: '2002',
icon: 'precision_manufacturing',
title: 'Evolusi Teknologi Informasi (TI)',
description: `Pada tahun 2002 perusahaan bertransformasi menjadi konsultan Sistem Informasi dan Teknologi Informasi, dengan fokus pada perancangan, pembangunan, dan implementasi aplikasi bisnis. Sejak itu PAJ telah menangani ${projects.length} proyek untuk ${clientCount} klien pemerintahan, BUMN, dan swasta — mulai dari sistem pemantauan harga Kementerian Perdagangan hingga layanan perizinan investasi BKPM.`,
description:
'Pada tahun 2002 perusahaan bertransformasi menjadi konsultan Sistem Informasi dan Teknologi Informasi, dengan fokus pada perancangan, pembangunan, dan implementasi aplikasi bisnis. Sejak itu PAJ telah menangani {projects} proyek untuk {clients} klien pemerintahan, BUMN, dan swasta — mulai dari sistem pemantauan harga Kementerian Perdagangan hingga layanan perizinan investasi BKPM.',
note: {
icon: 'data_object',
label: 'Automated Business Applications',
@@ -215,10 +216,11 @@ const en: typeof id = {
'Founded in 1985 as a leading trading company in the Oil & Gas and Steel industries, and an IT consultant since its transformation in 2002.',
},
{
value: `${clientCount}`,
value: '{clients}',
unit: 'Clients',
label: 'Clients • Portfolio',
description: `${projects.length} information systems projects for government, state-owned, and private organizations.`,
description:
'{projects} information systems projects for government, state-owned, and private organizations.',
},
],
},
@@ -244,7 +246,8 @@ const en: typeof id = {
year: '2002',
icon: 'precision_manufacturing',
title: 'Evolution into Information Technology',
description: `In 2002, the company transformed into an Information Systems and Information Technology consultant, focused on designing, building, and implementing business applications. Since then, PAJ has delivered ${projects.length} projects for ${clientCount} government, state-owned, and private clients — from the Ministry of Trade’s price monitoring systems to BKPM’s investment licensing services.`,
description:
'In 2002, the company transformed into an Information Systems and Information Technology consultant, focused on designing, building, and implementing business applications. Since then, PAJ has delivered {projects} projects for {clients} government, state-owned, and private clients — from the Ministry of Trade’s price monitoring systems to BKPM’s investment licensing services.',
note: {
icon: 'data_object',
label: 'Automated Business Applications',
+8 -19
View File
@@ -1,5 +1,4 @@
import type { ProjectProgram } from '@/data/projects';
import { clientCount, projects } from '@/data/projects';
import type { Localized } from '@/hooks/use-locale';
const id = {
@@ -11,8 +10,8 @@ const id = {
'“Kami mengucapkan terima kasih atas kepercayaan dan dukungan Anda sebagai klien kami. Sebagai tim konsultan IT di PT Probindo Artika Jaya (PAJ), kami berkomitmen untuk menyediakan solusi teknologi informasi yang berkualitas tinggi, tailor-fit, dan memberikan nilai tambah nyata bagi kesinambungan bisnis Anda.”',
stats: [
{
value: `${clientCount} Klien`,
caption: `${projects.length} Proyek Pemerintahan, BUMN & Swasta`,
value: '{clients} Klien',
caption: '{projects} Proyek Pemerintahan, BUMN & Swasta',
},
{ value: '20+ Thn', caption: 'Konsultan IT Sejak 2002' },
{ value: '99.8%', caption: 'Tingkat Retensi & Kepuasan' },
@@ -22,7 +21,8 @@ const id = {
heritage: {
eyebrow: 'Konsultan IT Sejak 2002',
title: 'Fondasi Sistem Informasi untuk Pemerintahan, BUMN & Swasta',
description: `Sejak bertransformasi menjadi konsultan sistem informasi pada 2002, PAJ telah menangani ${projects.length} proyek untuk ${clientCount} klien — mulai dari sistem pemantauan harga dan layanan perizinan investasi di kementerian, hingga implementasi ERP dan sistem akuntansi di perusahaan swasta. Setiap solusi dirancang mengikuti alur kerja riil klien, bukan sekadar perangkat lunak generik.`,
description:
'Sejak bertransformasi menjadi konsultan sistem informasi pada 2002, PAJ telah menangani {projects} proyek untuk {clients} klien — mulai dari sistem pemantauan harga dan layanan perizinan investasi di kementerian, hingga implementasi ERP dan sistem akuntansi di perusahaan swasta. Setiap solusi dirancang mengikuti alur kerja riil klien, bukan sekadar perangkat lunak generik.',
scsrTitle: 'Pendampingan Berkelanjutan',
scsrText:
'Banyak sistem kami dikembangkan, dipelihara, dan diperbarui selama bertahun-tahun bersama klien yang sama — seperti sistem pemantauan harga bahan pokok (SP2KP) yang kami tangani sejak 2013.',
@@ -136,12 +136,6 @@ const id = {
description:
'Apresiasi nyata atas komitmen tanpa henti dari tim konsultan teknis PT Probindo Artika Jaya.',
rating: 'Rating 5 dari 5',
items: [] as {
initials: string;
quote: string;
role: string;
organization: string;
}[],
},
reliability: {
eyebrow: 'Reliability Index & Audit Trail',
@@ -181,8 +175,8 @@ const en: typeof id = {
'“Thank you for your trust and support as our client. As the IT consulting team at PT Probindo Artika Jaya (PAJ), we are committed to delivering high-quality, tailor-fit information technology solutions that add real value to the continuity of your business.”',
stats: [
{
value: `${clientCount} Clients`,
caption: `${projects.length} Government, SOE & Private Projects`,
value: '{clients} Clients',
caption: '{projects} Government, SOE & Private Projects',
},
{
value: '20+ Yrs',
@@ -195,7 +189,8 @@ const en: typeof id = {
heritage: {
eyebrow: 'IT Consultant Since 2002',
title: 'Information Systems Foundations for Government, SOEs & Private Companies',
description: `Since becoming an information systems consultant in 2002, PAJ has delivered ${projects.length} projects for ${clientCount} clients — from price monitoring and investment licensing systems for ministries to ERP and accounting systems for private companies. Every solution follows the client’s real workflow, not generic software.`,
description:
'Since becoming an information systems consultant in 2002, PAJ has delivered {projects} projects for {clients} clients — from price monitoring and investment licensing systems for ministries to ERP and accounting systems for private companies. Every solution follows the client’s real workflow, not generic software.',
scsrTitle: 'Long-Term Support',
scsrText:
'Many of our systems have been developed, maintained, and upgraded for years with the same clients — such as the staple goods price monitoring system (SP2KP) we have worked on since 2013.',
@@ -303,12 +298,6 @@ const en: typeof id = {
description:
'Genuine appreciation for the unwavering commitment of PT Probindo Artika Jaya’s technical consultants.',
rating: 'Rated 5 out of 5',
items: [] as {
initials: string;
quote: string;
role: string;
organization: string;
}[],
},
reliability: {
eyebrow: 'Reliability Index & Audit Trail',
+40 -8
View File
@@ -40,6 +40,7 @@ const id = {
clients: 'Daftar Klien Industri',
audit: 'Permintaan Audit Sistem',
offices: 'Lokasi Kantor PAJ',
admin: 'Admin Panel',
},
rights: 'Hak Cipta Dilindungi Undang-Undang.',
location: 'Jakarta Selatan, Indonesia',
@@ -142,6 +143,19 @@ const id = {
badge: 'KONSULTASI TEKNIS & AUDIT INFRASTRUKTUR',
tel: 'Telp',
},
login: {
pageTitle: 'Masuk Admin Panel',
title: 'Masuk ke Admin Panel',
description: 'Masukkan email dan password akun admin Anda.',
email: 'Email',
emailPlaceholder: 'nama@probindo.com',
password: 'Password',
passwordPlaceholder: 'Password',
forgot: 'Lupa password?',
remember: 'Ingat saya',
submit: 'Masuk',
back: 'Kembali ke website',
},
};
const en: typeof id = {
@@ -172,6 +186,7 @@ const en: typeof id = {
clients: 'Industry Clients',
audit: 'Request a System Audit',
offices: 'PAJ Office Locations',
admin: 'Admin Panel',
},
rights: 'All rights reserved.',
location: 'South Jakarta, Indonesia',
@@ -274,15 +289,32 @@ const en: typeof id = {
badge: 'TECHNICAL CONSULTATION & INFRASTRUCTURE AUDIT',
tel: 'Tel',
},
login: {
pageTitle: 'Admin Panel Login',
title: 'Log in to the Admin Panel',
description: 'Enter the email and password of your admin account.',
email: 'Email',
emailPlaceholder: 'name@probindo.com',
password: 'Password',
passwordPlaceholder: 'Password',
forgot: 'Forgot password?',
remember: 'Remember me',
submit: 'Log in',
back: 'Back to website',
},
};
export const common: Localized<typeof id> = { id, en };
/** Options for the "service needed" select in every consultation form. */
export const serviceOptions = (content: typeof id) => [
...content.services.map((service, index) => ({
value: `service-${index + 1}`,
label: `${index + 1}. ${service.expansion ? `${service.title} (${service.expansion})` : service.title}`,
})),
...content.extraServiceOptions,
];
/**
* Options for the "service needed" select in every consultation form. The
* submitted value is the label itself, so the admin inbox reads naturally.
*/
export const serviceOptions = (content: typeof id) =>
[
...content.services.map(
(service, index) =>
`${index + 1}. ${service.expansion ? `${service.title} (${service.expansion})` : service.title}`,
),
...content.extraServiceOptions.map((option) => option.label),
].map((label) => ({ value: label, label }));
+10 -9
View File
@@ -1,4 +1,3 @@
import { clientCount, projects } from '@/data/projects';
import type { Localized } from '@/hooks/use-locale';
const id = {
@@ -11,7 +10,7 @@ const id = {
primaryAction: 'Jadwalkan Konsultasi',
secondaryAction: 'Lihat Layanan Kami',
trustBefore: 'Dipercaya oleh',
trustLink: `${clientCount} klien`,
trustLink: '{clients} klien',
trustAfter: 'dari instansi pemerintahan, BUMN, dan perusahaan swasta.',
},
console: {
@@ -59,9 +58,10 @@ const id = {
},
{
label: 'Klien • Portofolio',
value: `${clientCount}`,
value: '{clients}',
unit: 'Klien',
description: `${projects.length} proyek sistem informasi untuk instansi pemerintahan, BUMN, dan perusahaan swasta.`,
description:
'{projects} proyek sistem informasi untuk instansi pemerintahan, BUMN, dan perusahaan swasta.',
},
{
label: 'Layanan • Keandalan',
@@ -82,7 +82,7 @@ const id = {
eyebrow: 'Klien & Rekam Jejak Proyek',
description:
'Dipercaya instansi pemerintahan, BUMN, dan perusahaan swasta untuk membangun dan memelihara sistem informasinya.',
since: `${projects.length} PROYEK • ${clientCount} KLIEN • SEJAK 2002`,
since: '{projects} PROYEK • {clients} KLIEN • SEJAK 2002',
more: 'Lihat semua klien & proyek',
},
profile: {
@@ -147,7 +147,7 @@ const en: typeof id = {
primaryAction: 'Schedule a Consultation',
secondaryAction: 'Explore Our Services',
trustBefore: 'Trusted by',
trustLink: `${clientCount} clients`,
trustLink: '{clients} clients',
trustAfter:
'across government, state-owned, and private organizations.',
},
@@ -196,9 +196,10 @@ const en: typeof id = {
},
{
label: 'Clients • Portfolio',
value: `${clientCount}`,
value: '{clients}',
unit: 'Clients',
description: `${projects.length} information systems projects for government, state-owned, and private organizations.`,
description:
'{projects} information systems projects for government, state-owned, and private organizations.',
},
{
label: 'Services • Reliability',
@@ -219,7 +220,7 @@ const en: typeof id = {
eyebrow: 'Clients & Project Track Record',
description:
'Trusted by government institutions, state-owned enterprises, and private companies to build and maintain their information systems.',
since: `${projects.length} PROJECTS • ${clientCount} CLIENTS • SINCE 2002`,
since: '{projects} PROJECTS • {clients} CLIENTS • SINCE 2002',
more: 'See all clients & projects',
},
profile: {
+58 -396
View File
@@ -1,386 +1,11 @@
import type { Project, ProjectProgram, ProjectSector } from '@/types';
export type { Project, ProjectProgram, ProjectSector } from '@/types';
/**
* PAJ's IT project track record. Project and client names are official
* Indonesian names, so they are shown as-is in both languages.
* Portfolio helpers. The projects themselves are managed in the admin panel
* and arrive as a page prop, already sorted newest first.
*/
export type ProjectSector = 'government' | 'private' | 'soe';
/** Long-running programs highlighted as featured case studies. */
export type ProjectProgram = 'price-monitoring' | 'investment' | 'erp';
export type Project = {
title: string;
client: string;
year: number;
sector: ProjectSector;
program?: ProjectProgram;
/** Optional detail of what the project covered. */
scope?: string;
};
const government: Omit<Project, 'sector'>[] = [
{
title: 'Penyusunan dan Pembuatan SOP Direktorat Data dan Informasi',
client: 'Kementerian Investasi / Badan Koordinasi Penanaman Modal (BKPM)',
year: 2025,
program: 'investment',
},
{
title: 'Perencanaan Peta Jalan Direktorat Data dan Informasi TA 2024',
client: 'Kementerian Investasi / Badan Koordinasi Penanaman Modal (BKPM)',
year: 2024,
program: 'investment',
},
{
title: 'Modul Pelayanan Metrologi Legal Submodul Verifikasi (Sistem Informasi Ketertelusuran Standar (SIKS))',
client: 'Kementerian Perdagangan',
year: 2024,
},
{
title: 'Penyediaan Data Harga dan Pasokan Cabe dan Bawang di Pasar Induk Wilayah Barat',
client: 'Kementerian Perdagangan',
year: 2024,
program: 'price-monitoring',
},
{
title: 'Pemeliharaan Sistem Pelaporan Harga (SP2KP), Pusat Data dan Sistem Informasi',
client: 'Kementerian Perdagangan',
year: 2023,
program: 'price-monitoring',
},
{
title: 'Penyediaan Data Harga dan Pasokan Cabe dan Bawang di Pasar Induk Wilayah Barat',
client: 'Kementerian Perdagangan',
year: 2023,
program: 'price-monitoring',
},
{
title: 'Pembangunan Aplikasi Sertifikasi Prasarana Perkeretaapian Dengan Memanfaatkan Teknologi Informasi',
client: 'Kementerian Perhubungan',
year: 2022,
},
{
title: 'Pengembangan Aplikasi LDH',
client: 'Badan Informasi Geospasial (BIG)',
year: 2022,
program: 'erp',
scope: 'OpenERP Community: manajemen dokumen dan custom development',
},
{
title: 'Perawatan Sistem Pelaporan Harga',
client: 'Kementerian Perdagangan',
year: 2022,
program: 'price-monitoring',
},
{
title: 'Pengembangan Aplikasi Sistem Informasi Ketertelusuran Standar',
client: 'Kementerian Perdagangan',
year: 2021,
},
{
title: 'Pengembangan Modul Master Data Aplikasi SIREBEKA (Sistem Informasi Remunerasi Berbasis Kinerja)',
client: 'Kementerian Perhubungan',
year: 2021,
},
{
title: 'Pengelolaan Sistem Pelaporan Harga',
client: 'Kementerian Perdagangan',
year: 2020,
program: 'price-monitoring',
},
{
title: 'Pengadaan Metrologi Aplikasi Mobile: Pendataan dan Pemetaan Kondisi UTTP dan BDKT dalam Kerangka Pengawasan Kemetrologian',
client: 'Kementerian Perdagangan',
year: 2020,
},
{
title: 'Penyediaan Data Harga dan Pasokan Cabe dan Bawang di Pasar Induk Wilayah Barat',
client: 'Kementerian Perdagangan',
year: 2020,
program: 'price-monitoring',
},
{
title: 'Penyediaan Data Harga dan Pasokan Cabe dan Bawang di Pasar Induk Wilayah Timur',
client: 'Kementerian Perdagangan',
year: 2020,
program: 'price-monitoring',
},
{
title: 'Perawatan Sistem Pelaporan Harga',
client: 'Kementerian Perdagangan',
year: 2020,
program: 'price-monitoring',
},
{
title: 'Perancangan Antar Muka Sistem Informasi Ketertelusuran Standar',
client: 'Kementerian Perdagangan',
year: 2019,
},
{
title: 'Pengembangan dan Pemeliharaan Portal NSWI TA 2019',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2019,
program: 'investment',
},
{
title: 'Pengembangan Aplikasi Website Intranet',
client: 'Kementerian Perdagangan',
year: 2018,
},
{
title: 'Pemantauan Harga Bapok 34 Ibukota Provinsi pada Sabtu atau Minggu, Libur Nasional dan HBKN, Pasar Kab/Kota, Daerah TTT dan Pasar Ritel Modern Tahun 2018',
client: 'Kementerian Perdagangan',
year: 2018,
program: 'price-monitoring',
},
{
title: 'Pengembangan Business Intelligence (BI) TA 2018',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2018,
program: 'investment',
},
{
title: 'Pengembangan Dashboard Produk Wisata MICE',
client: 'Kementerian Pariwisata',
year: 2017,
},
{
title: 'Pembuatan Sistem Informasi Manajemen Harga dan Pasokan',
client: 'Kementerian Perdagangan',
year: 2017,
program: 'price-monitoring',
},
{
title: 'Penerapan Model Pembangunan dan Revitalisasi Pasar Rakyat Dengan Pendekatan Teknologi Informasi Berbasis Mobile',
client: 'Kementerian Perdagangan',
year: 2017,
},
{
title: 'Penyusunan Pemutakhiran (Updating) Cetak Biru Pengembangan Sistem Pelayanan Informasi dan Perizinan',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2017,
program: 'investment',
},
{
title: 'Pemantauan Stok dan Pasokan Barang Kebutuhan Pokok 100 Titik di Pasar Rakyat (Tradisional), Pasar Induk dan Sentra Produksi',
client: 'Kementerian Perdagangan',
year: 2017,
program: 'price-monitoring',
},
{
title: 'Verifikasi Pelaporan Harga Barang Pokok pada SP2KP',
client: 'Kementerian Perdagangan',
year: 2016,
program: 'price-monitoring',
},
{
title: 'Pengembangan Program Aplikasi Harga Kabupaten Kota',
client: 'Kementerian Perdagangan',
year: 2016,
program: 'price-monitoring',
},
{
title: 'Updating Program Penilaian Pasar',
client: 'Kementerian Perdagangan',
year: 2016,
},
{
title: 'Pengembangan Sistem Aplikasi Jaringan Informasi Dokumentasi Hukum',
client: 'Badan Informasi Geospasial (BIG)',
year: 2016,
program: 'erp',
scope: 'OpenERP Community: manajemen dokumen dan custom development',
},
{
title: 'Verifikasi Pelaporan Harga Barang Pokok pada Sistem Pemantauan Pasar Kebutuhan Pokok (SP2KP)',
client: 'Kementerian Perdagangan',
year: 2016,
program: 'price-monitoring',
},
{
title: 'Pekerjaan Pengembangan Program Aplikasi Harga Kabupaten Kota',
client: 'Kementerian Perdagangan',
year: 2016,
program: 'price-monitoring',
},
{
title: 'Pembuatan Software pada Kegiatan Penguatan Regional Climate Change Center',
client: 'Badan Meteorologi Klimatologi dan Geofisika (BMKG)',
year: 2015,
},
{
title: 'Kajian Tata Kelola Teknologi Informasi BKPM TA 2014',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2014,
program: 'investment',
},
{
title: 'Sistem Informasi Monitoring Updating Harga dan Pasokan Bawang Merah',
client: 'Kementerian Perdagangan',
year: 2014,
program: 'price-monitoring',
},
{
title: 'Pemetaan Indikasi dan Variabel Penentu Kenaikan Harga Bahan Pokok (Komoditas Cabe Merah, Bawang Merah dan Beras)',
client: 'Kementerian Perdagangan',
year: 2014,
program: 'price-monitoring',
},
{
title: 'Pembangunan Sistem Informasi Pelayanan Dokumen Hukum',
client: 'Badan Informasi Geospasial (BIG)',
year: 2014,
program: 'erp',
scope: 'OpenERP Community: manajemen dokumen dan custom development',
},
{
title: 'Pendampingan Helpdesk Implementasi Sistem Pelayanan Informasi dan Perizinan Investasi Secara Elektronik (SPIPISE)',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2013,
program: 'investment',
},
{
title: 'Pengadaan Jasa Konsultasi Pekerjaan Pembangunan Sub Sistem Bidang Ketahanan Lembaga Usaha Ekonomi',
client: 'Kementerian Perdagangan',
year: 2012,
},
{
title: 'Pendampingan Helpdesk Implementasi Sistem Pelayanan Informasi dan Perizinan Investasi Secara Elektronik (SPIPISE)',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2012,
program: 'investment',
},
{
title: 'Pembangunan Aplikasi Jaringan Dokumentasi dan Informasi Hukum',
client: 'Badan Informasi Geospasial (BIG)',
year: 2012,
program: 'erp',
scope: 'OpenERP Community: manajemen dokumen dan custom development',
},
{
title: 'Pengembangan Portal National Single Window for Investment (NSWI) untuk Mendukung SPIPISE BKPM',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2010,
program: 'investment',
},
{
title: 'Pembuatan Database Eksportir Importir, Perusahaan, Asosiasi dan Pelaku Industri Kreatif serta Lembaga Pendidikan Formal/Non Formal',
client: 'Departemen Perdagangan Republik Indonesia',
year: 2009,
},
{
title: 'Pembangunan Sistem Informasi Tracking Promosi Investasi Tahun Anggaran 2006',
client: 'Badan Koordinasi Penanaman Modal (BKPM)',
year: 2006,
program: 'investment',
},
];
const privateSector: Omit<Project, 'sector'>[] = [
{
title: 'Implementasi Odoo ERP',
client: 'Marawah Beach Club',
year: 2022,
program: 'erp',
scope: 'Odoo Enterprise: POS, event, pembelian, penjualan, website, dan custom development',
},
{
title: 'Enhancement Manajemen Sistem',
client: 'PT Cipta Kridatama',
year: 2022,
},
{
title: 'Implementasi OpenERP Manufacturing System',
client: 'CV Mujur Jaya Abadi',
year: 2015,
program: 'erp',
scope: 'OpenERP Community: akunting, manufaktur, pembelian, penjualan, inventory, dan custom development',
},
{
title: 'Pengembangan Reswara Integrated Management System',
client: 'PT Reswara Minergi Hartama',
year: 2013,
},
{
title: 'Kegiatan Pengembangan dan Operasionalisasi Sistem Pemantauan Pasar Kebutuhan Pokok (SP2KP)',
client: 'PT LAPI ITB (Bapokstra)',
year: 2013,
program: 'price-monitoring',
},
{
title: 'Pengembangan Aplikasi GIS Pelaporan Online Harga',
client: 'PT DKN',
year: 2011,
program: 'price-monitoring',
},
{
title: 'Pengembangan Portal Ekonomi Kreatif Indonesia (www.indonesiakreatif.net)',
client: 'CV Multi Sarana Putra',
year: 2010,
},
{
title: 'Pengembangan Cetak Biru Teknologi Informasi Pusat Pengendalian Online Harga dan Stok',
client: 'PT Pranala Nitisala',
year: 2010,
program: 'price-monitoring',
},
{
title: 'Pembuatan Sistem Informasi Monitoring Penerimaan dan Pengeluaran BBM di Setiap Fasilitas Rantai Pasok BBM',
client: 'KSO LAPI – Pranala',
year: 2008,
},
{
title: 'Validasi dan Koreksi Data Akunting',
client: 'PT Lautan Berlian Utama Motor',
year: 2008,
},
{
title: 'Pengembangan, Pemutakhiran dan Pemeliharaan Website Tahun Anggaran 2007',
client: 'CV Multi Sarana Putra',
year: 2007,
},
{
title: 'Pembuatan Compact Disc (CD) Profil Wilayah, Sektor dan Komoditi',
client: 'PT Qualita Prima Pariwara',
year: 2007,
},
{
title: 'Pengembangan Aplikasi Titis Sampurna Accounting Information System',
client: 'PT Titis Sampurna',
year: 2006,
program: 'erp',
},
{
title: 'Jasa Konsultasi Pengembangan Sistem Informasi Eksekutif Keuangan',
client: 'CV Multi Sarana Putra',
year: 2006,
},
{
title: 'Desain Sistem Informasi Museum Bank Indonesia',
client: 'PT Global Media Inline',
year: 2006,
},
];
const soe: Omit<Project, 'sector'>[] = [
{
title: 'Pembangunan Sistem Aplikasi',
client: 'PT Sucofindo (Persero)',
year: 2015,
},
{
title: 'Jasa Pemeliharaan Sistem Merpati Operation System Infrastructure (MOSI), Mobile Flight Log (MFL), Schedule Messaging System (SMS)',
client: 'PT Merpati Nusantara Airlines',
year: 2006,
},
];
const newestFirst = (a: Project, b: Project) => b.year - a.year;
export const projects: Project[] = [
...government.map((p) => ({ ...p, sector: 'government' as const })),
...privateSector.map((p) => ({ ...p, sector: 'private' as const })),
...soe.map((p) => ({ ...p, sector: 'soe' as const })),
].sort(newestFirst);
/** Different names used over the years for the same institution. */
const clientAliases: Record<string, string> = {
@@ -392,11 +17,8 @@ const clientAliases: Record<string, string> = {
const clientName = (project: Project) =>
clientAliases[project.client] ?? project.client;
/** Number of distinct clients across all projects. */
export const clientCount = new Set(projects.map(clientName)).size;
/** Distinct clients, most frequent first. */
const rankClients = (items: Project[]) => {
export const rankClients = (items: Project[]) => {
const counts = new Map<string, number>();
items.forEach((project) => {
@@ -409,24 +31,64 @@ const rankClients = (items: Project[]) => {
.map(([name]) => name);
};
export const clientsInSector = (sector: ProjectSector) =>
rankClients(projects.filter((project) => project.sector === sector));
export const bySector = (items: Project[], sector: ProjectSector) =>
items.filter((project) => project.sector === sector);
/** Clients with the most projects across every sector. */
export const topClients = (limit: number) =>
rankClients(projects).slice(0, limit);
export const projectsBySector = (sector: ProjectSector) =>
projects.filter((project) => project.sector === sector);
export const projectsInProgram = (program: ProjectProgram) =>
projects.filter((project) => project.program === program);
export const inProgram = (items: Project[], program: ProjectProgram) =>
items.filter((project) => project.program === program);
/** e.g. "2006 – 2025", or a single year when all projects share it. */
export const yearRange = (items: Project[]) => {
if (items.length === 0) {
return '–';
}
const years = items.map((project) => project.year);
const first = Math.min(...years);
const last = Math.max(...years);
return first === last ? `${first}` : `${first} – ${last}`;
};
/** All portfolio numbers and lists derived from one set of projects. */
export const portfolio = (projects: Project[]) => ({
projects,
projectCount: projects.length,
clientCount: rankClients(projects).length,
projectsBySector: (sector: ProjectSector) => bySector(projects, sector),
projectsInProgram: (program: ProjectProgram) =>
inProgram(projects, program),
clientsInSector: (sector: ProjectSector) =>
rankClients(bySector(projects, sector)),
topClients: (limit: number) => rankClients(projects).slice(0, limit),
});
export type Portfolio = ReturnType<typeof portfolio>;
/** Fills `{projects}` and `{clients}` placeholders in content strings. */
export const withCounts = (text: string, stats: Portfolio) =>
text
.replaceAll('{projects}', String(stats.projectCount))
.replaceAll('{clients}', String(stats.clientCount));
/** Applies `withCounts` to every string in a content object. */
export function fillCounts<T>(value: T, stats: Portfolio): T {
if (typeof value === 'string') {
return withCounts(value, stats) as T;
}
if (Array.isArray(value)) {
return value.map((item: unknown) => fillCounts(item, stats)) as T;
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
fillCounts(item, stats),
]),
) as T;
}
return value;
}
+12 -51
View File
@@ -1,8 +1,9 @@
import { about, clients, contact, services } from '@/routes';
import type { Office } from '@/types';
/**
* Language-independent company facts. Anything visitors read as prose lives
* in `resources/js/content` in both languages.
* Fixed company facts. Editable contact details and office locations come
* from the admin panel through the shared `site` prop (see `useSiteInfo`).
*/
export const company = {
name: 'PT Probindo Artika Jaya',
@@ -12,59 +13,19 @@ export const company = {
/** Year PAJ became an IT consultant; shown as "Est." on the site. */
established: 2002,
logo: '/images/probindo/logo-paj.png',
phone: { label: '(021) 7266-3353', href: 'tel:+622172663353' },
whatsapp: {
label: '0818-0218-9425',
href: 'https://wa.me/6281802189425',
},
email: {
support: 'support@probindo.com',
busdev: 'busdev@probindo.com',
},
} as const;
export type Office = {
name: string;
icon: string;
building: string;
street: string;
area: string;
city: string;
/** Compact location used in chips and one-line summaries. */
district: string;
phone: { label: string; href: string };
mapsUrl: string;
/** Indonesian phone number → international digits, e.g. "622172663353". */
const internationalDigits = (phone: string) => {
const digits = phone.replace(/\D/g, '');
return digits.startsWith('0') ? `62${digits.slice(1)}` : digits;
};
/**
* PAJ works from two locations; most of the team is based at the Software
* Engineering Center, so it is listed first.
*/
export const offices: Office[] = [
{
name: 'PAJ Software Engineering Center',
icon: 'code_blocks',
building: 'Anindhaloka',
street: 'Jl. YRS No.20, RW.1',
area: 'Bintaro, Kec. Pesanggrahan',
city: 'Kota Jakarta Selatan 12330',
district: 'Bintaro, Jakarta Selatan',
phone: { label: '(021) 7266-3353', href: 'tel:+622172663353' },
mapsUrl:
'https://maps.google.com/?q=Jl.+YRS+No.20+Bintaro+Pesanggrahan+Jakarta+Selatan',
},
{
name: 'PAJ Business Development Center',
icon: 'apartment',
building: 'Plaza Simatupang Lt. 6 Unit 3',
street: 'Jl. TB. Simatupang Kav. IS No. 01',
area: 'Pondok Pinang, Kebayoran Lama',
city: 'Jakarta Selatan 12310',
district: 'Plaza Simatupang, Jakarta Selatan',
phone: { label: '+62 21 5789 5927', href: 'tel:+622157895927' },
mapsUrl: 'https://maps.google.com/?q=Plaza+Simatupang+Jakarta',
},
];
export const telHref = (phone: string) => `tel:+${internationalDigits(phone)}`;
export const whatsappHref = (phone: string) =>
`https://wa.me/${internationalDigits(phone)}`;
export const officeAddress = (office: Office) =>
[office.building, office.street, office.area, office.city].join(', ');
+10
View File
@@ -0,0 +1,10 @@
import { usePage } from '@inertiajs/react';
import { portfolio } from '@/data/projects';
import type { Project } from '@/types';
/** Portfolio helpers for pages that receive a `projects` prop. */
export function usePortfolio() {
const { projects } = usePage<{ projects: Project[] }>().props;
return portfolio(projects ?? []);
}
+26
View File
@@ -0,0 +1,26 @@
import { usePage } from '@inertiajs/react';
import { telHref, whatsappHref } from '@/data/site';
/**
* Editable company contact details and office locations (admin panel →
* Info Perusahaan), with ready-to-use link targets.
*/
export function useSiteInfo() {
const { contact, offices } = usePage().props.site;
return {
phone: { label: contact.phone, href: telHref(contact.phone) },
whatsapp: {
label: contact.whatsapp,
href: whatsappHref(contact.whatsapp),
},
email: {
busdev: contact.email_busdev,
support: contact.email_support,
},
offices: offices.map((office) => ({
...office,
phoneHref: telHref(office.phone),
})),
};
}
+57
View File
@@ -0,0 +1,57 @@
import type {
ConsultationStatus,
ProjectProgram,
ProjectSector,
UserRole,
} from '@/types';
export const sectorLabels: Record<ProjectSector, string> = {
government: 'Pemerintahan',
private: 'Swasta',
soe: 'BUMN',
};
export const programLabels: Record<ProjectProgram, string> = {
'price-monitoring': 'Pemantauan Harga',
investment: 'Investasi',
erp: 'ERP',
};
export const statusLabels: Record<ConsultationStatus, string> = {
new: 'Baru',
contacted: 'Dihubungi',
closed: 'Selesai',
};
export const statusBadgeClasses: Record<ConsultationStatus, string> = {
new: 'bg-primary text-primary-foreground border-transparent',
contacted:
'border-transparent bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-300',
closed: 'border-transparent bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-300',
};
export const sourceLabels = {
home: 'Beranda',
services: 'Jasa',
contact: 'Kontak',
} as const;
export const roleLabels: Record<UserRole, string> = {
admin: 'Admin',
editor: 'Editor',
};
/** `{ value, label }` pairs for a select, in declaration order. */
export function optionsOf<T extends string>(labels: Record<T, string>) {
return (Object.keys(labels) as T[]).map((value) => ({
value,
label: labels[value],
}));
}
export function formatDate(value: string, withTime = false): string {
return new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium',
...(withTime ? { timeStyle: 'short' } : {}),
}).format(new Date(value));
}
+267
View File
@@ -0,0 +1,267 @@
import { Form, Head } from '@inertiajs/react';
import { ExternalLink, MapPin, Pencil, Plus, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import {
ConfirmDelete,
FormDialog,
FormField,
PageHeader,
} from '@/components/admin/form-controls';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { officeAddress } from '@/data/site';
import { edit } from '@/routes/admin/company';
import { update as updateContact } from '@/routes/admin/company/contact';
import { destroy, store, update } from '@/routes/admin/offices';
import type { Office, SiteInfo } from '@/types';
export default function CompanyEdit({ contact, offices }: SiteInfo) {
return (
<>
<Head title="Info Perusahaan" />
<div className="flex flex-col gap-8 p-4 md:p-6">
<PageHeader
title="Info Perusahaan"
description="Kontak dan alamat kantor yang tampil di header, footer, dan halaman Kontak."
/>
<section className="bg-card flex flex-col gap-6 rounded-xl border p-5">
<Heading
variant="small"
title="Kontak"
description="Email busdev juga menjadi tujuan notifikasi konsultasi baru."
/>
<Form
{...updateContact.form()}
options={{ preserveScroll: true }}
className="grid gap-4 sm:grid-cols-2"
>
{({ processing, errors }) => (
<>
<FormField
id="phone"
label="Telepon kantor"
error={errors.phone}
>
<Input
id="phone"
name="phone"
defaultValue={contact.phone}
required
/>
</FormField>
<FormField
id="whatsapp"
label="WhatsApp konsultasi"
error={errors.whatsapp}
>
<Input
id="whatsapp"
name="whatsapp"
defaultValue={contact.whatsapp}
required
/>
</FormField>
<FormField
id="email_busdev"
label="Email business development"
error={errors.email_busdev}
>
<Input
id="email_busdev"
name="email_busdev"
type="email"
defaultValue={contact.email_busdev}
required
/>
</FormField>
<FormField
id="email_support"
label="Email support"
error={errors.email_support}
>
<Input
id="email_support"
name="email_support"
type="email"
defaultValue={contact.email_support}
required
/>
</FormField>
<div className="flex justify-end sm:col-span-2">
<Button type="submit" disabled={processing}>
Simpan Kontak
</Button>
</div>
</>
)}
</Form>
</section>
<section className="flex flex-col gap-4">
<div className="flex items-end justify-between gap-3">
<Heading
variant="small"
title="Kantor"
description="Diurutkan berdasarkan nomor urut."
/>
<OfficeDialog
nextOrder={offices.length + 1}
trigger={
<Button>
<Plus /> Tambah Kantor
</Button>
}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
{offices.map((office) => (
<div
key={office.id}
className="bg-card flex flex-col gap-3 rounded-xl border p-5"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col">
<span className="font-display font-bold">
{office.name}
</span>
<span className="text-muted-foreground text-xs">
{office.district} · {office.phone}
</span>
</div>
<div className="flex gap-1">
<OfficeDialog
office={office}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Ubah"
>
<Pencil />
</Button>
}
/>
<ConfirmDelete
title="Hapus kantor ini?"
description={`${office.name} tidak akan tampil lagi di website.`}
form={destroy.form(office.id)}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Hapus"
className="text-destructive"
>
<Trash2 />
</Button>
}
/>
</div>
</div>
<p className="flex gap-2 text-sm">
<MapPin className="text-primary mt-0.5 size-4 shrink-0" />
{officeAddress(office)}
</p>
{office.maps_url && (
<a
href={office.maps_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary flex items-center gap-1 text-xs font-semibold hover:underline"
>
Buka di Google Maps
<ExternalLink className="size-3.5" />
</a>
)}
</div>
))}
</div>
</section>
</div>
</>
);
}
function OfficeDialog({
office,
nextOrder = 1,
trigger,
}: {
office?: Office;
nextOrder?: number;
trigger: ReactNode;
}) {
const id = (field: string) => `office-${office?.id ?? 'new'}-${field}`;
const text = (
field: keyof Office,
label: string,
errors: Partial<Record<string, string>>,
extra: { placeholder?: string; required?: boolean } = {
required: true,
},
) => (
<FormField id={id(field)} label={label} error={errors[field]}>
<Input
id={id(field)}
name={field}
defaultValue={office?.[field] ?? ''}
{...extra}
/>
</FormField>
);
return (
<FormDialog
trigger={trigger}
title={office ? 'Ubah Kantor' : 'Tambah Kantor'}
form={office ? update.form(office.id) : store.form()}
>
{(errors) => (
<>
{text('name', 'Nama kantor', errors)}
{text('building', 'Gedung / nomor', errors)}
{text('street', 'Jalan', errors)}
<div className="grid gap-4 sm:grid-cols-2">
{text('area', 'Kelurahan / kecamatan', errors)}
{text('city', 'Kota & kode pos', errors)}
{text('district', 'Label wilayah', errors, {
placeholder: 'Mis. Bintaro',
required: true,
})}
{text('phone', 'Telepon', errors)}
</div>
{text('maps_url', 'Link Google Maps (opsional)', errors, {
placeholder: 'https://maps.app.goo.gl/…',
})}
<div className="grid gap-4 sm:grid-cols-2">
{text('icon', 'Ikon (Material Symbols)', errors, {
placeholder: 'apartment',
required: true,
})}
<FormField
id={id('sort_order')}
label="Nomor urut"
error={errors.sort_order}
>
<Input
id={id('sort_order')}
name="sort_order"
type="number"
min={0}
defaultValue={office?.sort_order ?? nextOrder}
required
/>
</FormField>
</div>
</>
)}
</FormDialog>
);
}
CompanyEdit.layout = {
breadcrumbs: [{ title: 'Info Perusahaan', href: edit() }],
};
@@ -0,0 +1,327 @@
import { Form, Head, Link } from '@inertiajs/react';
import { Mail, MessageCircle, Phone, Trash2 } from 'lucide-react';
import { useState } from 'react';
import {
ConfirmDelete,
FormField,
NativeSelect,
PageHeader,
Textarea,
} from '@/components/admin/form-controls';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { telHref, whatsappHref } from '@/data/site';
import {
formatDate,
optionsOf,
sourceLabels,
statusBadgeClasses,
statusLabels,
} from '@/lib/admin-labels';
import { cn } from '@/lib/utils';
import { destroy, index, update } from '@/routes/admin/consultations';
import type { Consultation, ConsultationStatus, Paginated } from '@/types';
type Props = {
consultations: Paginated<Consultation>;
status: ConsultationStatus | null;
counts: Partial<Record<ConsultationStatus, number>>;
};
const timelineLabels: Record<string, string> = {
immediate: 'Segera (< 1 bulan)',
quarter: '1–3 bulan',
midterm: '3–6 bulan',
feasibility: 'Masih studi kelayakan',
};
export default function ConsultationsIndex({
consultations,
status,
counts,
}: Props) {
const [selectedId, setSelectedId] = useState<number | null>(
consultations.data[0]?.id ?? null,
);
const selected =
consultations.data.find((item) => item.id === selectedId) ??
consultations.data[0];
const total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
const filters: {
value: ConsultationStatus | null;
label: string;
count: number;
}[] = [
{ value: null, label: 'Semua', count: total },
...optionsOf(statusLabels).map(({ value, label }) => ({
value,
label,
count: counts[value] ?? 0,
})),
];
return (
<>
<Head title="Inbox Konsultasi" />
<div className="flex flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Inbox Konsultasi"
description="Permintaan dari form konsultasi di website. Notifikasi juga dikirim ke email busdev."
/>
<div className="flex flex-wrap gap-2">
{filters.map((filter) => (
<Link
key={filter.label}
href={index({
query: filter.value
? { status: filter.value }
: {},
})}
preserveScroll
className={cn(
'flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors',
status === filter.value
? 'bg-primary text-primary-foreground border-transparent'
: 'hover:bg-accent',
)}
>
{filter.label}
<span className="font-mono text-xs opacity-75">
{filter.count}
</span>
</Link>
))}
</div>
{consultations.data.length === 0 ? (
<div className="text-muted-foreground rounded-xl border border-dashed p-12 text-center text-sm">
Belum ada permintaan konsultasi di sini.
</div>
) : (
<div className="grid gap-4 lg:grid-cols-5">
<div className="bg-card flex flex-col rounded-xl border lg:col-span-2">
<ul className="divide-border divide-y">
{consultations.data.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() =>
setSelectedId(item.id)
}
className={cn(
'flex w-full flex-col gap-1 px-4 py-3 text-left transition-colors',
selected?.id === item.id
? 'bg-accent'
: 'hover:bg-accent/50',
)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold">
{item.name}
</span>
<Badge
className={
statusBadgeClasses[
item.status
]
}
>
{statusLabels[item.status]}
</Badge>
</div>
<span className="text-muted-foreground truncate text-xs">
{item.company ?? item.email}
</span>
<span className="text-muted-foreground text-xs">
{formatDate(
item.created_at,
true,
)}
{' · '}
{sourceLabels[item.source]}
</span>
</button>
</li>
))}
</ul>
{consultations.last_page > 1 && (
<div className="mt-auto flex items-center justify-between border-t px-4 py-3 text-sm">
<PageLink
href={consultations.prev_page_url}
label="Sebelumnya"
/>
<span className="text-muted-foreground text-xs">
Halaman {consultations.current_page} /{' '}
{consultations.last_page}
</span>
<PageLink
href={consultations.next_page_url}
label="Berikutnya"
/>
</div>
)}
</div>
{selected && (
<ConsultationDetail
key={selected.id}
consultation={selected}
/>
)}
</div>
)}
</div>
</>
);
}
function PageLink({ href, label }: { href: string | null; label: string }) {
if (!href) {
return <span className="text-muted-foreground/50">{label}</span>;
}
return (
<Link href={href} preserveScroll className="text-primary font-medium">
{label}
</Link>
);
}
function ConsultationDetail({ consultation }: { consultation: Consultation }) {
const details: [string, string | null][] = [
['Perusahaan', consultation.company],
['Jabatan', consultation.job_title],
['Industri', consultation.industry],
['Layanan', consultation.service],
[
'Target waktu',
consultation.timeline
? (timelineLabels[consultation.timeline] ??
consultation.timeline)
: null,
],
['Halaman asal', sourceLabels[consultation.source]],
['Bahasa', consultation.locale === 'en' ? 'English' : 'Indonesia'],
['Setuju NDA', consultation.nda_agreed ? 'Ya' : 'Tidak'],
];
return (
<div className="bg-card flex flex-col gap-6 rounded-xl border p-5 lg:col-span-3">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
Masuk {formatDate(consultation.created_at, true)}
</span>
<ConfirmDelete
title="Hapus permintaan ini?"
description={`Data konsultasi dari ${consultation.name} akan dihapus permanen.`}
form={destroy.form(consultation.id)}
trigger={
<Button
variant="ghost"
size="sm"
className="text-destructive"
>
<Trash2 /> Hapus
</Button>
}
/>
</div>
<h2 className="font-display text-lg font-bold">
{consultation.name}
</h2>
<div className="mt-2 flex flex-wrap gap-2">
<Button asChild size="sm" variant="outline">
<a href={`mailto:${consultation.email}`}>
<Mail /> {consultation.email}
</a>
</Button>
<Button asChild size="sm" variant="outline">
<a href={telHref(consultation.phone)}>
<Phone /> {consultation.phone}
</a>
</Button>
<Button asChild size="sm" variant="outline">
<a
href={whatsappHref(consultation.phone)}
target="_blank"
rel="noopener noreferrer"
>
<MessageCircle /> WhatsApp
</a>
</Button>
</div>
</div>
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
{details
.filter(([, value]) => value)
.map(([label, value]) => (
<div key={label} className="flex flex-col">
<dt className="text-muted-foreground text-xs">
{label}
</dt>
<dd className="text-sm font-medium">{value}</dd>
</div>
))}
</dl>
{consultation.details && (
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">
Kebutuhan
</span>
<p className="bg-muted/50 rounded-lg p-3 text-sm whitespace-pre-line">
{consultation.details}
</p>
</div>
)}
<Form
{...update.form(consultation.id)}
options={{ preserveScroll: true }}
className="flex flex-col gap-4 border-t pt-5"
>
{({ processing, errors }) => (
<>
<FormField
id="status"
label="Status tindak lanjut"
error={errors.status}
>
<NativeSelect
id="status"
name="status"
defaultValue={consultation.status}
options={optionsOf(statusLabels)}
/>
</FormField>
<FormField
id="admin_notes"
label="Catatan internal"
error={errors.admin_notes}
>
<Textarea
id="admin_notes"
name="admin_notes"
defaultValue={consultation.admin_notes ?? ''}
placeholder="Mis. sudah ditelepon, jadwal meeting 3 Okt."
/>
</FormField>
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
);
}
ConsultationsIndex.layout = {
breadcrumbs: [{ title: 'Inbox Konsultasi', href: index() }],
};
+290
View File
@@ -0,0 +1,290 @@
import { Head } from '@inertiajs/react';
import { Pencil, Plus, Search, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import {
ConfirmDelete,
FormDialog,
FormField,
NativeSelect,
PageHeader,
Textarea,
} from '@/components/admin/form-controls';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { portfolio } from '@/data/projects';
import { optionsOf, programLabels, sectorLabels } from '@/lib/admin-labels';
import { destroy, index, store, update } from '@/routes/admin/projects';
import type { Project, ProjectSector } from '@/types';
export default function ProjectsIndex({ projects }: { projects: Project[] }) {
const [query, setQuery] = useState('');
const [sector, setSector] = useState<ProjectSector | ''>('');
const stats = portfolio(projects);
const visible = useMemo(() => {
const needle = query.trim().toLowerCase();
return projects.filter(
(project) =>
(!sector || project.sector === sector) &&
(!needle ||
`${project.title} ${project.client} ${project.year}`
.toLowerCase()
.includes(needle)),
);
}, [projects, query, sector]);
return (
<>
<Head title="Portofolio" />
<div className="flex flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Portofolio Proyek"
description={`${stats.projectCount} proyek dari ${stats.clientCount} klien. Angka di website dihitung otomatis dari daftar ini.`}
action={
<ProjectDialog
trigger={
<Button>
<Plus /> Tambah Proyek
</Button>
}
/>
}
/>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Cari judul, klien, atau tahun…"
className="pl-9"
/>
</div>
<NativeSelect
value={sector}
onChange={(event) =>
setSector(event.target.value as ProjectSector | '')
}
options={[
{ value: '', label: 'Semua sektor' },
...optionsOf(sectorLabels),
]}
className="sm:w-48"
/>
</div>
<div className="bg-card overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="text-muted-foreground border-b text-left text-xs">
<tr>
<th className="px-4 py-3 font-medium">Tahun</th>
<th className="px-4 py-3 font-medium">
Proyek
</th>
<th className="px-4 py-3 font-medium">
Sektor
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-border divide-y">
{visible.map((project) => (
<tr key={project.id} className="align-top">
<td className="px-4 py-3 font-mono text-xs font-bold">
{project.year}
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<span className="font-semibold">
{project.title}
</span>
<span className="text-muted-foreground text-xs">
{project.client}
</span>
{project.scope && (
<span className="text-muted-foreground text-xs italic">
{project.scope}
</span>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
<Badge variant="outline">
{sectorLabels[project.sector]}
</Badge>
{project.program && (
<Badge variant="secondary">
{
programLabels[
project.program
]
}
</Badge>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<ProjectDialog
project={project}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Ubah"
>
<Pencil />
</Button>
}
/>
<ConfirmDelete
title="Hapus proyek ini?"
description={`"${project.title}" akan dihapus dari portofolio website.`}
form={destroy.form(project.id)}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Hapus"
className="text-destructive"
>
<Trash2 />
</Button>
}
/>
</div>
</td>
</tr>
))}
{visible.length === 0 && (
<tr>
<td
colSpan={4}
className="text-muted-foreground px-4 py-12 text-center"
>
Tidak ada proyek yang cocok.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}
function ProjectDialog({
project,
trigger,
}: {
project?: Project;
trigger: ReactNode;
}) {
const id = (field: string) => `project-${project?.id ?? 'new'}-${field}`;
return (
<FormDialog
trigger={trigger}
title={project ? 'Ubah Proyek' : 'Tambah Proyek'}
form={project ? update.form(project.id) : store.form()}
>
{(errors) => (
<>
<FormField
id={id('title')}
label="Judul proyek"
error={errors.title}
>
<Textarea
id={id('title')}
name="title"
rows={2}
defaultValue={project?.title}
required
/>
</FormField>
<FormField
id={id('client')}
label="Klien"
error={errors.client}
>
<Input
id={id('client')}
name="client"
defaultValue={project?.client}
required
/>
</FormField>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
id={id('year')}
label="Tahun"
error={errors.year}
>
<Input
id={id('year')}
name="year"
type="number"
min={1985}
max={new Date().getFullYear() + 1}
defaultValue={
project?.year ?? new Date().getFullYear()
}
required
/>
</FormField>
<FormField
id={id('sector')}
label="Sektor"
error={errors.sector}
>
<NativeSelect
id={id('sector')}
name="sector"
defaultValue={project?.sector ?? 'government'}
options={optionsOf(sectorLabels)}
/>
</FormField>
</div>
<FormField
id={id('program')}
label="Program unggulan (opsional)"
error={errors.program}
>
<NativeSelect
id={id('program')}
name="program"
defaultValue={project?.program ?? ''}
options={[
{ value: '', label: '— Tidak ada —' },
...optionsOf(programLabels),
]}
/>
</FormField>
<FormField
id={id('scope')}
label="Cakupan (opsional)"
error={errors.scope}
>
<Input
id={id('scope')}
name="scope"
defaultValue={project?.scope ?? ''}
placeholder="Mis. Odoo Enterprise → POS, Penjualan → Custom Development"
/>
</FormField>
</>
)}
</FormDialog>
);
}
ProjectsIndex.layout = {
breadcrumbs: [{ title: 'Portofolio', href: index() }],
};
@@ -0,0 +1,244 @@
import { Head } from '@inertiajs/react';
import { Pencil, Plus, Quote, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import {
ConfirmDelete,
FormDialog,
FormField,
PageHeader,
Textarea,
} from '@/components/admin/form-controls';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { destroy, index, store, update } from '@/routes/admin/testimonials';
import type { Testimonial } from '@/types';
export default function TestimonialsIndex({
testimonials,
}: {
testimonials: Testimonial[];
}) {
return (
<>
<Head title="Testimoni" />
<div className="flex flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Testimoni"
description="Bagian testimoni di halaman Klien hanya muncul jika ada testimoni yang dipublikasikan. Gunakan hanya testimoni asli yang sudah disetujui klien."
action={
<TestimonialDialog
nextOrder={testimonials.length + 1}
trigger={
<Button>
<Plus /> Tambah Testimoni
</Button>
}
/>
}
/>
{testimonials.length === 0 ? (
<div className="text-muted-foreground rounded-xl border border-dashed p-12 text-center text-sm">
Belum ada testimoni. Bagian testimoni sedang
disembunyikan di website.
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{testimonials.map((testimonial) => (
<div
key={testimonial.id}
className="bg-card flex flex-col gap-3 rounded-xl border p-5"
>
<div className="flex items-start justify-between gap-2">
<Quote className="text-primary size-5" />
<div className="flex items-center gap-1">
<Badge
variant={
testimonial.is_published
? 'default'
: 'secondary'
}
>
{testimonial.is_published
? 'Tayang'
: 'Draf'}
</Badge>
<TestimonialDialog
testimonial={testimonial}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Ubah"
>
<Pencil />
</Button>
}
/>
<ConfirmDelete
title="Hapus testimoni ini?"
description={`Testimoni dari ${testimonial.organization_id} akan dihapus permanen.`}
form={destroy.form(testimonial.id)}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Hapus"
className="text-destructive"
>
<Trash2 />
</Button>
}
/>
</div>
</div>
<p className="text-sm italic">
“{testimonial.quote_id}”
</p>
<span className="text-muted-foreground text-xs">
{testimonial.role_id} ·{' '}
{testimonial.organization_id}
</span>
</div>
))}
</div>
)}
</div>
</>
);
}
function TestimonialDialog({
testimonial,
nextOrder = 1,
trigger,
}: {
testimonial?: Testimonial;
nextOrder?: number;
trigger: ReactNode;
}) {
const id = (field: string) =>
`testimonial-${testimonial?.id ?? 'new'}-${field}`;
return (
<FormDialog
trigger={trigger}
title={testimonial ? 'Ubah Testimoni' : 'Tambah Testimoni'}
description="Isi versi Bahasa Indonesia dan English; website menampilkan sesuai bahasa pengunjung."
form={testimonial ? update.form(testimonial.id) : store.form()}
className="sm:max-w-2xl"
>
{(errors) => (
<>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
id={id('quote_id')}
label="Kutipan (ID)"
error={errors.quote_id}
>
<Textarea
id={id('quote_id')}
name="quote_id"
defaultValue={testimonial?.quote_id}
required
/>
</FormField>
<FormField
id={id('quote_en')}
label="Quote (EN)"
error={errors.quote_en}
>
<Textarea
id={id('quote_en')}
name="quote_en"
defaultValue={testimonial?.quote_en}
required
/>
</FormField>
<FormField
id={id('role_id')}
label="Jabatan (ID)"
error={errors.role_id}
>
<Input
id={id('role_id')}
name="role_id"
defaultValue={testimonial?.role_id}
required
/>
</FormField>
<FormField
id={id('role_en')}
label="Role (EN)"
error={errors.role_en}
>
<Input
id={id('role_en')}
name="role_en"
defaultValue={testimonial?.role_en}
required
/>
</FormField>
<FormField
id={id('organization_id')}
label="Organisasi (ID)"
error={errors.organization_id}
>
<Input
id={id('organization_id')}
name="organization_id"
defaultValue={testimonial?.organization_id}
required
/>
</FormField>
<FormField
id={id('organization_en')}
label="Organization (EN)"
error={errors.organization_en}
>
<Input
id={id('organization_en')}
name="organization_en"
defaultValue={testimonial?.organization_en}
required
/>
</FormField>
<FormField
id={id('sort_order')}
label="Nomor urut"
error={errors.sort_order}
>
<Input
id={id('sort_order')}
name="sort_order"
type="number"
min={0}
defaultValue={
testimonial?.sort_order ?? nextOrder
}
required
/>
</FormField>
<label className="flex items-center gap-2 self-end pb-2 text-sm font-medium">
<input
type="checkbox"
name="is_published"
value="1"
defaultChecked={
testimonial?.is_published ?? true
}
className="accent-primary size-4"
/>
Tampilkan di website
</label>
</div>
</>
)}
</FormDialog>
);
}
TestimonialsIndex.layout = {
breadcrumbs: [{ title: 'Testimoni', href: index() }],
};
+266
View File
@@ -0,0 +1,266 @@
import { Head, usePage } from '@inertiajs/react';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import {
ConfirmDelete,
FormDialog,
FormField,
NativeSelect,
PageHeader,
} from '@/components/admin/form-controls';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { formatDate, optionsOf, roleLabels } from '@/lib/admin-labels';
import { destroy, index, store, update } from '@/routes/admin/users';
import type { UserRole } from '@/types';
type PanelUser = {
id: number;
name: string;
email: string;
role: UserRole;
created_at: string;
};
const roleDescriptions: Record<UserRole, string> = {
admin: 'Semua menu, termasuk kelola pengguna.',
editor: 'Konten website dan inbox konsultasi.',
};
export default function UsersIndex({ users }: { users: PanelUser[] }) {
const { auth } = usePage().props;
return (
<>
<Head title="Pengguna" />
<div className="flex flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Pengguna"
description="Akun yang dapat masuk ke admin panel. Registrasi publik dinonaktifkan."
action={
<UserDialog
trigger={
<Button>
<Plus /> Tambah Pengguna
</Button>
}
/>
}
/>
<div className="bg-card overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="text-muted-foreground border-b text-left text-xs">
<tr>
<th className="px-4 py-3 font-medium">Nama</th>
<th className="px-4 py-3 font-medium">Peran</th>
<th className="px-4 py-3 font-medium">
Dibuat
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-border divide-y">
{users.map((user) => {
const isSelf = user.id === auth.user?.id;
return (
<tr key={user.id}>
<td className="px-4 py-3">
<div className="flex flex-col">
<span className="font-semibold">
{user.name}
{isSelf && (
<span className="text-muted-foreground font-normal">
{' '}
(Anda)
</span>
)}
</span>
<span className="text-muted-foreground text-xs">
{user.email}
</span>
</div>
</td>
<td className="px-4 py-3">
<Badge
variant={
user.role === 'admin'
? 'default'
: 'secondary'
}
>
{roleLabels[user.role]}
</Badge>
</td>
<td className="text-muted-foreground px-4 py-3 text-xs">
{formatDate(user.created_at)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<UserDialog
user={user}
isSelf={isSelf}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Ubah"
>
<Pencil />
</Button>
}
/>
{!isSelf && (
<ConfirmDelete
title="Hapus pengguna ini?"
description={`${user.name} tidak akan bisa masuk ke admin panel lagi.`}
form={destroy.form(
user.id,
)}
trigger={
<Button
variant="ghost"
size="icon"
aria-label="Hapus"
className="text-destructive"
>
<Trash2 />
</Button>
}
/>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{optionsOf(roleLabels).map(({ value, label }) => (
<div
key={value}
className="text-muted-foreground rounded-lg border border-dashed p-4 text-sm"
>
<span className="text-foreground font-semibold">
{label}:
</span>{' '}
{roleDescriptions[value]}
</div>
))}
</div>
</div>
</>
);
}
function UserDialog({
user,
isSelf = false,
trigger,
}: {
user?: PanelUser;
isSelf?: boolean;
trigger: ReactNode;
}) {
const id = (field: string) => `user-${user?.id ?? 'new'}-${field}`;
return (
<FormDialog
trigger={trigger}
title={user ? 'Ubah Pengguna' : 'Tambah Pengguna'}
form={user ? update.form(user.id) : store.form()}
>
{(errors) => (
<>
<FormField id={id('name')} label="Nama" error={errors.name}>
<Input
id={id('name')}
name="name"
defaultValue={user?.name}
required
/>
</FormField>
<FormField
id={id('email')}
label="Email"
error={errors.email}
>
<Input
id={id('email')}
name="email"
type="email"
defaultValue={user?.email}
required
/>
</FormField>
<FormField
id={id('role')}
label="Peran"
error={errors.role}
>
<NativeSelect
id={id('role')}
name="role"
defaultValue={user?.role ?? 'editor'}
options={optionsOf(roleLabels)}
disabled={isSelf}
/>
{isSelf && (
<>
<input
type="hidden"
name="role"
value={user?.role}
/>
<p className="text-muted-foreground text-xs">
Anda tidak dapat mengubah peran akun
sendiri.
</p>
</>
)}
</FormField>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
id={id('password')}
label={user ? 'Password baru' : 'Password'}
error={errors.password}
>
<Input
id={id('password')}
name="password"
type="password"
autoComplete="new-password"
placeholder={
user ? 'Kosongkan jika tidak diubah' : ''
}
required={!user}
/>
</FormField>
<FormField
id={id('password_confirmation')}
label="Ulangi password"
error={errors.password_confirmation}
>
<Input
id={id('password_confirmation')}
name="password_confirmation"
type="password"
autoComplete="new-password"
required={!user}
/>
</FormField>
</div>
</>
)}
</FormDialog>
);
}
UsersIndex.layout = {
breadcrumbs: [{ title: 'Pengguna', href: index() }],
};
+147 -95
View File
@@ -1,117 +1,169 @@
import { Form, Head } from '@inertiajs/react';
import { Form, Head, Link } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { register } from '@/routes';
import { Button } from '@/components/site/button';
import Card from '@/components/site/card';
import { TextInput } from '@/components/site/form-field';
import Glow, { GridPattern } from '@/components/site/glow';
import Icon from '@/components/site/icon';
import LanguageToggle from '@/components/site/language-toggle';
import ThemeToggle from '@/components/site/theme-toggle';
import { common } from '@/content/common';
import { company } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { home } from '@/routes';
import { store } from '@/routes/login';
import { request } from '@/routes/password';
import PasskeyVerify from '@/components/passkey-verify';
type Props = {
status?: string;
canResetPassword: boolean;
};
/**
* Admin panel sign-in. Public registration is disabled; admin accounts are
* created with `php artisan admin:create`.
*/
export default function Login({ status, canResetPassword }: Props) {
const t = useContent(common).login;
return (
<>
<Head title="Log in" />
<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]" />
<PasskeyVerify />
<div className="absolute top-5 right-5 flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
</div>
<Form
{...store.form()}
resetOnSuccess={['password']}
className="flex flex-col gap-6"
>
{({ processing, errors }) => (
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
required
autoFocus
tabIndex={1}
autoComplete="email"
placeholder="email@example.com"
/>
<InputError message={errors.email} />
</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>
<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}
>
Forgot your password?
</TextLink>
)}
<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-5"
>
{({ processing, errors }) => (
<>
<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
autoComplete="email"
placeholder={t.emailPlaceholder}
/>
<InputError message={errors.email} />
</div>
<PasswordInput
id="password"
name="password"
required
tabIndex={2}
autoComplete="current-password"
placeholder="Password"
/>
<InputError message={errors.password} />
</div>
<div className="flex items-center space-x-3">
<Checkbox
id="remember"
name="remember"
tabIndex={3}
/>
<Label htmlFor="remember">Remember me</Label>
</div>
<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"
>
{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
autoComplete="current-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>
<Button
type="submit"
className="mt-4 w-full"
tabIndex={4}
disabled={processing}
data-test="login-button"
>
{processing && <Spinner />}
Log in
</Button>
</div>
<label className="text-on-surface-variant flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
name="remember"
className="h-4 w-4"
/>
{t.remember}
</label>
<div className="text-muted-foreground text-center text-sm">
Don't have an account?{' '}
<TextLink href={register()} tabIndex={5}>
Sign up
</TextLink>
</div>
</>
)}
</Form>
<Button
type="submit"
size="block"
icon={
processing
? 'progress_activity'
: 'login'
}
disabled={processing}
data-test="login-button"
>
{t.submit}
</Button>
</>
)}
</Form>
</Card>
{status && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
{status}
</div>
)}
</>
<Link
href={home()}
className="text-on-surface-variant hover:text-primary flex items-center gap-1.5 self-center text-sm"
>
<Icon name="arrow_back" className="text-base" />
{t.back}
</Link>
</div>
</div>
);
}
Login.layout = {
title: 'Log in to your account',
description: 'Enter your email and password below to log in',
};
-120
View File
@@ -1,120 +0,0 @@
import { Form, Head } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { login } from '@/routes';
import { store } from '@/routes/register';
type Props = {
passwordRules: string;
};
export default function Register({ passwordRules }: Props) {
return (
<>
<Head title="Register" />
<Form
{...store.form()}
resetOnSuccess={['password', 'password_confirmation']}
disableWhileProcessing
className="flex flex-col gap-6"
>
{({ processing, errors }) => (
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
required
autoFocus
tabIndex={1}
autoComplete="name"
name="name"
placeholder="Full name"
/>
<InputError
message={errors.name}
className="mt-2"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
required
tabIndex={2}
autoComplete="email"
name="email"
placeholder="email@example.com"
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
required
tabIndex={3}
autoComplete="new-password"
name="password"
placeholder="Password"
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
</Label>
<PasswordInput
id="password_confirmation"
required
tabIndex={4}
autoComplete="new-password"
name="password_confirmation"
placeholder="Confirm password"
passwordrules={passwordRules}
/>
<InputError
message={errors.password_confirmation}
/>
</div>
<Button
type="submit"
className="mt-2 w-full"
tabIndex={5}
data-test="register-user-button"
>
{processing && <Spinner />}
Create account
</Button>
</div>
<div className="text-muted-foreground text-center text-sm">
Already have an account?{' '}
<TextLink href={login()} tabIndex={6}>
Log in
</TextLink>
</div>
</>
)}
</Form>
</>
);
}
Register.layout = {
title: 'Create an account',
description: 'Enter your details below to create your account',
};
+285 -16
View File
@@ -1,25 +1,294 @@
import { Head } from '@inertiajs/react';
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
import { dashboard } from '@/routes';
import { Head, Link, usePage } from '@inertiajs/react';
import {
ArrowUpRight,
Building2,
FolderKanban,
Inbox,
Layers,
Users,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { portfolio, yearRange } from '@/data/projects';
import {
formatDate,
sectorLabels,
statusBadgeClasses,
statusLabels,
} from '@/lib/admin-labels';
import { about, clients, contact, dashboard, home, services } from '@/routes';
import { index as consultationsIndex } from '@/routes/admin/consultations';
import { index as projectsIndex } from '@/routes/admin/projects';
import type { Consultation, Project, ProjectSector } from '@/types';
type DashboardProps = {
projects: Project[];
newConsultations: number;
latestConsultations: Pick<
Consultation,
'id' | 'name' | 'company' | 'service' | 'status' | 'created_at'
>[];
};
const sitePages = [
{ title: 'Beranda', href: home() },
{ title: 'Tentang Kami', href: about() },
{ title: 'Jasa', href: services() },
{ title: 'Klien & Portofolio', href: clients() },
{ title: 'Kontak', href: contact() },
];
function StatCard({
icon: Icon,
label,
value,
note,
}: {
icon: LucideIcon;
label: string;
value: string | number;
note: string;
}) {
return (
<div className="bg-card border-sidebar-border/70 flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-mono text-xs font-semibold tracking-wider uppercase">
{label}
</span>
<span className="bg-accent text-accent-foreground flex size-9 items-center justify-center rounded-lg">
<Icon className="size-4" />
</span>
</div>
<span className="font-display text-3xl font-extrabold">
{value}
</span>
<span className="text-muted-foreground text-xs">{note}</span>
</div>
);
}
export default function Dashboard({
projects,
newConsultations,
latestConsultations,
}: DashboardProps) {
const { auth } = usePage().props;
const {
clientCount,
clientsInSector,
projectsBySector,
projectsInProgram,
} = portfolio(projects);
const sectors: ProjectSector[] = ['government', 'private', 'soe'];
const erpProjects = projectsInProgram('erp');
const latest = projects.slice(0, 5);
export default function Dashboard() {
return (
<>
<Head title="Dashboard" />
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<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="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>
<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 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>
</div>
</>
+10 -5
View File
@@ -14,13 +14,15 @@ import SectionHeader from '@/components/site/section-header';
import CtaBanner from '@/components/site/sections/cta-banner';
import StatCard from '@/components/site/stat-card';
import { aboutContent } from '@/content/about';
import { clientsInSector, projectsBySector } from '@/data/projects';
import { fillCounts } from '@/data/projects';
import { consultationUrl } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { usePortfolio } from '@/hooks/use-portfolio';
import { clients, services } from '@/routes';
export default function About() {
const t = useContent(aboutContent);
const stats = usePortfolio();
const t = fillCounts(useContent(aboutContent), stats);
return (
<>
@@ -178,7 +180,9 @@ export default function About() {
/>
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
{t.industries.items.map((item) => {
const clientNames = clientsInSector(item.sector);
const clientNames = stats.clientsInSector(
item.sector,
);
return (
<Card
@@ -196,8 +200,9 @@ export default function About() {
{clientNames.length}{' '}
{t.industries.clients} ·{' '}
{
projectsBySector(item.sector)
.length
stats.projectsBySector(
item.sector,
).length
}{' '}
{t.industries.projects}
</span>
+40 -27
View File
@@ -1,4 +1,4 @@
import { Head } from '@inertiajs/react';
import { Head, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { Button, ButtonLink } from '@/components/site/button';
import Card from '@/components/site/card';
@@ -13,21 +13,22 @@ import Section from '@/components/site/section';
import SectionHeader from '@/components/site/section-header';
import CtaBanner from '@/components/site/sections/cta-banner';
import { clientsContent } from '@/content/clients';
import type { ProjectSector } from '@/data/projects';
import {
projectsBySector,
projectsInProgram,
yearRange,
} from '@/data/projects';
import { company, consultationUrl, officeAddress, offices } from '@/data/site';
import type { Portfolio } from '@/data/projects';
import { fillCounts, yearRange } from '@/data/projects';
import { company, consultationUrl, officeAddress } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { usePortfolio } from '@/hooks/use-portfolio';
import { useSiteInfo } from '@/hooks/use-site-info';
import { cn } from '@/lib/utils';
import { contact } from '@/routes';
import type { LocalizedTestimonial, ProjectSector } from '@/types';
type Content = typeof clientsContent.id;
export default function Clients() {
const t = useContent(clientsContent);
const stats = usePortfolio();
const t = fillCounts(useContent(clientsContent), stats);
const site = useSiteInfo();
return (
<>
@@ -94,9 +95,9 @@ export default function Clients() {
</div>
</Section>
<SectorPortfolio t={t} />
<SectorPortfolio t={t} stats={stats} />
<FeaturedPrograms t={t} />
<FeaturedPrograms t={t} stats={stats} />
<Section tone="muted">
<div className="flex flex-col gap-12">
@@ -194,28 +195,28 @@ export default function Clients() {
{t.office.description}
</p>
<div className="text-on-surface mt-2 flex flex-col gap-2 font-mono text-sm">
{offices.map((office) => (
{site.offices.map((office) => (
<a
key={office.name}
href={office.phone.href}
href={office.phoneHref}
className="hover:text-primary flex items-center gap-2"
>
<Icon
name="call"
className="text-primary text-base"
/>
{office.phone.label}
{office.phone}
</a>
))}
<a
href={`mailto:${company.email.busdev}`}
href={`mailto:${site.email.busdev}`}
className="hover:text-primary flex items-center gap-2"
>
<Icon
name="mail"
className="text-primary text-base"
/>
{company.email.busdev}
{site.email.busdev}
</a>
</div>
</div>
@@ -227,7 +228,7 @@ export default function Clients() {
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="relative z-10 grid w-full grid-cols-1 gap-3 sm:grid-cols-2">
{offices.map((office) => (
{site.offices.map((office) => (
<div
key={office.name}
className="bg-surface-container-lowest/90 rounded-xl p-4 shadow-lg backdrop-blur-md"
@@ -250,11 +251,11 @@ export default function Clients() {
const PREVIEW_COUNT = 8;
function SectorPortfolio({ t }: { t: Content }) {
function SectorPortfolio({ t, stats }: { t: Content; stats: Portfolio }) {
const sectors: ProjectSector[] = ['government', 'private', 'soe'];
const [sector, setSector] = useState<ProjectSector>('government');
const [expanded, setExpanded] = useState(false);
const items = projectsBySector(sector);
const items = stats.projectsBySector(sector);
const visible = expanded ? items : items.slice(0, PREVIEW_COUNT);
const selectSector = (next: ProjectSector) => {
@@ -289,7 +290,7 @@ function SectorPortfolio({ t }: { t: Content }) {
)}
>
{t.portfolio.sectors[option]} (
{projectsBySector(option).length})
{stats.projectsBySector(option).length})
</button>
))}
</div>
@@ -360,7 +361,7 @@ function SectorPortfolio({ t }: { t: Content }) {
const RELATED_PREVIEW = 4;
function FeaturedPrograms({ t }: { t: Content }) {
function FeaturedPrograms({ t, stats }: { t: Content; stats: Portfolio }) {
return (
<Section>
<div className="flex flex-col gap-12">
@@ -371,7 +372,7 @@ function FeaturedPrograms({ t }: { t: Content }) {
/>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{t.cases.items.map((item, index) => {
const related = projectsInProgram(item.program);
const related = stats.projectsInProgram(item.program);
const remaining = related.length - RELATED_PREVIEW;
return (
@@ -456,9 +457,13 @@ function FeaturedPrograms({ t }: { t: Content }) {
);
}
/** Hidden until real client testimonials are added to the content file. */
/** Shown only once published testimonials exist (admin → Testimoni). */
function Testimonials({ t }: { t: Content }) {
if (t.testimonials.items.length === 0) {
const { testimonials } = usePage<{
testimonials: LocalizedTestimonial[];
}>().props;
if (testimonials.length === 0) {
return null;
}
@@ -472,9 +477,9 @@ function Testimonials({ t }: { t: Content }) {
description={t.testimonials.description}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{t.testimonials.items.map((testimonial) => (
{testimonials.map((testimonial) => (
<Card
key={testimonial.initials}
key={testimonial.id}
className="flex flex-col justify-between gap-6"
>
<div className="space-y-4">
@@ -492,7 +497,7 @@ function Testimonials({ t }: { t: Content }) {
</div>
<div className="flex items-center gap-3">
<div className="bg-surface-container text-primary flex h-10 w-10 items-center justify-center rounded-full font-mono font-bold">
{testimonial.initials}
{initials(testimonial.role)}
</div>
<div>
<div className="text-on-surface text-sm font-bold">
@@ -511,6 +516,14 @@ function Testimonials({ t }: { t: Content }) {
);
}
const initials = (text: string) =>
text
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join('');
function RetentionGauge({ t }: { t: Content }) {
return (
<div className="bg-surface-container flex w-full flex-col items-center justify-center rounded-xl p-6 lg:w-96">
+237 -182
View File
@@ -1,11 +1,11 @@
import { Head } from '@inertiajs/react';
import { Form, Head } from '@inertiajs/react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Button, ButtonLink } from '@/components/site/button';
import Card from '@/components/site/card';
import Eyebrow from '@/components/site/eyebrow';
import {
Field,
Honeypot,
SelectInput,
TextArea,
TextInput,
@@ -19,13 +19,16 @@ import SectionHeader from '@/components/site/section-header';
import StepCard from '@/components/site/step-card';
import { common, serviceOptions } from '@/content/common';
import { contactContent } from '@/content/contact';
import { company, officeAddress, offices } from '@/data/site';
import { officeAddress } from '@/data/site';
import { useContent } from '@/hooks/use-locale';
import { useSiteInfo } from '@/hooks/use-site-info';
import { cn } from '@/lib/utils';
import { store } from '@/routes/consultations';
type Content = typeof contactContent.id;
export default function Contact() {
const site = useSiteInfo();
const t = useContent(contactContent);
return (
@@ -186,19 +189,19 @@ export default function Contact() {
</p>
<div className="flex flex-wrap items-center justify-center gap-4 pt-2">
<ButtonLink
href={company.whatsapp.href}
href={site.whatsapp.href}
size="sm"
icon="chat"
>
{t.faq.whatsapp} ({company.whatsapp.label})
{t.faq.whatsapp} ({site.whatsapp.label})
</ButtonLink>
<ButtonLink
href={company.phone.href}
href={site.phone.href}
variant="secondary"
size="sm"
icon="call"
>
{t.faq.phone} ({company.phone.label})
{t.faq.phone} ({site.phone.label})
</ButtonLink>
</div>
</div>
@@ -217,6 +220,7 @@ function CardLabel({ children }: { children: string }) {
}
function OfficeCard({ t }: { t: Content }) {
const site = useSiteInfo();
return (
<Card padding="lg">
<div className="pb-4">
@@ -226,7 +230,7 @@ function OfficeCard({ t }: { t: Content }) {
</h3>
</div>
<div className="flex flex-col gap-3">
{offices.map((office) => (
{site.offices.map((office) => (
<div
key={office.name}
className="bg-surface-container flex items-start gap-3 rounded-lg p-3.5"
@@ -240,10 +244,10 @@ function OfficeCard({ t }: { t: Content }) {
{officeAddress(office)}
</span>
<a
href={office.phone.href}
href={office.phoneHref}
className="text-on-surface hover:text-primary font-mono font-semibold"
>
{t.offices.tel} {office.phone.label}
{t.offices.tel} {office.phone}
</a>
</div>
</div>
@@ -275,31 +279,32 @@ function OfficeCard({ t }: { t: Content }) {
}
function ChannelsCard({ t }: { t: Content }) {
const site = useSiteInfo();
const channels = [
{
icon: 'call',
label: t.channels.phone,
value: company.phone.label,
href: company.phone.href,
value: site.phone.label,
href: site.phone.href,
},
{
icon: 'chat',
label: t.channels.whatsapp,
value: company.whatsapp.label,
href: `${company.whatsapp.href}?text=${encodeURIComponent(t.channels.whatsappMessage)}`,
value: site.whatsapp.label,
href: `${site.whatsapp.href}?text=${encodeURIComponent(t.channels.whatsappMessage)}`,
badge: t.channels.whatsappBadge,
},
{
icon: 'mail',
label: t.channels.email,
value: company.email.busdev,
href: `mailto:${company.email.busdev}`,
value: site.email.busdev,
href: `mailto:${site.email.busdev}`,
},
{
icon: 'headset_mic',
label: t.channels.support,
value: company.email.support,
href: `mailto:${company.email.support}`,
value: site.email.support,
href: `mailto:${site.email.support}`,
badge: t.channels.supportBadge,
},
];
@@ -398,12 +403,6 @@ function ConsultationRequestForm({ t }: { t: Content }) {
const [submitted, setSubmitted] = useState(false);
const form = t.form;
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitted(true);
event.currentTarget.reset();
};
return (
<Card padding="lg" className="shadow-xl lg:p-10">
<div className="border-outline-variant/30 border-b pb-6">
@@ -416,162 +415,216 @@ function ConsultationRequestForm({ t }: { t: Content }) {
</p>
</div>
<form className="flex flex-col gap-6 pt-6" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field id="full-name" label={form.name} required>
<TextInput
id="full-name"
name="name"
placeholder={form.namePlaceholder}
required
/>
</Field>
<Field id="company-name" label={form.company} required>
<TextInput
id="company-name"
name="company"
placeholder={form.companyPlaceholder}
required
/>
</Field>
<Field id="job-title" label={form.jobTitle} required>
<TextInput
id="job-title"
name="job_title"
placeholder={form.jobTitlePlaceholder}
required
/>
</Field>
<Field id="work-email" label={form.email} required>
<TextInput
id="work-email"
name="email"
type="email"
placeholder={form.emailPlaceholder}
required
/>
</Field>
<Field id="phone-number" label={form.phone} required>
<TextInput
id="phone-number"
name="phone"
type="tel"
placeholder="+62 812 XXXX XXXX"
required
/>
</Field>
<Field id="industry-sector" label={form.industry} required>
<SelectInput
id="industry-sector"
name="industry"
placeholder={form.industryPlaceholder}
options={form.industries}
required
/>
</Field>
</div>
<Field id="service-focus" label={form.service} required>
<SelectInput
id="service-focus"
name="service"
placeholder={form.servicePlaceholder}
options={serviceOptions(shared)}
required
/>
</Field>
<fieldset className="flex flex-col gap-2">
<legend className="text-on-surface pb-2 font-mono text-xs font-medium">
{form.timeline}
</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{form.timelines.map((timeline, index) => (
<label
key={timeline.value}
className={cn(
'border-outline-variant/60 bg-surface-container-lowest text-on-surface hover:bg-surface-container-high flex cursor-pointer flex-col rounded-lg border p-3 transition-colors',
'has-checked:border-primary-container has-checked:bg-primary-container has-checked:text-white',
'has-focus-visible:ring-primary has-focus-visible:ring-2',
)}
>
<input
type="radio"
name="timeline"
value={timeline.value}
defaultChecked={index === 0}
className="sr-only"
<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
id="full-name"
name="name"
placeholder={form.namePlaceholder}
required
/>
<span className="text-xs font-bold">
{timeline.label}
</span>
<span className="text-sm opacity-80">
{timeline.note}
</span>
</label>
))}
</div>
</fieldset>
<Field id="project-details" label={form.details}>
<TextArea
id="project-details"
name="details"
rows={4}
placeholder={form.detailsPlaceholder}
/>
</Field>
<div className="bg-surface-container-lowest flex items-start gap-3 rounded-lg p-3.5">
<input
id="nda-consent"
name="nda"
type="checkbox"
required
className="mt-0.5 h-4 w-4 shrink-0"
/>
<label
htmlFor="nda-consent"
className="text-on-surface-variant cursor-pointer text-sm"
>
<RichText text={form.nda} />
</label>
</div>
<Button type="submit" size="block" trailingIcon="arrow_forward">
{form.submit}
</Button>
{submitted && (
<div
role="status"
className="border-primary/40 bg-surface-container-high text-on-surface flex items-center gap-3 rounded-lg border p-4"
>
<Icon
name="check_circle"
className="text-primary text-2xl"
/>
<div>
<span className="font-bold">
{form.successTitle}
</span>
<p className="text-on-surface-variant text-sm">
{form.successMessage}
</p>
</Field>
<Field
id="company-name"
label={form.company}
required
>
<TextInput
id="company-name"
name="company"
placeholder={form.companyPlaceholder}
required
/>
</Field>
<Field
id="job-title"
label={form.jobTitle}
required
>
<TextInput
id="job-title"
name="job_title"
placeholder={form.jobTitlePlaceholder}
required
/>
</Field>
<Field id="work-email" label={form.email} required>
<TextInput
id="work-email"
name="email"
type="email"
placeholder={form.emailPlaceholder}
required
/>
</Field>
<Field
id="phone-number"
label={form.phone}
required
>
<TextInput
id="phone-number"
name="phone"
type="tel"
placeholder="+62 812 XXXX XXXX"
required
/>
</Field>
<Field
id="industry-sector"
label={form.industry}
required
>
<SelectInput
id="industry-sector"
name="industry"
placeholder={form.industryPlaceholder}
options={form.industries.map(
({ label }) => ({
value: label,
label,
}),
)}
required
/>
</Field>
</div>
</div>
)}
<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" />
{form.privacy}
</div>
</form>
<Field id="service-focus" label={form.service} required>
<SelectInput
id="service-focus"
name="service"
placeholder={form.servicePlaceholder}
options={serviceOptions(shared)}
required
/>
</Field>
<fieldset className="flex flex-col gap-2">
<legend className="text-on-surface pb-2 font-mono text-xs font-medium">
{form.timeline}
</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{form.timelines.map((timeline, index) => (
<label
key={timeline.value}
className={cn(
'border-outline-variant/60 bg-surface-container-lowest text-on-surface hover:bg-surface-container-high flex cursor-pointer flex-col rounded-lg border p-3 transition-colors',
'has-checked:border-primary-container has-checked:bg-primary-container has-checked:text-white',
'has-focus-visible:ring-primary has-focus-visible:ring-2',
)}
>
<input
type="radio"
name="timeline"
value={timeline.value}
defaultChecked={index === 0}
className="sr-only"
/>
<span className="text-xs font-bold">
{timeline.label}
</span>
<span className="text-sm opacity-80">
{timeline.note}
</span>
</label>
))}
</div>
</fieldset>
<Field id="project-details" label={form.details}>
<TextArea
id="project-details"
name="details"
rows={4}
placeholder={form.detailsPlaceholder}
/>
</Field>
<div className="bg-surface-container-lowest flex items-start gap-3 rounded-lg p-3.5">
<input
id="nda-consent"
name="nda_agreed"
value="1"
type="checkbox"
required
className="mt-0.5 h-4 w-4 shrink-0"
/>
<label
htmlFor="nda-consent"
className="text-on-surface-variant cursor-pointer text-sm"
>
<RichText text={form.nda} />
</label>
</div>
{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>
{submitted && (
<div
role="status"
className="border-primary/40 bg-surface-container-high text-on-surface flex items-center gap-3 rounded-lg border p-4"
>
<Icon
name="check_circle"
className="text-primary text-2xl"
/>
<div>
<span className="font-bold">
{form.successTitle}
</span>
<p className="text-on-surface-variant text-sm">
{form.successMessage}
</p>
</div>
</div>
)}
<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"
/>
{form.privacy}
</div>
</>
)}
</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,15 +654,17 @@ function OfficeMap({ t }: { t: Content }) {
<p className="text-on-surface-variant text-sm leading-relaxed">
{officeAddress(office)}
</p>
<ButtonLink
href={office.mapsUrl}
variant="secondary"
size="sm"
icon="directions"
className="self-start"
>
{t.location.directions}
</ButtonLink>
{office.maps_url && (
<ButtonLink
href={office.maps_url}
variant="secondary"
size="sm"
icon="directions"
className="self-start"
>
{t.location.directions}
</ButtonLink>
)}
</div>
))}
</div>
+6 -3
View File
@@ -14,14 +14,16 @@ import ConsultationSection from '@/components/site/sections/consultation-section
import ServiceGrid from '@/components/site/sections/service-grid';
import StatCard from '@/components/site/stat-card';
import { homeContent } from '@/content/home';
import { topClients } from '@/data/projects';
import { fillCounts } from '@/data/projects';
import { useContent } from '@/hooks/use-locale';
import { usePortfolio } from '@/hooks/use-portfolio';
import { about, clients, services } from '@/routes';
type Content = typeof homeContent.id;
export default function Home() {
const t = useContent(homeContent);
const stats = usePortfolio();
const t = fillCounts(useContent(homeContent), stats);
return (
<>
@@ -48,7 +50,7 @@ export default function Home() {
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
{topClients(10).map((client) => (
{stats.topClients(10).map((client) => (
<div
key={client}
className="border-outline-variant/40 bg-surface-container text-on-surface hover:border-primary/50 hover:bg-surface-container-high flex min-h-16 items-center justify-center rounded-xl border px-4 py-3 text-center font-mono text-xs font-medium transition-all"
@@ -140,6 +142,7 @@ export default function Home() {
<ConsultationSection
id="konsultasi"
source="home"
title={t.consultation.title}
description={t.consultation.description}
/>
+1
View File
@@ -185,6 +185,7 @@ export default function Services() {
<ConsultationSection
id="konsultasi"
source="services"
title={t.consultation.title}
description={t.consultation.description}
/>
+1
View File
@@ -2,6 +2,7 @@ export type User = {
id: number;
name: string;
email: string;
role: 'admin' | 'editor';
avatar?: string;
email_verified_at: string | null;
two_factor_enabled?: boolean;
+3
View File
@@ -1,4 +1,5 @@
import type { Auth } from '@/types/auth';
import type { SiteInfo } from '@/types/site';
declare module 'react' {
interface InputHTMLAttributes<T> {
@@ -12,6 +13,8 @@ declare module '@inertiajs/core' {
name: string;
locale: 'id' | 'en';
auth: Auth;
site: SiteInfo;
can: { manageUsers: boolean };
sidebarOpen: boolean;
[key: string]: unknown;
};
+1
View File
@@ -1,3 +1,4 @@
export type * from './auth';
export type * from './navigation';
export type * from './site';
export type * from './ui';
+95
View File
@@ -0,0 +1,95 @@
export type ProjectSector = 'government' | 'private' | 'soe';
/** Long-running programs highlighted as featured case studies. */
export type ProjectProgram = 'price-monitoring' | 'investment' | 'erp';
/** A portfolio entry, as stored in the `projects` table. */
export type Project = {
id: number;
title: string;
client: string;
year: number;
sector: ProjectSector;
program: ProjectProgram | null;
/** Optional detail of what the project covered. */
scope: string | null;
};
export type Office = {
id: number;
name: string;
icon: string;
building: string;
street: string;
area: string;
city: string;
district: string;
phone: string;
maps_url: string | null;
sort_order: number;
};
export type SiteContact = {
phone: string;
whatsapp: string;
email_busdev: string;
email_support: string;
};
/** Editable company details shared with every page (see `SiteInfo`). */
export type SiteInfo = {
contact: SiteContact;
offices: Office[];
};
export type ConsultationStatus = 'new' | 'contacted' | 'closed';
export type Consultation = {
id: number;
source: 'home' | 'services' | 'contact';
name: string;
company: string | null;
job_title: string | null;
email: string;
phone: string;
industry: string | null;
service: string | null;
timeline: string | null;
details: string | null;
nda_agreed: boolean;
locale: 'id' | 'en';
status: ConsultationStatus;
admin_notes: string | null;
created_at: string;
};
export type Testimonial = {
id: number;
quote_id: string;
quote_en: string;
role_id: string;
role_en: string;
organization_id: string;
organization_en: string;
is_published: boolean;
sort_order: number;
};
/** A testimonial in the visitor's language, as sent to the site. */
export type LocalizedTestimonial = {
id: number;
quote: string;
role: string;
organization: string;
};
export type UserRole = 'admin' | 'editor';
export type Paginated<T> = {
data: T[];
current_page: number;
last_page: number;
total: number;
prev_page_url: string | null;
next_page_url: string | null;
};