497 lines
28 KiB
TypeScript
497 lines
28 KiB
TypeScript
import { FileUpload } from '@/components/file-upload';
|
|
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, updateHr, updateMarketplace, updateSocialMedia, updateSystem } from '@/routes/admin/settings';
|
|
import { Form, Head } from '@inertiajs/react';
|
|
import { Plus, Trash2 } from 'lucide-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;
|
|
hero_image_key: string;
|
|
about_image_url: string;
|
|
about_image_key: string;
|
|
gallery_images: string[];
|
|
gallery_image_keys: 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;
|
|
};
|
|
};
|
|
|
|
type HomepageSettingsTabProps = {
|
|
homepage: Props['homepage'];
|
|
};
|
|
|
|
function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
|
|
const [heroKey, setHeroKey] = useState<string | null>(homepage.hero_image_key ?? null);
|
|
const [heroUploading, setHeroUploading] = useState(false);
|
|
const [aboutKey, setAboutKey] = useState<string | null>(homepage.about_image_key ?? null);
|
|
const [aboutUploading, setAboutUploading] = useState(false);
|
|
const [galleryKeys, setGalleryKeys] = useState<string[]>(homepage.gallery_image_keys ?? []);
|
|
const [galleryUploading, setGalleryUploading] = useState(false);
|
|
|
|
function addGalleryImage() {
|
|
setGalleryKeys((prev) => [...prev, '']);
|
|
}
|
|
|
|
function removeGalleryImage(index: number) {
|
|
setGalleryKeys((prev) => prev.filter((_, i) => i !== index));
|
|
}
|
|
|
|
function updateGalleryKey(index: number, key: string | null) {
|
|
setGalleryKeys((prev) => prev.map((k, i) => (i === index ? (key ?? '') : k)));
|
|
}
|
|
|
|
return (
|
|
<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">
|
|
<input type="hidden" name="hero_image_key" value={heroKey ?? ''} />
|
|
<input type="hidden" name="about_image_key" value={aboutKey ?? ''} />
|
|
{galleryKeys.map((key, index) => (
|
|
<input key={index} type="hidden" name={`gallery_image_keys[${index}]`} value={key} />
|
|
))}
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Foto Hero</Label>
|
|
<FileUpload
|
|
value={heroKey}
|
|
onChange={setHeroKey}
|
|
folder="homepage/hero"
|
|
existingUrl={homepage.hero_image_url}
|
|
onUploadingChange={setHeroUploading}
|
|
/>
|
|
<InputError message={errors.hero_image_key} />
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Foto Tentang Kami</Label>
|
|
<FileUpload
|
|
value={aboutKey}
|
|
onChange={setAboutKey}
|
|
folder="homepage/about"
|
|
existingUrl={homepage.about_image_url}
|
|
onUploadingChange={setAboutUploading}
|
|
/>
|
|
<InputError message={errors.about_image_key} />
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label>Galeri Foto</Label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addGalleryImage}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Tambah
|
|
</Button>
|
|
</div>
|
|
<div className="grid gap-3">
|
|
{galleryKeys.map((key, index) => (
|
|
<div key={index} className="flex items-start gap-2">
|
|
<div className="flex-1">
|
|
<FileUpload
|
|
value={key || null}
|
|
onChange={(k) => updateGalleryKey(index, k)}
|
|
folder="homepage/gallery"
|
|
existingUrl={homepage.gallery_images[index] ?? null}
|
|
onUploadingChange={setGalleryUploading}
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="mt-1 text-destructive hover:text-destructive"
|
|
onClick={() => removeGalleryImage(index)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
{galleryKeys.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">Belum ada gambar. Klik "Tambah" untuk menambahkan gambar galeri.</p>
|
|
)}
|
|
</div>
|
|
<InputError message={errors.gallery_image_keys} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<div className="flex items-center gap-4">
|
|
<Button type="submit" disabled={processing || heroUploading || aboutUploading || galleryUploading}>
|
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
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' && (
|
|
<HomepageSettingsTab homepage={homepage} />
|
|
)}
|
|
|
|
{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={updateHr()} 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',
|
|
},
|
|
],
|
|
};
|