dstpabuaran.com/resources/js/pages/admin/settings/index.tsx
Yoga Pangestu 50b2be68ac Add error handling with toast notifications for form submissions
- Integrated toast notifications to display error messages when form submissions fail across various components, enhancing user feedback and experience.
- Updated components including DeleteUser, FormDialog, ManageTwoFactor, TwoFactorRecoveryCodes, TwoFactorSetupModal, PayrollPeriodShow, EmployeeCreate, EmployeeEdit, CuttingCreate, CuttingEdit, PurchaseCreate, PurchaseEdit, RestockCreate, RestockEdit, TransactionCreate, TransactionEdit, Category management, Product management, Raw Material management, Role management, Settings, and Authentication pages.
2026-08-07 08:50:11 +07:00

922 lines
44 KiB
TypeScript

import { Form, Head } from '@inertiajs/react';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
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';
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 }}
onError={() => {
toast.error('Terjadi kesalahan saat menyimpan data. Silakan periksa kembali input Anda.');
}}
>
{({ 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="text-xs font-normal"
>
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="text-xs font-normal"
>
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="text-xs font-normal"
>
Flat
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="persentase"
id={`${prefix}-type-persentase`}
/>
<Label
htmlFor={`${prefix}-type-persentase`}
className="text-xs font-normal"
>
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 }}
onError={() => {
toast.error('Terjadi kesalahan saat menyimpan data. Silakan periksa kembali input Anda.');
}}
>
{({ 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 }}
onError={() => {
toast.error('Terjadi kesalahan saat menyimpan data. Silakan periksa kembali input Anda.');
}}
>
{({ 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 }}
onError={() => {
toast.error('Terjadi kesalahan saat menyimpan data. Silakan periksa kembali input Anda.');
}}
>
{({ 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 }}
onError={() => {
toast.error('Terjadi kesalahan saat menyimpan data. Silakan periksa kembali input Anda.');
}}
>
{({ 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>
</>
);
}