feat: implement general settings module for application configuration and branding management
This commit is contained in:
parent
959c01b6bc
commit
481ec6a3f6
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\System;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\System\GeneralSettingRequest;
|
||||
use App\Models\GeneralSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GeneralSettingController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/system/settings/index', [
|
||||
'setting' => GeneralSetting::first(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(GeneralSettingRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$setting = GeneralSetting::first();
|
||||
if ($setting) {
|
||||
$setting->update($validated);
|
||||
} else {
|
||||
$setting = GeneralSetting::create($validated);
|
||||
}
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$setting->addMediaFromRequest('logo')->toMediaCollection('logo');
|
||||
}
|
||||
|
||||
if ($request->hasFile('icon')) {
|
||||
$setting->addMediaFromRequest('icon')->toMediaCollection('icon');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Pengaturan berhasil diperbarui');
|
||||
}
|
||||
}
|
||||
37
app/Http/Requests/Admin/System/GeneralSettingRequest.php
Normal file
37
app/Http/Requests/Admin/System/GeneralSettingRequest.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\System;
|
||||
|
||||
use App\Models\GeneralSetting;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GeneralSettingRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$settingExists = GeneralSetting::exists();
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['required', 'string'],
|
||||
'address' => ['required', 'string'],
|
||||
'phone' => ['required', 'string', 'max:20'],
|
||||
'logo' => [$settingExists ? 'nullable' : 'required', 'image', 'max:2048'],
|
||||
'icon' => [$settingExists ? 'nullable' : 'required', 'image', 'max:1024'],
|
||||
];
|
||||
}
|
||||
}
|
||||
91
app/Models/GeneralSetting.php
Normal file
91
app/Models/GeneralSetting.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['logo_url', 'icon_url'])]
|
||||
class GeneralSetting extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory, InteractsWithMedia, LogsActivity;
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('logo')
|
||||
->singleFile();
|
||||
|
||||
$this->addMediaCollection('icon')
|
||||
->singleFile();
|
||||
}
|
||||
|
||||
protected function logoUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->getFirstMediaUrl('logo') ?: null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function iconUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->getFirstMediaUrl('icon') ?: null,
|
||||
);
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['name', 'description', 'address', 'phone'])
|
||||
->logOnlyDirty()
|
||||
->useLogName('Pengaturan Umum');
|
||||
}
|
||||
|
||||
public function tapActivity(Activity $activity, string $eventName)
|
||||
{
|
||||
$activity->description = match ($eventName) {
|
||||
'created' => 'TAMBAH',
|
||||
'updated' => 'UBAH',
|
||||
'deleted' => 'HAPUS',
|
||||
default => $activity->description,
|
||||
};
|
||||
|
||||
if (isset($activity->properties['attributes'])) {
|
||||
$attributeMap = [
|
||||
'name' => 'Nama Aplikasi',
|
||||
'description' => 'Deskripsi',
|
||||
'address' => 'Alamat',
|
||||
'phone' => 'No. Telepon',
|
||||
];
|
||||
|
||||
$properties = $activity->properties->toArray();
|
||||
|
||||
$localizeValues = function ($attrs) use ($attributeMap) {
|
||||
$newAttrs = [];
|
||||
foreach ($attrs as $key => $value) {
|
||||
$label = $attributeMap[$key] ?? $key;
|
||||
$newAttrs[$label] = $value;
|
||||
}
|
||||
|
||||
return $newAttrs;
|
||||
};
|
||||
|
||||
$properties['attributes'] = $localizeValues($properties['attributes']);
|
||||
|
||||
if (isset($properties['old'])) {
|
||||
$properties['old'] = $localizeValues($properties['old']);
|
||||
}
|
||||
|
||||
$activity->properties = collect($properties);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('general_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->text('address')->nullable();
|
||||
$table->string('phone')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('general_settings');
|
||||
}
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Boxes, DollarSign, History, LayoutGrid, List, ScrollText, ShoppingCart, User, Wallet } from 'lucide-react';
|
||||
import { Boxes, DollarSign, History, LayoutGrid, List, ScrollText, Settings, ShoppingCart, User, Wallet } from 'lucide-react';
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import {
|
||||
@ -69,6 +69,11 @@ const financeNavItems: NavItem[] = [
|
||||
];
|
||||
|
||||
const systemNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Pengaturan',
|
||||
href: system.settings.index().url,
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
title: 'Logs',
|
||||
href: system.logs.index().url,
|
||||
|
||||
250
resources/js/pages/admin/system/settings/index.tsx
Normal file
250
resources/js/pages/admin/system/settings/index.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { GeneralSetting } from '@/types';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import systemRoutes from '@/routes/system';
|
||||
import { ImagePlus, X } from 'lucide-react';
|
||||
|
||||
export default function SettingIndex({ setting }: { setting: GeneralSetting | null }) {
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
name: setting?.name || '',
|
||||
description: setting?.description || '',
|
||||
address: setting?.address || '',
|
||||
phone: setting?.phone || '',
|
||||
logo: null as File | null,
|
||||
icon: null as File | null,
|
||||
});
|
||||
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const iconInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(setting?.logo_url || null);
|
||||
const [iconPreview, setIconPreview] = useState<string | null>(setting?.icon_url || null);
|
||||
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setData('logo', file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setLogoPreview(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const removeLogo = () => {
|
||||
setData('logo', null);
|
||||
setLogoPreview(null);
|
||||
if (logoInputRef.current) logoInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setData('icon', file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setIconPreview(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const removeIcon = () => {
|
||||
setData('icon', null);
|
||||
setIconPreview(null);
|
||||
if (iconInputRef.current) iconInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(systemRoutes.settings.update().url, {
|
||||
forceFormData: true,
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<Head title="Pengaturan Umum" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Pengaturan Umum</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Informasi Aplikasi</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Field>
|
||||
<Label htmlFor="name" required>Nama Aplikasi</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
autoComplete='off'
|
||||
placeholder='Contoh: VN Grup Dress'
|
||||
maxLength={100}
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Label htmlFor="description" required>Deskripsi</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={data.description || ''}
|
||||
onChange={(e) => setData('description', e.target.value)}
|
||||
placeholder="Deskripsi singkat aplikasi"
|
||||
rows={4}
|
||||
/>
|
||||
{errors.description && <p className="text-xs text-red-500 mt-1">{errors.description}</p>}
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Field>
|
||||
<Label htmlFor="phone" required>No. Telepon</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={data.phone || ''}
|
||||
onChange={(e) => setData('phone', e.target.value)}
|
||||
placeholder="0812xxxx"
|
||||
/>
|
||||
{errors.phone && <p className="text-xs text-red-500">{errors.phone}</p>}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<Label htmlFor="address" required>Alamat</Label>
|
||||
<Textarea
|
||||
id="address"
|
||||
value={data.address || ''}
|
||||
onChange={(e) => setData('address', e.target.value)}
|
||||
placeholder="Alamat lengkap toko"
|
||||
rows={3}
|
||||
/>
|
||||
{errors.address && <p className="text-xs text-red-500 mt-1">{errors.address}</p>}
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Logo Aplikasi</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{logoPreview ? (
|
||||
<div className="relative inline-block w-full">
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo preview"
|
||||
className="w-full object-cover rounded-xl border shadow"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeLogo}
|
||||
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center shadow-md transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logoInputRef.current?.click()}
|
||||
className="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-border rounded-xl hover:border-primary hover:bg-primary/5 transition-all cursor-pointer group"
|
||||
>
|
||||
<ImagePlus className="w-10 h-10 text-muted-foreground group-hover:text-primary transition-colors mb-2" />
|
||||
<span className="text-sm text-muted-foreground group-hover:text-primary transition-colors font-medium">Klik untuk upload logo</span>
|
||||
<span className="text-xs text-muted-foreground mt-1">PNG, JPG, WEBP — maks. 2MB</span>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
{errors.logo && <p className="text-xs text-red-500 mt-2">{errors.logo}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Favicon / Icon</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{iconPreview ? (
|
||||
<div className="relative inline-block w-full flex justify-center">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={iconPreview}
|
||||
alt="Icon preview"
|
||||
className="size-24 object-contain rounded-xl border shadow p-2"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeIcon}
|
||||
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center shadow-md transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => iconInputRef.current?.click()}
|
||||
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-border rounded-xl hover:border-primary hover:bg-primary/5 transition-all cursor-pointer group"
|
||||
>
|
||||
<ImagePlus className="w-8 h-8 text-muted-foreground group-hover:text-primary transition-colors mb-2" />
|
||||
<span className="text-sm text-muted-foreground group-hover:text-primary transition-colors font-medium">Klik untuk upload icon</span>
|
||||
<span className="text-xs text-muted-foreground mt-1">ICO, PNG — maks. 1MB</span>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={iconInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleIconChange}
|
||||
/>
|
||||
{errors.icon && <p className="text-xs text-red-500 mt-2">{errors.icon}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-4 p-4 rounded-xl bg-primary/5 border border-primary/10 shadow-sm">
|
||||
<Button type="submit" className="w-full" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan Perubahan'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SettingIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Sistem',
|
||||
},
|
||||
{
|
||||
title: 'Pengaturan Umum',
|
||||
}
|
||||
],
|
||||
};
|
||||
11
resources/js/types/general-setting.ts
Normal file
11
resources/js/types/general-setting.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface GeneralSetting {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
logo_url: string | null;
|
||||
icon_url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -6,3 +6,5 @@ export type * from './product';
|
||||
export type * from './expense';
|
||||
export type * from './payroll';
|
||||
export type * from './purchase';
|
||||
export type * from './general-setting';
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\System\ActivityLogController;
|
||||
use App\Http\Controllers\Admin\System\GeneralSettingController;
|
||||
use App\Http\Controllers\Admin\System\LogController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@ -8,5 +9,10 @@
|
||||
Route::prefix('admin/system')->group(function () {
|
||||
Route::get('logs', [LogController::class, 'index'])->name('system.logs.index');
|
||||
Route::get('activity-logs', [ActivityLogController::class, 'index'])->name('system.activity-logs.index');
|
||||
|
||||
Route::controller(GeneralSettingController::class)->prefix('settings')->group(function () {
|
||||
Route::get('/', 'index')->name('system.settings.index');
|
||||
Route::post('/', 'update')->name('system.settings.update');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user