dstpabuaran.com/resources/js/pages/admin/settings/index.tsx
Yoga Pangestu a1a73cb222 Add admin settings routes and corresponding feature tests
- Introduced routes for managing admin settings including system, homepage, social media, marketplace, and HR settings.
- Created a comprehensive test suite for the AdminSettingsController to ensure proper functionality and validation of settings updates.
- Implemented tests for authentication, data integrity, and realistic user scenarios to validate the settings management process.
2026-07-31 01:04:14 +07:00

399 lines
23 KiB
TypeScript

import InputError from '@/components/input-error';
import { PhoneNumberInput } from '@/components/phone-number-input';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { updateHomepage, updateMarketplace, updateSocialMedia, updateSystem } from '@/routes/admin/settings';
import { Form, Head } from '@inertiajs/react';
import { useState } from 'react';
type MarketplaceFeeRule = {
base: string;
type: string;
value: number;
};
type Props = {
system: {
app_name: string;
about_app: string;
address: string;
email: string;
phone: string;
};
homepage: {
hero_image_url: string;
about_image_url: string;
gallery_images: string[];
};
socialMedia: {
instagram_url: string;
facebook_url: string;
tiktok_url: string;
};
marketplace: {
tiktok_shop: Record<string, MarketplaceFeeRule>;
shopee: Record<string, MarketplaceFeeRule>;
};
hr: {
scheduled_check_in_time: string;
scheduled_check_out_time: string;
late_penalty_amount: number;
absent_penalty_amount: number;
};
};
const sidebarTabs = [
{ key: 'sistem', label: 'Sistem' },
{ key: 'homepage', label: 'Homepage' },
{ key: 'media-sosial', label: 'Media Sosial' },
{ key: 'marketplace', label: 'Marketplace' },
{ key: 'hr', label: 'HR' },
] as const;
type TabKey = (typeof sidebarTabs)[number]['key'];
function MarketplaceVariableInput({ label, prefix, data }: { label: string; prefix: string; data: MarketplaceFeeRule }) {
const [type, setType] = useState(data.type);
return (
<div className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-start md:gap-4">
<span className="min-w-[160px] text-sm font-medium md:pt-2">{label}</span>
<div className="grid flex-1 grid-cols-1 gap-3 md:grid-cols-3">
<div className="grid gap-2">
<span className="text-xs font-medium text-muted-foreground md:hidden">Dasar</span>
<RadioGroup name={`${prefix}[base]`} defaultValue={data.base} className="flex gap-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="per_transaksi" id={`${prefix}-base-per_transaksi`} />
<Label htmlFor={`${prefix}-base-per_transaksi`} className="font-normal text-xs">Per Transaksi</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="per_produk" id={`${prefix}-base-per_produk`} />
<Label htmlFor={`${prefix}-base-per_produk`} className="font-normal text-xs">Per Produk</Label>
</div>
</RadioGroup>
</div>
<div className="grid gap-2">
<span className="text-xs font-medium text-muted-foreground md:hidden">Tipe</span>
<RadioGroup name={`${prefix}[type]`} defaultValue={data.type} onValueChange={setType} className="flex gap-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="flat" id={`${prefix}-type-flat`} />
<Label htmlFor={`${prefix}-type-flat`} className="font-normal text-xs">Flat</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="persentase" id={`${prefix}-type-persentase`} />
<Label htmlFor={`${prefix}-type-persentase`} className="font-normal text-xs">Persentase</Label>
</div>
</RadioGroup>
</div>
<div className="grid gap-2">
<span className="text-xs font-medium text-muted-foreground md:hidden">Nilai</span>
{type === 'flat' ? (
<RupiahInput name={`${prefix}[value]`} defaultValue={data.value} />
) : (
<Input
name={`${prefix}[value]`}
type="number"
step="0.01"
min="0"
defaultValue={data.value}
placeholder="0"
/>
)}
</div>
</div>
</div>
);
}
function MarketplaceColumnHeader() {
return (
<div className="hidden gap-3 px-3 pb-1 text-xs font-medium text-muted-foreground md:grid md:grid-cols-[160px_1fr] md:gap-4">
<span>Biaya</span>
<div className="grid flex-1 grid-cols-3 gap-3">
<span>Dasar</span>
<span>Tipe</span>
<span>Nilai</span>
</div>
</div>
);
}
function MarketplaceCard({ title, variables }: { title: string; variables: { label: string; prefix: string; data: MarketplaceFeeRule }[] }) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-2">
<MarketplaceColumnHeader />
{variables.map((v) => (
<MarketplaceVariableInput key={v.prefix} {...v} />
))}
</CardContent>
</Card>
);
}
export default function AdminSettings({ system, homepage, socialMedia, marketplace, hr }: Props) {
const [activeTab, setActiveTab] = useState<TabKey>('sistem');
const [marketplaceTab, setMarketplaceTab] = useState<'tiktok-shop' | 'shopee'>('tiktok-shop');
const tiktokShopVariables = [
{ label: 'Komisi Platform', prefix: 'tiktok_shop_platform_commission', data: marketplace.tiktok_shop.platform_commission },
{ label: 'Layanan Logistik', prefix: 'tiktok_shop_logistics_service_fee', data: marketplace.tiktok_shop.logistics_service_fee },
{ label: 'Komisi Dinamis', prefix: 'tiktok_shop_dynamic_commission', data: marketplace.tiktok_shop.dynamic_commission },
{ label: 'Pemrosesan Pesanan', prefix: 'tiktok_shop_order_processing_fee', data: marketplace.tiktok_shop.order_processing_fee },
{ label: 'Affiliate', prefix: 'tiktok_shop_affiliate', data: marketplace.tiktok_shop.affiliate },
{ label: 'Layanan PO', prefix: 'tiktok_shop_pre_order_service_fee', data: marketplace.tiktok_shop.pre_order_service_fee },
];
const shopeeVariables = [
{ label: 'Biaya Administrasi', prefix: 'shopee_admin_fee', data: marketplace.shopee.admin_fee },
{ label: 'Biaya Program', prefix: 'shopee_program_fee', data: marketplace.shopee.program_fee },
{ label: 'Hemat Biaya Kirim', prefix: 'shopee_shipping_savings', data: marketplace.shopee.shipping_savings },
{ label: 'Premi', prefix: 'shopee_premium', data: marketplace.shopee.premium },
{ label: 'Biaya Layanan', prefix: 'shopee_service_fee', data: marketplace.shopee.service_fee },
{ label: 'Biaya Proses Pesanan', prefix: 'shopee_order_processing_fee', data: marketplace.shopee.order_processing_fee },
{ label: 'Biaya Komisi AMS', prefix: 'shopee_ams_commission_fee', data: marketplace.shopee.ams_commission_fee },
{ label: 'PO', prefix: 'shopee_pre_order', data: marketplace.shopee.pre_order },
{ label: 'Live Extra', prefix: 'shopee_live_extra', data: marketplace.shopee.live_extra },
];
return (
<>
<Head title="Pengaturan" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Pengaturan
</h2>
</div>
</div>
<div className="flex flex-col lg:flex-row lg:space-x-12">
<aside className="w-full max-w-xl lg:w-48">
<nav className="flex flex-col space-y-1 space-x-0" aria-label="Admin Settings">
{sidebarTabs.map((tab) => (
<Button
key={tab.key}
size="sm"
variant="ghost"
onClick={() => setActiveTab(tab.key)}
className={cn('w-full justify-start', {
'bg-muted': activeTab === tab.key,
})}
>
{tab.label}
</Button>
))}
</nav>
</aside>
<Separator className="my-6 lg:hidden" />
<section className="w-full min-w-0 flex-1 space-y-6">
{activeTab === 'sistem' && (
<Form action={updateSystem()} options={{ preserveScroll: true }}>
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Sistem</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="app_name">
Nama Aplikasi <span className="text-destructive">*</span>
</Label>
<Input id="app_name" name="app_name" placeholder="Masukkan nama aplikasi" defaultValue={system.app_name} />
<InputError message={errors.app_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" placeholder="Masukkan email" defaultValue={system.email} />
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone">No. Telepon</Label>
<PhoneNumberInput name="phone" defaultValue={system.phone} />
<InputError message={errors.phone} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">Alamat</Label>
<Textarea id="address" name="address" placeholder="Masukkan alamat" rows={3} defaultValue={system.address} />
<InputError message={errors.address} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="about_app">Tentang Aplikasi</Label>
<Textarea id="about_app" name="about_app" placeholder="Masukkan deskripsi aplikasi" rows={4} defaultValue={system.about_app} />
<InputError message={errors.about_app} />
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button>
</div>
</div>
)}
</Form>
)}
{activeTab === 'homepage' && (
<Form action={updateHomepage()} options={{ preserveScroll: true }}>
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Homepage</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4">
<div className="grid gap-2">
<Label htmlFor="hero_image_url">URL Foto Hero</Label>
<Input id="hero_image_url" name="hero_image_url" placeholder="https://..." defaultValue={homepage.hero_image_url} />
<InputError message={errors.hero_image_url} />
</div>
<div className="grid gap-2">
<Label htmlFor="about_image_url">URL Foto Tentang Kami</Label>
<Input id="about_image_url" name="about_image_url" placeholder="https://..." defaultValue={homepage.about_image_url} />
<InputError message={errors.about_image_url} />
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button>
</div>
</div>
)}
</Form>
)}
{activeTab === 'media-sosial' && (
<Form action={updateSocialMedia()} options={{ preserveScroll: true }}>
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Media Sosial</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="instagram_url">Instagram</Label>
<Input id="instagram_url" name="instagram_url" placeholder="https://instagram.com/..." defaultValue={socialMedia.instagram_url ?? ''} />
<InputError message={errors.instagram_url} />
</div>
<div className="grid gap-2">
<Label htmlFor="facebook_url">Facebook</Label>
<Input id="facebook_url" name="facebook_url" placeholder="https://facebook.com/..." defaultValue={socialMedia.facebook_url ?? ''} />
<InputError message={errors.facebook_url} />
</div>
<div className="grid gap-2">
<Label htmlFor="tiktok_url">TikTok</Label>
<Input id="tiktok_url" name="tiktok_url" placeholder="https://tiktok.com/..." defaultValue={socialMedia.tiktok_url ?? ''} />
<InputError message={errors.tiktok_url} />
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button>
</div>
</div>
)}
</Form>
)}
{activeTab === 'marketplace' && (
<Form action={updateMarketplace()} options={{ preserveScroll: true }}>
{({ processing }) => (
<div className="grid gap-6">
<Tabs value={marketplaceTab} onValueChange={(v) => setMarketplaceTab(v as 'tiktok-shop' | 'shopee')}>
<TabsList className="mb-3">
<TabsTrigger value="tiktok-shop">TikTok Shop</TabsTrigger>
<TabsTrigger value="shopee">Shopee</TabsTrigger>
</TabsList>
</Tabs>
<div hidden={marketplaceTab !== 'tiktok-shop'}>
<MarketplaceCard title="TikTok Shop" variables={tiktokShopVariables} />
</div>
<div hidden={marketplaceTab !== 'shopee'}>
<MarketplaceCard title="Shopee" variables={shopeeVariables} />
</div>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button>
</div>
</div>
)}
</Form>
)}
{activeTab === 'hr' && (
<Form action="/admin/settings/hr" options={{ preserveScroll: true }}>
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>HR</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="scheduled_check_in_time">
Jam Masuk Kerja <span className="text-destructive">*</span>
</Label>
<Input id="scheduled_check_in_time" name="scheduled_check_in_time" type="time" defaultValue={hr.scheduled_check_in_time} />
<InputError message={errors.scheduled_check_in_time} />
</div>
<div className="grid gap-2">
<Label htmlFor="scheduled_check_out_time">
Jam Pulang Kerja <span className="text-destructive">*</span>
</Label>
<Input id="scheduled_check_out_time" name="scheduled_check_out_time" type="time" defaultValue={hr.scheduled_check_out_time} />
<InputError message={errors.scheduled_check_out_time} />
</div>
<div className="grid gap-2">
<Label>
Denda Keterlambatan <span className="text-destructive">*</span>
</Label>
<RupiahInput name="late_penalty_amount" defaultValue={hr.late_penalty_amount} />
<InputError message={errors.late_penalty_amount} />
</div>
<div className="grid gap-2">
<Label>
Denda Bolos <span className="text-destructive">*</span>
</Label>
<RupiahInput name="absent_penalty_amount" defaultValue={hr.absent_penalty_amount} />
<InputError message={errors.absent_penalty_amount} />
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button>
</div>
</div>
)}
</Form>
)}
</section>
</div>
</div>
</>
);
}
AdminSettings.layout = {
breadcrumbs: [
{
title: 'Pengaturan',
href: '/admin/settings',
},
],
};