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
@@ -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 />