dstpabuaran.com/resources/js/pages/admin/settings/index.tsx

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',
},
],
};