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.
This commit is contained in:
Yoga Pangestu 2026-07-31 01:04:14 +07:00
parent c2b93a0489
commit a1a73cb222
28 changed files with 2662 additions and 4 deletions

View File

@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Settings\UpdateHRRequest;
use App\Http\Requests\Admin\Settings\UpdateHomepageRequest;
use App\Http\Requests\Admin\Settings\UpdateMarketplaceRequest;
use App\Http\Requests\Admin\Settings\UpdateSocialMediaRequest;
use App\Http\Requests\Admin\Settings\UpdateSystemRequest;
use App\Services\Admin\AdminSettingsService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class AdminSettingsController extends Controller
{
public function __construct(
private AdminSettingsService $service
) {}
public function index(): Response
{
return Inertia::render('admin/settings/index', [
'system' => $this->service->getSystemData(),
'homepage' => $this->service->getHomepageData(),
'socialMedia' => $this->service->getSocialMediaData(),
'marketplace' => $this->service->getMarketplaceData(),
'hr' => $this->service->getHRData(),
]);
}
public function updateSystem(UpdateSystemRequest $request): RedirectResponse
{
$this->service->updateSystem($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan sistem berhasil diperbarui.']);
return to_route('admin.settings.index');
}
public function updateHomepage(UpdateHomepageRequest $request): RedirectResponse
{
$this->service->updateHomepage($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan homepage berhasil diperbarui.']);
return to_route('admin.settings.index');
}
public function updateSocialMedia(UpdateSocialMediaRequest $request): RedirectResponse
{
$this->service->updateSocialMedia($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan media sosial berhasil diperbarui.']);
return to_route('admin.settings.index');
}
public function updateMarketplace(UpdateMarketplaceRequest $request): RedirectResponse
{
$this->service->updateMarketplace($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan marketplace berhasil diperbarui.']);
return to_route('admin.settings.index');
}
public function updateHR(UpdateHRRequest $request): RedirectResponse
{
$this->service->updateHR($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan hr berhasil diperbarui.']);
return to_route('admin.settings.index');
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\Admin\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateHRRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'scheduled_check_in_time' => ['required', 'string', 'max:5'],
'scheduled_check_out_time' => ['required', 'string', 'max:5'],
'late_penalty_amount' => ['required', 'integer', 'min:0'],
'absent_penalty_amount' => ['required', 'integer', 'min:0'],
];
}
public function attributes(): array
{
return [
'scheduled_check_in_time' => 'jam masuk kerja',
'scheduled_check_out_time' => 'jam pulang kerja',
'late_penalty_amount' => 'denda keterlambatan',
'absent_penalty_amount' => 'denda bolos',
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Admin\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateHomepageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'hero_image_url' => ['nullable', 'string', 'max:2000'],
'about_image_url' => ['nullable', 'string', 'max:2000'],
'gallery_images' => ['nullable', 'array'],
'gallery_images.*' => ['string', 'max:2000'],
];
}
public function attributes(): array
{
return [
'hero_image_url' => 'url foto hero',
'about_image_url' => 'url foto tentang kami',
'gallery_images' => 'gambar galeri',
];
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Http\Requests\Admin\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateMarketplaceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
private function feeRuleRules(): array
{
return ['required', 'array'];
}
private function feeFieldRules(string $prefix): array
{
return [
"{$prefix}.base" => ['required', 'string', 'in:per_transaksi,per_produk'],
"{$prefix}.type" => ['required', 'string', 'in:flat,persentase'],
"{$prefix}.value" => ['required', 'numeric', 'min:0'],
];
}
public function rules(): array
{
$keys = [
'tiktok_shop_platform_commission',
'tiktok_shop_logistics_service_fee',
'tiktok_shop_dynamic_commission',
'tiktok_shop_order_processing_fee',
'tiktok_shop_affiliate',
'tiktok_shop_pre_order_service_fee',
'shopee_admin_fee',
'shopee_program_fee',
'shopee_shipping_savings',
'shopee_premium',
'shopee_service_fee',
'shopee_order_processing_fee',
'shopee_ams_commission_fee',
'shopee_pre_order',
'shopee_live_extra',
];
$rules = [];
foreach ($keys as $key) {
$rules[$key] = $this->feeRuleRules();
$rules = array_merge($rules, $this->feeFieldRules($key));
}
return $rules;
}
public function attributes(): array
{
return [
'tiktok_shop_platform_commission.base' => 'dasar komisi platform tiktok shop',
'tiktok_shop_platform_commission.type' => 'tipe komisi platform tiktok shop',
'tiktok_shop_platform_commission.value' => 'nilai komisi platform tiktok shop',
'tiktok_shop_logistics_service_fee.base' => 'dasar layanan logistik tiktok shop',
'tiktok_shop_logistics_service_fee.type' => 'tipe layanan logistik tiktok shop',
'tiktok_shop_logistics_service_fee.value' => 'nilai layanan logistik tiktok shop',
'tiktok_shop_dynamic_commission.base' => 'dasar komisi dinamis tiktok shop',
'tiktok_shop_dynamic_commission.type' => 'tipe komisi dinamis tiktok shop',
'tiktok_shop_dynamic_commission.value' => 'nilai komisi dinamis tiktok shop',
'tiktok_shop_order_processing_fee.base' => 'dasar pemrosesan pesanan tiktok shop',
'tiktok_shop_order_processing_fee.type' => 'tipe pemrosesan pesanan tiktok shop',
'tiktok_shop_order_processing_fee.value' => 'nilai pemrosesan pesanan tiktok shop',
'tiktok_shop_affiliate.base' => 'dasar affiliate tiktok shop',
'tiktok_shop_affiliate.type' => 'tipe affiliate tiktok shop',
'tiktok_shop_affiliate.value' => 'nilai affiliate tiktok shop',
'tiktok_shop_pre_order_service_fee.base' => 'dasar layanan PO tiktok shop',
'tiktok_shop_pre_order_service_fee.type' => 'tipe layanan PO tiktok shop',
'tiktok_shop_pre_order_service_fee.value' => 'nilai layanan PO tiktok shop',
'shopee_admin_fee.base' => 'dasar biaya administrasi shopee',
'shopee_admin_fee.type' => 'tipe biaya administrasi shopee',
'shopee_admin_fee.value' => 'nilai biaya administrasi shopee',
'shopee_program_fee.base' => 'dasar biaya program shopee',
'shopee_program_fee.type' => 'tipe biaya program shopee',
'shopee_program_fee.value' => 'nilai biaya program shopee',
'shopee_shipping_savings.base' => 'dasar hemat biaya kirim shopee',
'shopee_shipping_savings.type' => 'tipe hemat biaya kirim shopee',
'shopee_shipping_savings.value' => 'nilai hemat biaya kirim shopee',
'shopee_premium.base' => 'dasar premi shopee',
'shopee_premium.type' => 'tipe premi shopee',
'shopee_premium.value' => 'nilai premi shopee',
'shopee_service_fee.base' => 'dasar biaya layanan shopee',
'shopee_service_fee.type' => 'tipe biaya layanan shopee',
'shopee_service_fee.value' => 'nilai biaya layanan shopee',
'shopee_order_processing_fee.base' => 'dasar biaya proses pesanan shopee',
'shopee_order_processing_fee.type' => 'tipe biaya proses pesanan shopee',
'shopee_order_processing_fee.value' => 'nilai biaya proses pesanan shopee',
'shopee_ams_commission_fee.base' => 'dasar biaya komisi AMS shopee',
'shopee_ams_commission_fee.type' => 'tipe biaya komisi AMS shopee',
'shopee_ams_commission_fee.value' => 'nilai biaya komisi AMS shopee',
'shopee_pre_order.base' => 'dasar PO shopee',
'shopee_pre_order.type' => 'tipe PO shopee',
'shopee_pre_order.value' => 'nilai PO shopee',
'shopee_live_extra.base' => 'dasar live extra shopee',
'shopee_live_extra.type' => 'tipe live extra shopee',
'shopee_live_extra.value' => 'nilai live extra shopee',
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Admin\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSocialMediaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'instagram_url' => ['nullable', 'string', 'max:500'],
'facebook_url' => ['nullable', 'string', 'max:500'],
'tiktok_url' => ['nullable', 'string', 'max:500'],
];
}
public function attributes(): array
{
return [
'instagram_url' => 'instagram',
'facebook_url' => 'facebook',
'tiktok_url' => 'tiktok',
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Admin\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSystemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'app_name' => ['required', 'string', 'max:255'],
'about_app' => ['nullable', 'string', 'max:2000'],
'address' => ['nullable', 'string', 'max:500'],
'email' => ['nullable', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'],
];
}
public function attributes(): array
{
return [
'app_name' => 'nama aplikasi',
'about_app' => 'tentang aplikasi',
'address' => 'alamat',
'email' => 'email',
'phone' => 'nomor telepon',
];
}
}

View File

@ -0,0 +1,146 @@
<?php
namespace App\Services\Admin;
use App\Settings\HRSettings;
use App\Settings\HomepageSettings;
use App\Settings\MarketplaceSettings;
use App\Settings\SocialMediaSettings;
use App\Settings\SystemSettings;
use App\Support\Marketplace\MarketplaceFeeRule;
class AdminSettingsService
{
public function getSystemData(): array
{
$settings = app(SystemSettings::class);
return [
'app_name' => $settings->app_name,
'about_app' => $settings->about_app,
'address' => $settings->address,
'email' => $settings->email,
'phone' => $settings->phone,
];
}
public function getHomepageData(): array
{
$settings = app(HomepageSettings::class);
return [
'hero_image_url' => $settings->hero_image_url,
'about_image_url' => $settings->about_image_url,
'gallery_images' => $settings->gallery_images,
];
}
public function getSocialMediaData(): array
{
$settings = app(SocialMediaSettings::class);
return [
'instagram_url' => $settings->instagram_url,
'facebook_url' => $settings->facebook_url,
'tiktok_url' => $settings->tiktok_url,
];
}
public function getMarketplaceData(): array
{
$settings = app(MarketplaceSettings::class);
return [
'tiktok_shop' => [
'platform_commission' => $settings->tiktok_shop_platform_commission->toArray(),
'logistics_service_fee' => $settings->tiktok_shop_logistics_service_fee->toArray(),
'dynamic_commission' => $settings->tiktok_shop_dynamic_commission->toArray(),
'order_processing_fee' => $settings->tiktok_shop_order_processing_fee->toArray(),
'affiliate' => $settings->tiktok_shop_affiliate->toArray(),
'pre_order_service_fee' => $settings->tiktok_shop_pre_order_service_fee->toArray(),
],
'shopee' => [
'admin_fee' => $settings->shopee_admin_fee->toArray(),
'program_fee' => $settings->shopee_program_fee->toArray(),
'shipping_savings' => $settings->shopee_shipping_savings->toArray(),
'premium' => $settings->shopee_premium->toArray(),
'service_fee' => $settings->shopee_service_fee->toArray(),
'order_processing_fee' => $settings->shopee_order_processing_fee->toArray(),
'ams_commission_fee' => $settings->shopee_ams_commission_fee->toArray(),
'pre_order' => $settings->shopee_pre_order->toArray(),
'live_extra' => $settings->shopee_live_extra->toArray(),
],
];
}
public function getHRData(): array
{
$settings = app(HRSettings::class);
return [
'scheduled_check_in_time' => $settings->scheduled_check_in_time,
'scheduled_check_out_time' => $settings->scheduled_check_out_time,
'late_penalty_amount' => $settings->late_penalty_amount,
'absent_penalty_amount' => $settings->absent_penalty_amount,
];
}
public function updateSystem(array $data): void
{
$settings = app(SystemSettings::class);
$settings->fill($data);
$settings->save();
}
public function updateHomepage(array $data): void
{
$settings = app(HomepageSettings::class);
$settings->fill($data);
$settings->save();
}
public function updateSocialMedia(array $data): void
{
$settings = app(SocialMediaSettings::class);
$settings->fill($data);
$settings->save();
}
public function updateMarketplace(array $data): void
{
$settings = app(MarketplaceSettings::class);
$feeKeys = [
'tiktok_shop_platform_commission',
'tiktok_shop_logistics_service_fee',
'tiktok_shop_dynamic_commission',
'tiktok_shop_order_processing_fee',
'tiktok_shop_affiliate',
'tiktok_shop_pre_order_service_fee',
'shopee_admin_fee',
'shopee_program_fee',
'shopee_shipping_savings',
'shopee_premium',
'shopee_service_fee',
'shopee_order_processing_fee',
'shopee_ams_commission_fee',
'shopee_pre_order',
'shopee_live_extra',
];
foreach ($feeKeys as $key) {
if (isset($data[$key])) {
$settings->{$key} = MarketplaceFeeRule::from($data[$key]);
}
}
$settings->save();
}
public function updateHR(array $data): void
{
$settings = app(HRSettings::class);
$settings->fill($data);
$settings->save();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Settings;
use Spatie\LaravelSettings\Settings;
class HRSettings extends Settings
{
public string $scheduled_check_in_time;
public string $scheduled_check_out_time;
public int $late_penalty_amount;
public int $absent_penalty_amount;
public static function group(): string
{
return 'hr';
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Settings;
use Spatie\LaravelSettings\Settings;
class HomepageSettings extends Settings
{
public string $hero_badge;
public string $hero_title_line1;
public string $hero_title_line2;
public string $hero_title_highlight;
public string $hero_description;
public string $hero_cta_primary_text;
public string $hero_cta_secondary_text;
public ?string $hero_image_url;
public string $hero_bg_text_left;
public string $hero_bg_text_right;
public string $scroll_hashtag;
public string $scroll_tagline;
public string $catalog_badge;
public string $catalog_title;
public string $catalog_description;
public string $catalog_search_placeholder;
public string $gallery_badge;
public string $gallery_title;
public string $gallery_description;
public array $gallery_images;
public string $order_guide_badge;
public string $order_guide_title;
public string $order_guide_description;
public array $order_steps;
public string $about_badge;
public string $about_title;
public ?string $about_image_url;
public array $about_features;
public string $contact_badge;
public string $contact_title;
public string $contact_description;
public string $contact_form_title;
public string $footer_description;
public string $footer_copyright;
public static function group(): string
{
return 'homepage';
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Settings;
use App\Support\Marketplace\MarketplaceFeeRule;
use Spatie\LaravelSettings\Settings;
class MarketplaceSettings extends Settings
{
public MarketplaceFeeRule $tiktok_shop_platform_commission;
public MarketplaceFeeRule $tiktok_shop_logistics_service_fee;
public MarketplaceFeeRule $tiktok_shop_dynamic_commission;
public MarketplaceFeeRule $tiktok_shop_order_processing_fee;
public MarketplaceFeeRule $tiktok_shop_affiliate;
public MarketplaceFeeRule $tiktok_shop_pre_order_service_fee;
public MarketplaceFeeRule $shopee_admin_fee;
public MarketplaceFeeRule $shopee_program_fee;
public MarketplaceFeeRule $shopee_shipping_savings;
public MarketplaceFeeRule $shopee_premium;
public MarketplaceFeeRule $shopee_service_fee;
public MarketplaceFeeRule $shopee_order_processing_fee;
public MarketplaceFeeRule $shopee_ams_commission_fee;
public MarketplaceFeeRule $shopee_pre_order;
public MarketplaceFeeRule $shopee_live_extra;
public static function group(): string
{
return 'marketplace';
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Settings;
use Spatie\LaravelSettings\Settings;
class SocialMediaSettings extends Settings
{
public ?string $instagram_url;
public ?string $facebook_url;
public ?string $tiktok_url;
public static function group(): string
{
return 'social_media';
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Settings;
use Spatie\LaravelSettings\Settings;
class SystemSettings extends Settings
{
public string $app_name;
public ?string $address;
public ?string $about_app;
public ?string $email;
public ?string $phone;
public static function group(): string
{
return 'system';
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Support\Marketplace;
use Spatie\LaravelData\Data;
class MarketplaceFeeRule extends Data
{
public function __construct(
public string $base = 'per_transaksi',
public string $type = 'persentase',
public float $value = 0.0,
) {}
public static function defaultPercent(float $value): static
{
return new static(
base: 'per_transaksi',
type: 'persentase',
value: $value,
);
}
public function toArray(): array
{
return [
'base' => $this->base,
'type' => $this->type,
'value' => (float) $this->value,
];
}
}

View File

@ -17,8 +17,10 @@
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.14",
"league/flysystem-aws-s3-v3": "^3.0",
"spatie/laravel-data": "^4.23",
"spatie/laravel-medialibrary": "^11.23",
"spatie/laravel-permission": "^8.3",
"spatie/laravel-settings": "^3.9",
"spatie/laravel-sluggable": "^4.0"
},
"require-dev": {

250
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e3f219d587b4456806b7c536eab8b82f",
"content-hash": "5891ce301b1670b38277f3a3c2ac2426",
"packages": [
{
"name": "aws/aws-crt-php",
@ -4679,6 +4679,90 @@
},
"time": "2026-06-29T08:28:30+00:00"
},
{
"name": "spatie/laravel-data",
"version": "4.23.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-data.git",
"reference": "230543769c996e407fec2873930626aed7dd0d3b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-data/zipball/230543769c996e407fec2873930626aed7dd0d3b",
"reference": "230543769c996e407fec2873930626aed7dd0d3b",
"shasum": ""
},
"require": {
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
"php": "^8.1",
"phpdocumentor/reflection-common": "^2.2",
"phpdocumentor/reflection-docblock": "^5.3 || ^6.0",
"phpdocumentor/type-resolver": "^1.7 || ^2.0",
"spatie/laravel-package-tools": "^1.9.0",
"spatie/php-structure-discoverer": "^2.0"
},
"require-dev": {
"fakerphp/faker": "^1.14",
"friendsofphp/php-cs-fixer": "^3.0",
"inertiajs/inertia-laravel": "^2.0|^3.0",
"livewire/livewire": "^3.0|^4.0",
"mockery/mockery": "^1.6",
"nesbot/carbon": "^2.63|^3.0",
"orchestra/testbench": "^8.37.0|^9.16|^10.9|^11.0",
"pestphp/pest": "^2.36|^3.8|^4.3",
"pestphp/pest-plugin-laravel": "^2.4|^3.0|^4.0",
"pestphp/pest-plugin-livewire": "^2.1|^3.0|^4.0",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.1",
"spatie/invade": "^1.0",
"spatie/laravel-typescript-transformer": "^2.5",
"spatie/pest-plugin-snapshots": "^2.1",
"spatie/test-time": "^1.2"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\LaravelData\\LaravelDataServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\LaravelData\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ruben Van Assche",
"email": "ruben@spatie.be",
"role": "Developer"
}
],
"description": "Create unified resources and data transfer objects",
"homepage": "https://github.com/spatie/laravel-data",
"keywords": [
"laravel",
"laravel-data",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-data/issues",
"source": "https://github.com/spatie/laravel-data/tree/4.23.0"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-05-08T14:41:13+00:00"
},
{
"name": "spatie/laravel-medialibrary",
"version": "11.23.3",
@ -4938,6 +5022,91 @@
],
"time": "2026-07-03T15:36:01+00:00"
},
{
"name": "spatie/laravel-settings",
"version": "3.9.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-settings.git",
"reference": "6c1351cf57c4ae96cd2313ae395c6d367dfeee01"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-settings/zipball/6c1351cf57c4ae96cd2313ae395c6d367dfeee01",
"reference": "6c1351cf57c4ae96cd2313ae395c6d367dfeee01",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/database": "^11.0|^12.0|^13.0",
"php": "^8.2",
"phpdocumentor/type-resolver": "^1.5|^2.0",
"spatie/temporary-directory": "^1.3|^2.0"
},
"require-dev": {
"ext-redis": "*",
"mockery/mockery": "^1.4",
"orchestra/testbench": "^9.0|^10.0|^11.0",
"pestphp/pest": "^2.0|^3.0|^4.0",
"pestphp/pest-plugin-laravel": "^2.0|^3.0|^4.0",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan-deprecation-rules": "^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"spatie/laravel-data": "^2.0.0|^4.0.0",
"spatie/pest-plugin-snapshots": "^2.0",
"spatie/phpunit-snapshot-assertions": "^4.2|^5.0",
"spatie/ray": "^1.36"
},
"suggest": {
"spatie/data-transfer-object": "Allows for DTO casting to settings. (deprecated)"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\LaravelSettings\\LaravelSettingsServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\LaravelSettings\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ruben Van Assche",
"email": "ruben@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Store your application settings",
"homepage": "https://github.com/spatie/laravel-settings",
"keywords": [
"laravel-settings",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-settings/issues",
"source": "https://github.com/spatie/laravel-settings/tree/3.9.0"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-05-26T13:18:06+00:00"
},
{
"name": "spatie/laravel-sluggable",
"version": "4.0.3",
@ -5016,6 +5185,85 @@
],
"time": "2026-07-28T13:16:49+00:00"
},
{
"name": "spatie/php-structure-discoverer",
"version": "2.4.4",
"source": {
"type": "git",
"url": "https://github.com/spatie/php-structure-discoverer.git",
"reference": "fa2b7dae8e8a22c0306154c4b052420e054f7e2b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/fa2b7dae8e8a22c0306154c4b052420e054f7e2b",
"reference": "fa2b7dae8e8a22c0306154c4b052420e054f7e2b",
"shasum": ""
},
"require": {
"illuminate/collections": "^11.0|^12.0|^13.0",
"php": "^8.3",
"spatie/laravel-package-tools": "^1.92.7",
"symfony/finder": "^6.0|^7.3.5|^8.0"
},
"require-dev": {
"amphp/parallel": "^2.3.2",
"illuminate/console": "^11.0|^12.0|^13.0",
"nunomaduro/collision": "^7.0|^8.8.3",
"orchestra/testbench": "^9.5|^10.8|^11.0",
"pestphp/pest": "^3.8|^4.0",
"pestphp/pest-plugin-laravel": "^3.2|^4.0",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan-deprecation-rules": "^1.2.1",
"phpstan/phpstan-phpunit": "^1.4.2",
"spatie/laravel-ray": "^1.43.1"
},
"suggest": {
"amphp/parallel": "When you want to use the Parallel discover worker"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\StructureDiscoverer\\StructureDiscovererServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\StructureDiscoverer\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ruben Van Assche",
"email": "ruben@spatie.be",
"role": "Developer"
}
],
"description": "Automatically discover structures within your PHP application",
"homepage": "https://github.com/spatie/php-structure-discoverer",
"keywords": [
"discover",
"laravel",
"php",
"php-structure-discoverer"
],
"support": {
"issues": "https://github.com/spatie/php-structure-discoverer/issues",
"source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.4"
},
"funding": [
{
"url": "https://github.com/LaravelAutoDiscoverer",
"type": "github"
}
],
"time": "2026-06-15T07:14:32+00:00"
},
{
"name": "spatie/temporary-directory",
"version": "2.4.0",

100
config/settings.php Normal file
View File

@ -0,0 +1,100 @@
<?php
return [
/*
* Each settings class used in your application must be registered, you can
* put them (manually) here.
*/
'settings' => [
],
/*
* The path where the settings classes will be created.
*/
'setting_class_path' => app_path('Settings'),
/*
* In these directories settings migrations will be stored and ran when migrating. A settings
* migration created via the make:settings-migration command will be stored in the first path or
* a custom defined path when running the command.
*/
'migrations_paths' => [
database_path('settings'),
],
/*
* When no repository was set for a settings class the following repository
* will be used for loading and saving settings.
*/
'default_repository' => 'database',
/*
* Settings will be stored and loaded from these repositories.
*/
'repositories' => [
'database' => [
'type' => Spatie\LaravelSettings\SettingsRepositories\DatabaseSettingsRepository::class,
'model' => null,
'table' => null,
'connection' => null,
],
'redis' => [
'type' => Spatie\LaravelSettings\SettingsRepositories\RedisSettingsRepository::class,
'connection' => null,
'prefix' => null,
],
],
/*
* The encoder and decoder will determine how settings are stored and
* retrieved in the database. By default, `json_encode` and `json_decode`
* are used.
*/
'encoder' => null,
'decoder' => null,
/*
* The contents of settings classes can be cached through your application,
* settings will be stored within a provided Laravel store and can have an
* additional prefix.
*/
'cache' => [
'enabled' => (bool)env('SETTINGS_CACHE_ENABLED', false),
'store' => null,
'prefix' => null,
'ttl' => null,
/*
* When enabled, uses Laravel's memoized cache driver (requires Laravel 12.9+)
* to keep resolved values in memory during a single request.
*/
'memo' => env('SETTINGS_CACHE_MEMO', false),
],
/*
* These global casts will be automatically used whenever a property within
* your settings class isn't a default PHP type.
*/
'global_casts' => [
DateTimeInterface::class => Spatie\LaravelSettings\SettingsCasts\DateTimeInterfaceCast::class,
DateTimeZone::class => Spatie\LaravelSettings\SettingsCasts\DateTimeZoneCast::class,
// Spatie\DataTransferObject\DataTransferObject::class => Spatie\LaravelSettings\SettingsCasts\DtoCast::class,
Spatie\LaravelData\Data::class => Spatie\LaravelSettings\SettingsCasts\DataCast::class,
],
/*
* The package will look for settings in these paths and automatically
* register them.
*/
'auto_discover_settings' => [
app_path('Settings'),
],
/*
* Automatically discovered settings classes can be cached, so they don't
* need to be searched each time the application boots up.
*/
'discovered_settings_cache_path' => base_path('bootstrap/cache'),
];

View File

@ -0,0 +1,48 @@
<?php
use App\Support\Marketplace\MarketplaceFeeRule;
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('system', function (SettingsBlueprint $blueprint): void {
$blueprint->add('app_name', config('app.name', 'DST Collection'));
$blueprint->add('address', 'Jl. Raya Cipeundeuy - Pabuaran No.909, Pabuaran, Kec. Pabuaran, Kabupaten Subang, Jawa Barat 41262');
$blueprint->add('about_app', '');
$blueprint->add('email', '');
$blueprint->add('phone', '');
$blueprint->add('logo', '');
$blueprint->add('login_cover', '');
});
$this->migrator->inGroup('social_media', function (SettingsBlueprint $blueprint): void {
$blueprint->add('instagram_url', null);
$blueprint->add('facebook_url', null);
$blueprint->add('tiktok_url', null);
});
$this->migrator->inGroup('marketplace', function (SettingsBlueprint $blueprint): void {
// TikTok Shop
$blueprint->add('tiktok_shop_platform_commission', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('tiktok_shop_logistics_service_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('tiktok_shop_dynamic_commission', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('tiktok_shop_order_processing_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('tiktok_shop_affiliate', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('tiktok_shop_pre_order_service_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
// Shopee
$blueprint->add('shopee_admin_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_program_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_shipping_savings', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_premium', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_service_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_order_processing_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_ams_commission_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_pre_order', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_live_extra', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
});
}
};

View File

@ -0,0 +1,15 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('system', function (SettingsBlueprint $blueprint): void {
$blueprint->delete('logo');
$blueprint->delete('login_cover');
});
}
};

View File

@ -0,0 +1,70 @@
<?php
use App\Support\Marketplace\MarketplaceFeeRule;
use Illuminate\Support\Facades\DB;
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('marketplace', function (SettingsBlueprint $blueprint): void {
$oldKeys = [
'tiktok_shop_admin_fee',
'tiktok_shop_transaction_fee',
'tiktok_shop_payment_fee',
'tiktok_shop_affiliate_commission',
'tiktok_shop_shipping_subsidy',
'tiktok_shop_vat_rate',
'shopee_commission_fee',
'shopee_transaction_fee',
'shopee_service_fee',
'shopee_payment_fee',
'shopee_affiliate_commission',
'shopee_shipping_subsidy',
'shopee_voucher_fee',
];
$existing = DB::table('settings')
->where('group', 'marketplace')
->pluck('name')
->toArray();
foreach ($oldKeys as $key) {
if (in_array($key, $existing)) {
$blueprint->delete($key);
}
}
$newFields = [
'tiktok_shop_platform_commission',
'tiktok_shop_logistics_service_fee',
'tiktok_shop_dynamic_commission',
'tiktok_shop_order_processing_fee',
'tiktok_shop_affiliate',
'tiktok_shop_pre_order_service_fee',
'shopee_admin_fee',
'shopee_program_fee',
'shopee_shipping_savings',
'shopee_premium',
'shopee_service_fee',
'shopee_order_processing_fee',
'shopee_ams_commission_fee',
'shopee_pre_order',
'shopee_live_extra',
];
$current = DB::table('settings')
->where('group', 'marketplace')
->pluck('name')
->toArray();
foreach ($newFields as $key) {
if (! in_array($key, $current)) {
$blueprint->add($key, MarketplaceFeeRule::defaultPercent(0.0)->toArray());
}
}
});
}
};

View File

@ -0,0 +1,17 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void {
$blueprint->add('scheduled_check_in_time', '08:00');
$blueprint->add('scheduled_check_out_time', '17:00');
$blueprint->add('late_penalty_amount', 0);
$blueprint->add('absent_penalty_amount', 0);
});
}
};

View File

@ -0,0 +1,89 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('homepage', function (SettingsBlueprint $blueprint): void {
// Hero Section
$blueprint->add('hero_badge', 'Koleksi Segar 2026');
$blueprint->add('hero_title_line1', 'Ekspresikan');
$blueprint->add('hero_title_line2', 'Gaya');
$blueprint->add('hero_title_highlight', 'Segar Anda');
$blueprint->add('hero_description', 'Selamat datang di {app_name}. Temukan keanggunan motif batik modern dan setelan pakaian santai berkualitas premium. Dirancang khusus dengan bahan adem yang menyejukkan aktivitas harian Anda.');
$blueprint->add('hero_cta_primary_text', 'Beli Sekarang');
$blueprint->add('hero_cta_secondary_text', 'Tentang Kami');
$blueprint->add('hero_image_url', 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80');
$blueprint->add('hero_bg_text_left', 'DST');
$blueprint->add('hero_bg_text_right', 'Collection');
// Scroll Indicator
$blueprint->add('scroll_hashtag', '#DSTCollection');
$blueprint->add('scroll_tagline', 'Bahan Adem & Lembut');
// Catalog Section
$blueprint->add('catalog_badge', 'Katalog Eksklusif');
$blueprint->add('catalog_title', 'Koleksi Busana Pilihan');
$blueprint->add('catalog_description', 'Gunakan kategori dan filter di bawah untuk menyesuaikan pencarian busana idaman Anda dengan mudah.');
$blueprint->add('catalog_search_placeholder', 'Cari nama pakaian...');
// Deal Section
$blueprint->add('deal_badge', 'Promo Terbatas');
$blueprint->add('deal_title', 'Penawaran Bulan Ini');
$blueprint->add('deal_description', 'Jangan lewatkan promosi batik dan daster eksklusif kami! Dapatkan potongan harga spesial up to 25% untuk varian daster pilihan dan setelan pakaian modern. Dapatkan kenyamanan ekstra dengan bahan menyejukkan sebelum masa promo habis!');
$blueprint->add('deal_cta_text', 'Jelajahi Produk Diskon');
$blueprint->add('deal_images', [
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1572804013309-59a88b7e92f1?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=800&auto=format&fit=crop&q=80',
]);
// Order Guide Section
$blueprint->add('order_guide_badge', 'Langkah Pemesanan');
$blueprint->add('order_guide_title', 'Cara Melakukan Pemesanan');
$blueprint->add('order_guide_description', 'Sistem pemesanan kami sangat mudah dan terhubung langsung via WhatsApp untuk pelayanan cepat dan personal.');
$blueprint->add('order_steps', [
[
'title' => 'Pilih Produk',
'description' => 'Jelajahi pakaian batik dan daster favorit Anda, lalu ketuk "Lihat Detail" untuk memeriksa ukuran.',
],
[
'title' => 'Pilih Varian & Harga',
'description' => 'Tentukan varian ukuran yang diinginkan dan pilih jenis harga (Eceran, Grosir, Agen, dll.).',
],
[
'title' => 'Masukkan Keranjang',
'description' => 'Masukkan ke Keranjang Belanja untuk menampung seluruh daftar pakaian yang ingin Anda beli.',
],
[
'title' => 'Kirim ke WhatsApp',
'description' => 'Klik tombol kirim pesanan, admin kami akan merespons rincian transfer bank dan pengiriman kurir.',
],
]);
// About Section
$blueprint->add('about_badge', 'Tentang Kami');
$blueprint->add('about_title', 'DST Collection');
$blueprint->add('about_image_url', 'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80');
$blueprint->add('about_features', [
'Kain Rayon Super Tebal & Menyerap Keringat',
'Motif Eksklusif & Tidak Pasaran',
'Dukungan Penuh Layanan Admin Via WhatsApp',
]);
// Contact Section
$blueprint->add('contact_badge', 'Kontak Kami');
$blueprint->add('contact_title', 'Ada Pertanyaan? Hubungi Kami');
$blueprint->add('contact_description', 'Kami sangat senang mendengarkan pertanyaan Anda terkait spesifikasi produk, ketersediaan grosir, atau kemitraan. Hubungi tim admin kami melalui media di bawah.');
$blueprint->add('contact_form_title', 'Kirim Pesan Cepat');
// Footer
$blueprint->add('footer_description', 'Galeri resmi {app_name}. Pilihan busana lokal premium berpotongan modern dengan kenyamanan menyejukkan.');
$blueprint->add('footer_copyright', '&copy; 2026 {app_name} DST Collection. Hak Cipta Dilindungi.');
});
}
};

View File

@ -0,0 +1,32 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('homepage', function (SettingsBlueprint $blueprint): void {
// Delete old deal fields
$blueprint->delete('deal_badge');
$blueprint->delete('deal_title');
$blueprint->delete('deal_description');
$blueprint->delete('deal_cta_text');
$blueprint->delete('deal_images');
// Add new gallery fields
$blueprint->add('gallery_badge', 'Galeri Kami');
$blueprint->add('gallery_title', 'Koleksi Lookbook');
$blueprint->add('gallery_description', 'Intip koleksi lookbook kami untuk inspirasi gaya sehari-hari. Padu padan batik modern dan daster yang nyaman untuk berbagai suasana.');
$blueprint->add('gallery_images', [
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1572804013309-59a88b7e92f1?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1496747611176-843222e1e57c?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1509631179647-0177331693ae?w=800&auto=format&fit=crop&q=80',
]);
});
}
};

View File

@ -88,7 +88,7 @@ const hrItems: NavMenuItem[] = [
];
const sistemItems: NavMenuItem[] = [
{ title: 'Pengaturan', href: '#', icon: Settings },
{ title: 'Pengaturan', href: '/admin/settings', icon: Settings },
{ title: 'Log Aktivitas', href: '#', icon: Activity },
];

View File

@ -9,8 +9,8 @@ type PhoneNumberInputProps = {
className?: string;
};
function formatPhone(value: string): string {
const digits = value.replace(/[^0-9]/g, '');
function formatPhone(value: string | null | undefined): string {
const digits = (value ?? '').replace(/[^0-9]/g, '');
const groups: string[] = [];
for (let i = 0; i < digits.length; i += 4) {
groups.push(digits.slice(i, i + 4));

View File

@ -0,0 +1,89 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@ -0,0 +1,398 @@
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',
},
],
};

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Admin\AdminSettingsController;
use App\Http\Controllers\Admin\Finance\CashAccountController;
use App\Http\Controllers\Admin\Finance\EmployeeAdvanceController;
use App\Http\Controllers\Admin\Finance\ExpenseController;
@ -57,6 +58,15 @@
Route::delete('payroll-adjustments/{payrollAdjustment}', [PayrollAdjustmentController::class, 'destroy'])->name('payroll-adjustments.destroy');
});
Route::prefix('admin/settings')->name('admin.settings.')->group(function () {
Route::get('/', [AdminSettingsController::class, 'index'])->name('index');
Route::put('system', [AdminSettingsController::class, 'updateSystem'])->name('update-system');
Route::put('homepage', [AdminSettingsController::class, 'updateHomepage'])->name('update-homepage');
Route::put('social-media', [AdminSettingsController::class, 'updateSocialMedia'])->name('update-social-media');
Route::put('marketplace', [AdminSettingsController::class, 'updateMarketplace'])->name('update-marketplace');
Route::put('hr', [AdminSettingsController::class, 'updateHR'])->name('update-hr');
});
Route::prefix('admin/hr')->name('admin.hr.')->group(function () {
Route::resource('employees', EmployeeController::class)->except(['show'])->parameters(['employees' => 'user']);
Route::post('employees/{user}/toggle-active', [EmployeeController::class, 'toggleActive'])->name('employees.toggle-active');

View File

@ -0,0 +1,906 @@
<?php
use App\Models\User;
use App\Settings\HRSettings;
use App\Settings\HomepageSettings;
use App\Settings\MarketplaceSettings;
use App\Settings\SocialMediaSettings;
use App\Settings\SystemSettings;
use App\Support\Marketplace\MarketplaceFeeRule;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| AUTHENTICATION
|--------------------------------------------------------------------------
*/
test('guests are redirected to the login page', function () {
$response = $this->get(route('admin.settings.index'));
$response->assertRedirect(route('login'));
});
test('guest cannot update system settings', function () {
$response = $this->put(route('admin.settings.update-system'), [
'app_name' => 'Unauthorized',
]);
$response->assertRedirect(route('login'));
});
test('guest cannot update homepage settings', function () {
$response = $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => 'https://unauthorized.com/image.jpg',
]);
$response->assertRedirect(route('login'));
});
test('guest cannot update social media settings', function () {
$response = $this->put(route('admin.settings.update-social-media'), [
'instagram_url' => 'https://unauthorized.com',
]);
$response->assertRedirect(route('login'));
});
test('guest cannot update marketplace settings', function () {
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 100],
]);
$response->assertRedirect(route('login'));
});
test('guest cannot update hr settings', function () {
$response = $this->put(route('admin.settings.update-hr'), [
'scheduled_check_in_time' => '09:00',
]);
$response->assertRedirect(route('login'));
});
/*
|--------------------------------------------------------------------------
| INDEX PAGE
|--------------------------------------------------------------------------
*/
test('authenticated users can visit the settings page', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('admin.settings.index'));
$response->assertOk();
});
test('settings page renders with correct inertia component', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('admin.settings.index'));
$response->assertInertia(fn (Assert $page) => $page
->component('admin/settings/index')
->has('system')
->has('homepage')
->has('socialMedia')
->has('marketplace')
->has('hr')
);
});
test('settings page contains system data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$system = app(SystemSettings::class);
$response = $this->get(route('admin.settings.index'));
$response->assertInertia(fn (Assert $page) => $page
->where('system.app_name', $system->app_name)
->where('system.email', $system->email)
->where('system.phone', $system->phone)
);
});
test('settings page contains marketplace data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$marketplace = app(MarketplaceSettings::class);
$response = $this->get(route('admin.settings.index'));
$response->assertInertia(fn (Assert $page) => $page
->where('marketplace.tiktok_shop.platform_commission.base', $marketplace->tiktok_shop_platform_commission->base)
->where('marketplace.tiktok_shop.platform_commission.type', $marketplace->tiktok_shop_platform_commission->type)
->where('marketplace.tiktok_shop.platform_commission.value', json_decode(json_encode($marketplace->tiktok_shop_platform_commission->value)))
->where('marketplace.shopee.admin_fee.base', $marketplace->shopee_admin_fee->base)
);
});
test('settings page contains hr data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$hr = app(HRSettings::class);
$response = $this->get(route('admin.settings.index'));
$response->assertInertia(fn (Assert $page) => $page
->where('hr.scheduled_check_in_time', $hr->scheduled_check_in_time)
->where('hr.scheduled_check_out_time', $hr->scheduled_check_out_time)
->where('hr.late_penalty_amount', $hr->late_penalty_amount)
->where('hr.absent_penalty_amount', $hr->absent_penalty_amount)
);
});
/*
|--------------------------------------------------------------------------
| UPDATE SYSTEM
|--------------------------------------------------------------------------
*/
test('user can update system settings with valid data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-system'), [
'app_name' => 'DST Collection Updated',
'about_app' => 'Tentang DST Collection',
'address' => 'Jl. Test No. 123',
'email' => 'test@dst.com',
'phone' => '081234567890',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.settings.index'));
$settings = app(SystemSettings::class);
expect($settings->app_name)->toBe('DST Collection Updated');
expect($settings->about_app)->toBe('Tentang DST Collection');
expect($settings->address)->toBe('Jl. Test No. 123');
expect($settings->email)->toBe('test@dst.com');
expect($settings->phone)->toBe('081234567890');
});
test('system app_name is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-system'), [
'app_name' => '',
]);
$response->assertSessionHasErrors('app_name');
});
test('system email must be valid email format', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-system'), [
'app_name' => 'DST',
'email' => 'not-an-email',
]);
$response->assertSessionHasErrors('email');
});
test('system app_name must not exceed 255 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-system'), [
'app_name' => str_repeat('a', 256),
]);
$response->assertSessionHasErrors('app_name');
});
test('system about_app must not exceed 2000 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-system'), [
'app_name' => 'DST',
'about_app' => str_repeat('a', 2001),
]);
$response->assertSessionHasErrors('about_app');
});
test('system address must not exceed 500 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-system'), [
'app_name' => 'DST',
'address' => str_repeat('a', 501),
]);
$response->assertSessionHasErrors('address');
});
test('system phone must not exceed 20 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-system'), [
'app_name' => 'DST',
'phone' => str_repeat('1', 21),
]);
$response->assertSessionHasErrors('phone');
});
test('system nullable fields can be empty', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-system'), [
'app_name' => 'DST',
'about_app' => '',
'address' => '',
'email' => '',
'phone' => '',
]);
$response->assertSessionHasNoErrors();
});
/*
|--------------------------------------------------------------------------
| UPDATE HOMEPAGE
|--------------------------------------------------------------------------
*/
test('user can update homepage settings with valid data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => 'https://example.com/hero.jpg',
'about_image_url' => 'https://example.com/about.jpg',
'gallery_images' => ['https://example.com/g1.jpg', 'https://example.com/g2.jpg'],
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.settings.index'));
$settings = app(HomepageSettings::class);
expect($settings->hero_image_url)->toBe('https://example.com/hero.jpg');
expect($settings->about_image_url)->toBe('https://example.com/about.jpg');
expect($settings->gallery_images)->toBe(['https://example.com/g1.jpg', 'https://example.com/g2.jpg']);
});
test('homepage fields are nullable', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => null,
'about_image_url' => null,
]);
$response->assertSessionHasNoErrors();
});
test('homepage hero_image_url must not exceed 2000 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => str_repeat('a', 2001),
]);
$response->assertSessionHasErrors('hero_image_url');
});
test('homepage gallery_images must be array', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [
'gallery_images' => 'not-an-array',
]);
$response->assertSessionHasErrors('gallery_images');
});
test('homepage gallery_images items must not exceed 2000 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [
'gallery_images' => [str_repeat('a', 2001)],
]);
$response->assertSessionHasErrors('gallery_images.0');
});
/*
|--------------------------------------------------------------------------
| UPDATE SOCIAL MEDIA
|--------------------------------------------------------------------------
*/
test('user can update social media settings with valid data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-social-media'), [
'instagram_url' => 'https://instagram.com/dst',
'facebook_url' => 'https://facebook.com/dst',
'tiktok_url' => 'https://tiktok.com/@dst',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.settings.index'));
$settings = app(SocialMediaSettings::class);
expect($settings->instagram_url)->toBe('https://instagram.com/dst');
expect($settings->facebook_url)->toBe('https://facebook.com/dst');
expect($settings->tiktok_url)->toBe('https://tiktok.com/@dst');
});
test('social media fields are nullable', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-social-media'), [
'instagram_url' => null,
'facebook_url' => null,
'tiktok_url' => null,
]);
$response->assertSessionHasNoErrors();
});
test('social media instagram_url must not exceed 500 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-social-media'), [
'instagram_url' => str_repeat('a', 501),
]);
$response->assertSessionHasErrors('instagram_url');
});
test('social media facebook_url must not exceed 500 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-social-media'), [
'facebook_url' => str_repeat('a', 501),
]);
$response->assertSessionHasErrors('facebook_url');
});
test('social media tiktok_url must not exceed 500 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-social-media'), [
'tiktok_url' => str_repeat('a', 501),
]);
$response->assertSessionHasErrors('tiktok_url');
});
/*
|--------------------------------------------------------------------------
| UPDATE MARKETPLACE
|--------------------------------------------------------------------------
*/
test('user can update marketplace settings with valid data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 5000];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.settings.index'));
$settings = app(MarketplaceSettings::class);
expect($settings->tiktok_shop_platform_commission->base)->toBe('per_transaksi');
expect($settings->tiktok_shop_platform_commission->type)->toBe('flat');
expect($settings->tiktok_shop_platform_commission->value)->toBe(5000.0);
expect($settings->shopee_admin_fee->base)->toBe('per_transaksi');
expect($settings->shopee_admin_fee->type)->toBe('flat');
expect($settings->shopee_admin_fee->value)->toBe(5000.0);
});
test('marketplace accepts percentage type', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'per_produk', 'type' => 'persentase', 'value' => 2.5];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
]);
$response->assertSessionHasNoErrors();
$settings = app(MarketplaceSettings::class);
expect($settings->tiktok_shop_platform_commission->base)->toBe('per_produk');
expect($settings->tiktok_shop_platform_commission->type)->toBe('persentase');
expect($settings->tiktok_shop_platform_commission->value)->toBe(2.5);
});
test('marketplace base field must be valid', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'invalid', 'type' => 'flat', 'value' => 0];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
]);
$response->assertSessionHasErrors('tiktok_shop_platform_commission.base');
});
test('marketplace type field must be valid', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'per_transaksi', 'type' => 'invalid', 'value' => 0];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
]);
$response->assertSessionHasErrors('tiktok_shop_platform_commission.type');
});
test('marketplace value must be numeric', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 'not-a-number'];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
]);
$response->assertSessionHasErrors('tiktok_shop_platform_commission.value');
});
test('marketplace value must be at least 0', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'per_transaksi', 'type' => 'flat', 'value' => -1];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
]);
$response->assertSessionHasErrors('tiktok_shop_platform_commission.value');
});
test('marketplace fee rule array is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => 'not-an-array',
'tiktok_shop_logistics_service_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'tiktok_shop_dynamic_commission' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'tiktok_shop_order_processing_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'tiktok_shop_affiliate' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'tiktok_shop_pre_order_service_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_admin_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_program_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_shipping_savings' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_premium' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_service_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_order_processing_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_ams_commission_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_pre_order' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_live_extra' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
]);
$response->assertSessionHasErrors('tiktok_shop_platform_commission');
});
test('marketplace each fee key must be provided', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
// Missing other required keys
]);
$response->assertSessionHasErrors([
'tiktok_shop_logistics_service_fee',
'tiktok_shop_dynamic_commission',
'tiktok_shop_order_processing_fee',
'tiktok_shop_affiliate',
'tiktok_shop_pre_order_service_fee',
'shopee_admin_fee',
'shopee_program_fee',
'shopee_shipping_savings',
'shopee_premium',
'shopee_service_fee',
'shopee_order_processing_fee',
'shopee_ams_commission_fee',
'shopee_pre_order',
'shopee_live_extra',
]);
});
/*
|--------------------------------------------------------------------------
| UPDATE HR
|--------------------------------------------------------------------------
*/
test('user can update hr settings with valid data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-hr'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => 10000,
'absent_penalty_amount' => 50000,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.settings.index'));
$settings = app(HRSettings::class);
expect($settings->scheduled_check_in_time)->toBe('09:00');
expect($settings->scheduled_check_out_time)->toBe('18:00');
expect($settings->late_penalty_amount)->toBe(10000);
expect($settings->absent_penalty_amount)->toBe(50000);
});
test('hr scheduled_check_in_time is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-hr'), [
'scheduled_check_in_time' => '',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => 0,
'absent_penalty_amount' => 0,
]);
$response->assertSessionHasErrors('scheduled_check_in_time');
});
test('hr scheduled_check_out_time is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-hr'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '',
'late_penalty_amount' => 0,
'absent_penalty_amount' => 0,
]);
$response->assertSessionHasErrors('scheduled_check_out_time');
});
test('hr late_penalty_amount is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-hr'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => '',
'absent_penalty_amount' => 0,
]);
$response->assertSessionHasErrors('late_penalty_amount');
});
test('hr absent_penalty_amount is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-hr'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => 0,
'absent_penalty_amount' => '',
]);
$response->assertSessionHasErrors('absent_penalty_amount');
});
test('hr penalty amounts must be at least 0', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-hr'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => -1,
'absent_penalty_amount' => -1,
]);
$response->assertSessionHasErrors(['late_penalty_amount', 'absent_penalty_amount']);
});
test('hr penalty amounts must be integers', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-hr'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => 1000.50,
'absent_penalty_amount' => 2000.75,
]);
$response->assertSessionHasErrors(['late_penalty_amount', 'absent_penalty_amount']);
});
test('hr time fields must not exceed 5 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-hr'), [
'scheduled_check_in_time' => '09:000',
'scheduled_check_out_time' => '18:000',
'late_penalty_amount' => 0,
'absent_penalty_amount' => 0,
]);
$response->assertSessionHasErrors(['scheduled_check_in_time', 'scheduled_check_out_time']);
});
/*
|--------------------------------------------------------------------------
| EDGE CASES & DATA INTEGRITY
|--------------------------------------------------------------------------
*/
test('system settings default values exist', function () {
$settings = app(SystemSettings::class);
expect($settings->app_name)->not->toBeEmpty();
expect($settings->about_app)->not->toBeNull();
expect($settings->address)->not->toBeNull();
expect($settings->email)->not->toBeNull();
expect($settings->phone)->not->toBeNull();
});
test('hr settings default values exist', function () {
$settings = app(HRSettings::class);
expect($settings->scheduled_check_in_time)->not->toBeEmpty();
expect($settings->scheduled_check_out_time)->not->toBeEmpty();
expect($settings->late_penalty_amount)->toBeInt();
expect($settings->absent_penalty_amount)->toBeInt();
});
test('marketplace settings default values are MarketplaceFeeRule', function () {
$settings = app(MarketplaceSettings::class);
expect($settings->tiktok_shop_platform_commission)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->tiktok_shop_logistics_service_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->tiktok_shop_dynamic_commission)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->tiktok_shop_order_processing_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->tiktok_shop_affiliate)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->tiktok_shop_pre_order_service_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_admin_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_program_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_shipping_savings)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_premium)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_service_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_order_processing_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_ams_commission_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_pre_order)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_live_extra)->toBeInstanceOf(MarketplaceFeeRule::class);
});
test('marketplace fee rule to array contains correct keys', function () {
$rule = MarketplaceFeeRule::defaultPercent(0.0);
$array = $rule->toArray();
expect($array)->toHaveKeys(['base', 'type', 'value']);
expect($array['base'])->toBe('per_transaksi');
expect($array['type'])->toBe('persentase');
expect($array['value'])->toBe(0.0);
});
test('updating one setting group does not affect others', function () {
$user = User::factory()->create();
$this->actingAs($user);
$systemBefore = app(SystemSettings::class);
$hrBefore = app(HRSettings::class);
$this->put(route('admin.settings.update-system'), [
'app_name' => 'Changed Name',
'about_app' => $systemBefore->about_app,
'address' => $systemBefore->address,
'email' => $systemBefore->email,
'phone' => $systemBefore->phone,
]);
$systemAfter = app(SystemSettings::class);
$hrAfter = app(HRSettings::class);
expect($systemAfter->app_name)->toBe('Changed Name');
expect($hrAfter->scheduled_check_in_time)->toBe($hrBefore->scheduled_check_in_time);
expect($hrAfter->late_penalty_amount)->toBe($hrBefore->late_penalty_amount);
});
/*
|--------------------------------------------------------------------------
| REALISTIC USER SCENARIOS
|--------------------------------------------------------------------------
*/
test('user updates all settings groups in sequence', function () {
$user = User::factory()->create();
$this->actingAs($user);
// Update system
$this->put(route('admin.settings.update-system'), [
'app_name' => 'DST Updated',
'about_app' => 'About Updated',
'address' => 'Address Updated',
'email' => 'updated@dst.com',
'phone' => '089999999999',
])->assertSessionHasNoErrors();
// Update homepage
$this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => 'https://example.com/new-hero.jpg',
'about_image_url' => 'https://example.com/new-about.jpg',
])->assertSessionHasNoErrors();
// Update social media
$this->put(route('admin.settings.update-social-media'), [
'instagram_url' => 'https://instagram.com/newdst',
'facebook_url' => 'https://facebook.com/newdst',
'tiktok_url' => 'https://tiktok.com/@newdst',
])->assertSessionHasNoErrors();
// Update marketplace
$feeRule = ['base' => 'per_transaksi', 'type' => 'persentase', 'value' => 3.0];
$this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
])->assertSessionHasNoErrors();
// Update HR
$this->put(route('admin.settings.update-hr'), [
'scheduled_check_in_time' => '08:30',
'scheduled_check_out_time' => '17:30',
'late_penalty_amount' => 15000,
'absent_penalty_amount' => 75000,
])->assertSessionHasNoErrors();
// Verify all settings persisted
$system = app(SystemSettings::class);
$homepage = app(HomepageSettings::class);
$socialMedia = app(SocialMediaSettings::class);
$marketplace = app(MarketplaceSettings::class);
$hr = app(HRSettings::class);
expect($system->app_name)->toBe('DST Updated');
expect($homepage->hero_image_url)->toBe('https://example.com/new-hero.jpg');
expect($socialMedia->instagram_url)->toBe('https://instagram.com/newdst');
expect($marketplace->tiktok_shop_platform_commission->value)->toBe(3.0);
expect($hr->scheduled_check_in_time)->toBe('08:30');
expect($hr->late_penalty_amount)->toBe(15000);
});