Compare commits

...

10 Commits

Author SHA1 Message Date
Yoga Pangestu
482ce9f3b8 feat: implement order cancellation logic to restore stock while preserving existing order items
Some checks failed
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
linter / quality (push) Has been cancelled
2026-05-02 19:29:11 +07:00
Yoga Pangestu
30fcfb4f20 fix: exclude soft-deleted records and validate relations in analytics queries 2026-05-02 18:07:24 +07:00
Yoga Pangestu
f8529b2459 test: add validation tests for insufficient stock on purchase and order operations 2026-05-02 18:00:35 +07:00
Yoga Pangestu
23cc07596c feat: implement stock validation on purchase removal and add global toast error handling for flash messages 2026-05-02 17:57:32 +07:00
Yoga Pangestu
2d5d0b8eb1 refactor: remove prefetch attribute from sidebar and navigation links 2026-05-02 17:19:45 +07:00
Yoga Pangestu
3fcdaa7475 refactor: migrate general settings to spatie/laravel-settings and rename properties for consistency 2026-05-02 01:05:09 +07:00
Yoga Pangestu
044dffefa8 feat: enforce roles requirement in user management validation and UI 2026-05-02 00:25:30 +07:00
Yoga Pangestu
b059d5a260 feat: restrict Developer role visibility and management in user modules 2026-05-02 00:21:55 +07:00
Yoga Pangestu
85e04dbf6a feat: add order detail page and link to order management table 2026-05-02 00:13:19 +07:00
Yoga Pangestu
43d399f07a style: update CSS root color palette and add custom selection styling 2026-04-30 23:34:53 +07:00
52 changed files with 1256 additions and 503 deletions

View File

@ -36,7 +36,7 @@ public function create(): Response
->whereNull('order_id')
->latest()
->get(),
'orderStatus' => OrderStatus::options(),
'orderStatus' => collect(OrderStatus::options())->filter(fn ($opt) => $opt['value'] !== OrderStatus::CANCELLED->value)->values()->toArray(),
'orderChannels' => OrderChannel::options(),
'paymentMethods' => PaymentMethod::options(),
'priceTypes' => PriceType::options(),
@ -79,7 +79,11 @@ public function store(OrderRequest $request): RedirectResponse
'price_type' => $item['price_type'],
]);
Product::find($item['product_id'])->decrement('stock', $item['qty']);
$product = Product::find($item['product_id']);
if ($product->stock < $item['qty']) {
throw new \InvalidArgumentException("Stok produk {$product->name} tidak mencukupi. Sisa stok: {$product->stock}");
}
$product->decrement('stock', $item['qty']);
}
OrderItem::where('user_id', auth()->id())
@ -88,6 +92,8 @@ public function store(OrderRequest $request): RedirectResponse
});
return redirect()->route('order.index')->with('success', 'Pesanan berhasil disimpan');
} catch (\InvalidArgumentException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
} catch (\Throwable $e) {
LogHelper::logException($e, 'Failed to store order', [
'customer_name' => $validated['customer_name'],
@ -115,27 +121,66 @@ public function edit(Order $order): Response
]);
}
public function show(Order $order): Response
{
$order->load(['items.product', 'user']);
return Inertia::render('admin/manage/order/show', [
'order' => $order,
]);
}
public function update(OrderRequest $request, Order $order): RedirectResponse
{
$validated = $request->validated();
try {
DB::transaction(function () use ($validated, $order) {
$totalItemsPrice = collect($validated['items'])->sum(fn ($item) => $item['qty'] * $item['price']);
$cogs = collect($validated['items'])->sum(function ($item) {
$isCancelled = $validated['order_status'] === OrderStatus::CANCELLED->value;
$wasCancelled = ($order->order_status instanceof OrderStatus ? $order->order_status->value : $order->order_status) === OrderStatus::CANCELLED->value;
// 1. Jika status SEKARANG adalah GAGAL
if ($isCancelled) {
// Kembalikan stok jika sebelumnya TIDAK gagal
if (! $wasCancelled) {
foreach ($order->items as $item) {
$item->product->increment('stock', $item->qty);
}
}
// Update metadata saja, jangan hapus item (sesuai request)
$order->update([
'customer_name' => $validated['customer_name'],
'discount' => $validated['discount'],
'payment' => $validated['payment'],
'payment_method' => $validated['payment_method'],
'order_status' => $validated['order_status'],
'order_channel' => $validated['order_channel'],
]);
return;
}
// 2. Jika status SEKARANG TIDAK GAGAL
// Kembalikan stok lama jika sebelumnya TIDAK gagal (karena kita akan mengganti item)
if (! $wasCancelled) {
foreach ($order->items as $item) {
$item->product->increment('stock', $item->qty);
}
}
// Proses update item (hapus lama, buat baru)
$order->items()->delete();
$items = $validated['items'] ?? [];
$totalItemsPrice = collect($items)->sum(fn ($item) => $item['qty'] * $item['price']);
$cogs = collect($items)->sum(function ($item) {
$product = Product::find($item['product_id']);
$purchasePrice = $product->prices()->where('price_type', PriceType::PURCHASE)->first()?->price ?? 0;
return $purchasePrice * $item['qty'];
});
// Restore stock for old items
foreach ($order->items as $item) {
$item->product->increment('stock', $item->qty);
}
$order->items()->delete();
$order->update([
'customer_name' => $validated['customer_name'],
'cogs' => $cogs,
@ -147,7 +192,7 @@ public function update(OrderRequest $request, Order $order): RedirectResponse
'order_channel' => $validated['order_channel'],
]);
foreach ($validated['items'] as $item) {
foreach ($items as $item) {
$order->items()->create([
'user_id' => auth()->id(),
'product_id' => $item['product_id'],
@ -157,11 +202,18 @@ public function update(OrderRequest $request, Order $order): RedirectResponse
'price_type' => $item['price_type'],
]);
Product::find($item['product_id'])->decrement('stock', $item['qty']);
// Potong stok (karena status sekarang bukan gagal)
$product = Product::find($item['product_id']);
if ($product->stock < $item['qty']) {
throw new \InvalidArgumentException("Stok produk {$product->name} tidak mencukupi. Sisa stok: {$product->stock}");
}
$product->decrement('stock', $item['qty']);
}
});
return redirect()->route('order.index')->with('success', 'Pesanan berhasil diperbarui');
} catch (\InvalidArgumentException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
} catch (\Throwable $e) {
LogHelper::logException($e, 'Failed to update order', [
'order_id' => $order->id,

View File

@ -106,6 +106,9 @@ public function update(PurchaseRequest $request, Purchase $purchase): RedirectRe
]);
foreach ($purchase->items as $item) {
if ($item->product->stock < $item->quantity) {
throw new \InvalidArgumentException("Stok produk {$item->product->name} tidak mencukupi untuk dibatalkan. Sisa stok: {$item->product->stock}");
}
$item->product->decrement('stock', $item->quantity);
}
@ -125,6 +128,8 @@ public function update(PurchaseRequest $request, Purchase $purchase): RedirectRe
});
return redirect()->route('purchase.index')->with('success', 'Data berhasil diperbarui');
} catch (\InvalidArgumentException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
} catch (\Throwable $e) {
LogHelper::logException($e, 'Failed to update purchase', [
'purchase_id' => $purchase->id,
@ -143,12 +148,17 @@ public function destroy(Purchase $purchase): RedirectResponse
try {
DB::transaction(function () use ($purchase) {
foreach ($purchase->items as $item) {
if ($item->product->stock < $item->quantity) {
throw new \InvalidArgumentException("Stok produk {$item->product->name} tidak mencukupi untuk dihapus. Sisa stok: {$item->product->stock}");
}
$item->product->decrement('stock', $item->quantity);
}
$purchase->delete();
});
return redirect()->back()->with('success', 'Data berhasil dihapus');
} catch (\InvalidArgumentException $e) {
return redirect()->back()->with('error', $e->getMessage());
} catch (\Throwable $e) {
LogHelper::logException($e, 'Failed to delete purchase', [
'purchase_id' => $purchase->id,
@ -164,9 +174,12 @@ public function bulkDestroy(Request $request): RedirectResponse
try {
DB::transaction(function () use ($ids) {
$purchases = Purchase::with('items')->whereIn('id', $ids)->get();
$purchases = Purchase::with('items.product')->whereIn('id', $ids)->get();
foreach ($purchases as $purchase) {
foreach ($purchase->items as $item) {
if ($item->product->stock < $item->quantity) {
throw new \InvalidArgumentException("Stok produk {$item->product->name} tidak mencukupi untuk dihapus. Sisa stok: {$item->product->stock}");
}
$item->product->decrement('stock', $item->quantity);
}
$purchase->delete();
@ -174,6 +187,8 @@ public function bulkDestroy(Request $request): RedirectResponse
});
return redirect()->back()->with('success', 'Data terpilih berhasil dihapus');
} catch (\InvalidArgumentException $e) {
return redirect()->back()->with('error', $e->getMessage());
} catch (\Throwable $e) {
LogHelper::logException($e, 'Failed to bulk delete purchases', [
'ids' => $ids,

View File

@ -19,8 +19,7 @@ class UserController extends Controller
public function index(): Response
{
return Inertia::render('admin/master/user/index', [
'users' => User::with(['profile', 'roles'])->latest()->paginate(10),
'roles' => Role::all(),
'users' => User::with(['profile', 'roles'])->whereDoesntHave('roles', fn ($query) => $query->where('name', 'Developer'))->latest()->paginate(10),
'defaultPassword' => config('auth.password_default', 'password'),
]);
}
@ -28,7 +27,7 @@ public function index(): Response
public function create(): Response
{
return Inertia::render('admin/master/user/create', [
'roles' => Role::all(),
'roles' => Role::where('name', '!=', 'Developer')->get(),
]);
}
@ -41,7 +40,7 @@ public function store(UserRequest $request): RedirectResponse
$user = User::create([
'username' => $validated['username'],
'email' => $validated['email'],
'password' => Hash::make(config('auth.password_default')),
'password' => Hash::make(config('auth.password_default', 'password')),
]);
$user->profile()->create([
@ -79,7 +78,7 @@ public function edit(User $user): Response
return Inertia::render('admin/master/user/edit', [
'user' => $user,
'roles' => Role::all(),
'roles' => Role::where('name', '!=', 'Developer')->get(),
]);
}

View File

@ -4,8 +4,9 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\System\GeneralSettingRequest;
use App\Models\GeneralSetting;
use App\Settings\GeneralSettings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Inertia\Response;
@ -13,38 +14,48 @@ class GeneralSettingController extends Controller
{
public function index(): Response
{
return Inertia::render('admin/system/settings/index', [
'setting' => GeneralSetting::first(),
]);
return Inertia::render('admin/system/settings/index');
}
public function update(GeneralSettingRequest $request): RedirectResponse
public function update(GeneralSettingRequest $request, GeneralSettings $settings): RedirectResponse
{
$validated = $request->validated();
$setting = GeneralSetting::first();
if ($setting) {
$setting->update($validated);
} else {
$setting = GeneralSetting::create($validated);
}
$settings->site_name = $validated['site_name'];
$settings->site_description = $validated['site_description'];
$settings->site_address = $validated['site_address'];
$settings->site_phone = $validated['site_phone'];
if ($request->hasFile('logo_light')) {
$setting->addMediaFromRequest('logo_light')->toMediaCollection('logo_light');
if ($settings->logo_light) {
Storage::disk('public')->delete($settings->logo_light);
}
$settings->logo_light = $request->file('logo_light')->store('settings', 'public');
}
if ($request->hasFile('logo_dark')) {
$setting->addMediaFromRequest('logo_dark')->toMediaCollection('logo_dark');
if ($settings->logo_dark) {
Storage::disk('public')->delete($settings->logo_dark);
}
$settings->logo_dark = $request->file('logo_dark')->store('settings', 'public');
}
if ($request->hasFile('icon_light')) {
$setting->addMediaFromRequest('icon_light')->toMediaCollection('icon_light');
if ($settings->icon_light) {
Storage::disk('public')->delete($settings->icon_light);
}
$settings->icon_light = $request->file('icon_light')->store('settings', 'public');
}
if ($request->hasFile('icon_dark')) {
$setting->addMediaFromRequest('icon_dark')->toMediaCollection('icon_dark');
if ($settings->icon_dark) {
Storage::disk('public')->delete($settings->icon_dark);
}
$settings->icon_dark = $request->file('icon_dark')->store('settings', 'public');
}
$settings->save();
return redirect()->back()->with('success', 'Pengaturan berhasil diperbarui');
}
}

View File

@ -43,9 +43,10 @@ public function __invoke(Request $request)
'cogs' => $this->getStats(fn () => $applyFilter(Order::query())->sum('cogs')),
'total_discount' => $this->getStats(fn () => $applyFilter(Order::query())->sum('discount')),
'total_purchases' => $this->getStats(fn () => $applyFilter(Purchase::query())->sum('total')),
'products_sold' => $this->getStats(fn () => $applyFilter(OrderItem::query())->sum('qty')),
'products_sold' => $this->getStats(fn () => $applyFilter(OrderItem::query())->whereHas('order')->sum('qty')),
'total_customers' => $this->getStats(fn () => $applyFilter(Order::query())->whereNotNull('customer_name')->distinct('customer_name')->count('customer_name')),
'repeat_customers' => $this->getStats(fn () => $applyFilter(DB::table('orders'))
->whereNull('deleted_at')
->whereNotNull('customer_name')
->select('customer_name')
->groupBy('customer_name')
@ -76,6 +77,7 @@ public function __invoke(Request $request)
// Revenue vs Purchases per month
$revenueByMonth = $applyFilter(DB::table('orders'))
->whereNull('deleted_at')
->select(
DB::raw("$monthSelect as month"),
DB::raw('SUM(total) as total')
@ -84,6 +86,7 @@ public function __invoke(Request $request)
->get();
$purchasesByMonth = $applyFilter(DB::table('purchases'))
->whereNull('deleted_at')
->select(
DB::raw("$monthSelect as month"),
DB::raw('SUM(total) as total')
@ -105,6 +108,7 @@ public function __invoke(Request $request)
});
$paymentMethods = $applyFilter(DB::table('orders'))
->whereNull('deleted_at')
->select('payment_method as name', DB::raw('COUNT(*) as total'))
->groupBy('payment_method')
->get()
@ -117,6 +121,7 @@ public function __invoke(Request $request)
});
$orderStatuses = $applyFilter(DB::table('orders'))
->whereNull('deleted_at')
->select('order_status as name', DB::raw('COUNT(*) as total'))
->groupBy('order_status')
->get()
@ -129,6 +134,7 @@ public function __invoke(Request $request)
});
$orderChannels = $applyFilter(DB::table('orders'))
->whereNull('deleted_at')
->select('order_channel as name', DB::raw('COUNT(*) as total'))
->groupBy('order_channel')
->get()
@ -142,13 +148,17 @@ public function __invoke(Request $request)
$topProducts = $applyFilter(DB::table('order_items'), 'order_items.created_at')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('orders', 'order_items.order_id', '=', 'orders.id')
->select('products.name as name', DB::raw('SUM(order_items.qty) as total'))
->whereNull('orders.deleted_at')
->whereNull('order_items.deleted_at')
->groupBy('products.name')
->orderByDesc('total')
->limit(5)
->get();
$topCustomers = $applyFilter(DB::table('orders'), 'orders.created_at')
->whereNull('orders.deleted_at')
->select('customer_name as name', DB::raw('SUM(total) as total'))
->whereNotNull('customer_name')
->groupBy('customer_name')
@ -159,6 +169,7 @@ public function __invoke(Request $request)
$hourSelect = $isSqlite ? "CAST(strftime('%H', created_at) AS INTEGER)" : 'HOUR(created_at)';
$salesByHourRaw = $applyFilter(DB::table('orders'))
->whereNull('deleted_at')
->select(
DB::raw("$hourSelect as hour"),
DB::raw('COUNT(*) as total')
@ -177,6 +188,7 @@ public function __invoke(Request $request)
// Revenue & Profit & Volume per month
$ordersByMonth = $applyFilter(DB::table('orders'))
->whereNull('deleted_at')
->select(
DB::raw("$monthSelect as month"),
DB::raw('SUM(total) as revenue'),
@ -239,7 +251,9 @@ public function __invoke(Request $request)
->join('products', 'order_items.product_id', '=', 'products.id')
->join('category_product', 'products.id', '=', 'category_product.product_id')
->join('categories', 'category_product.category_id', '=', 'categories.id')
->join('orders', 'order_items.order_id', '=', 'orders.id')
->select('categories.name', DB::raw('SUM(order_items.qty) as total'))
->whereNull('orders.deleted_at')
->whereNull('order_items.deleted_at')
->groupBy('categories.id', 'categories.name')
->orderByDesc('total')

View File

@ -42,6 +42,7 @@ public function __invoke()
// Get sales by hour
$todayOrders = DB::table('orders')
->whereNull('deleted_at')
->whereDate('created_at', $today)
->select(
DB::raw("$hourSelect as hour"),
@ -51,6 +52,7 @@ public function __invoke()
->get();
$yesterdayOrders = DB::table('orders')
->whereNull('deleted_at')
->whereDate('created_at', $yesterday)
->select(
DB::raw("$hourSelect as hour"),
@ -71,6 +73,7 @@ public function __invoke()
});
$paymentMethods = DB::table('orders')
->whereNull('deleted_at')
->select('payment_method as name', DB::raw('COUNT(*) as total'))
->whereDate('created_at', $today)
->groupBy('payment_method')
@ -84,6 +87,7 @@ public function __invoke()
});
$orderStatuses = DB::table('orders')
->whereNull('deleted_at')
->select('order_status as name', DB::raw('COUNT(*) as total'))
->whereDate('created_at', $today)
->groupBy('order_status')
@ -97,6 +101,7 @@ public function __invoke()
});
$orderChannels = DB::table('orders')
->whereNull('deleted_at')
->select('order_channel as name', DB::raw('COUNT(*) as total'))
->whereDate('created_at', $today)
->groupBy('order_channel')
@ -113,6 +118,8 @@ public function __invoke()
->join('products', 'order_items.product_id', '=', 'products.id')
->join('orders', 'order_items.order_id', '=', 'orders.id')
->select('products.name as name', DB::raw('SUM(order_items.qty) as total'))
->whereNull('orders.deleted_at')
->whereNull('order_items.deleted_at')
->whereDate('orders.created_at', $today)
->groupBy('products.name')
->orderByDesc('total')
@ -120,6 +127,7 @@ public function __invoke()
->get();
$topCustomers = DB::table('orders')
->whereNull('deleted_at')
->select('customer_name as name', DB::raw('SUM(total) as total'))
->whereDate('created_at', $today)
->whereNotNull('customer_name')

View File

@ -3,7 +3,6 @@
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\GeneralSetting;
use App\Models\Product;
class HomepageController extends Controller
@ -14,7 +13,6 @@ public function __invoke()
$categorySlug = request('category');
return inertia('homepage', [
'setting' => GeneralSetting::first(),
'categories' => Category::active()->get(),
'bestSellers' => Product::with(['categories' => fn ($q) => $q->active(), 'prices'])
->when($search, function ($query, $search) {

View File

@ -2,7 +2,6 @@
namespace App\Http\Middleware;
use App\Models\GeneralSetting;
use Illuminate\Http\Request;
use Inertia\Middleware;
@ -39,7 +38,6 @@ public function share(Request $request): array
return [
...parent::share($request),
'name' => config('app.name'),
'setting' => GeneralSetting::first(),
'auth' => [
'user' => $request->user() ? $request->user()->load('profile') : null,
'roles' => $request->user() ? $request->user()->getRoleNames() : [],

View File

@ -27,20 +27,22 @@ public function authorize(): bool
*/
public function rules(): array
{
$isCancelled = $this->input('order_status') === OrderStatus::CANCELLED->value;
return [
'customer_name' => ['required', 'string', 'max:100'],
'subtotal' => ['required', 'integer', 'min:0'],
'subtotal' => ['nullable', 'integer', 'min:0'],
'discount' => ['required', 'integer', 'min:0'],
'payment' => ['required', 'integer', 'min:0'],
'payment_method' => ['required', Rule::enum(PaymentMethod::class)],
'order_status' => ['required', Rule::enum(OrderStatus::class)],
'order_channel' => ['required', Rule::enum(OrderChannel::class)],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', Rule::exists('products', 'id')->whereNull('deleted_at')],
'items.*.qty' => ['required', 'integer', 'min:1'],
'items.*.price' => ['required', 'integer', 'min:0'],
'items.*.total' => ['required', 'integer', 'min:0'],
'items.*.price_type' => ['required', Rule::enum(PriceType::class)],
'items' => [$isCancelled ? 'nullable' : 'required', 'array', $isCancelled ? 'min:0' : 'min:1'],
'items.*.product_id' => ['required_unless:order_status,cancelled', Rule::exists('products', 'id')->whereNull('deleted_at')],
'items.*.qty' => ['required_unless:order_status,cancelled', 'integer', 'min:1'],
'items.*.price' => ['required_unless:order_status,cancelled', 'integer', 'min:0'],
'items.*.total' => ['required_unless:order_status,cancelled', 'integer', 'min:0'],
'items.*.price_type' => ['required_unless:order_status,cancelled', Rule::enum(PriceType::class)],
];
}
}

View File

@ -37,7 +37,7 @@ public function rules(): array
'birth_place' => ['required', 'string', 'max:100'],
'birth_date' => ['required', 'date'],
'base_salary' => ['required', 'integer', 'min:0'],
'roles' => ['nullable', 'array'],
'roles' => ['required', 'array'],
];
}
}

View File

@ -2,7 +2,6 @@
namespace App\Http\Requests\Admin\System;
use App\Models\GeneralSetting;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
@ -23,16 +22,14 @@ public function authorize(): bool
*/
public function rules(): array
{
$settingExists = GeneralSetting::exists();
return [
'name' => ['required', 'string', 'max:50'],
'description' => ['required', 'string'],
'address' => ['required', 'string'],
'phone' => ['required', 'string', 'max:20'],
'logo_light' => [$settingExists ? 'nullable' : 'required', 'image', 'max:2048'],
'site_name' => ['required', 'string', 'max:50'],
'site_description' => ['required', 'string'],
'site_address' => ['required', 'string'],
'site_phone' => ['required', 'string', 'max:20'],
'logo_light' => ['nullable', 'image', 'max:2048'],
'logo_dark' => ['nullable', 'image', 'max:2048'],
'icon_light' => [$settingExists ? 'nullable' : 'required', 'image', 'max:1024'],
'icon_light' => ['nullable', 'image', 'max:1024'],
'icon_dark' => ['nullable', 'image', 'max:1024'],
];
}

View File

@ -1,111 +0,0 @@
<?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_light_url', 'logo_dark_url', 'icon_light_url', 'icon_dark_url'])]
class GeneralSetting extends Model implements HasMedia
{
use HasFactory, InteractsWithMedia, LogsActivity;
public function registerMediaCollections(): void
{
$this->addMediaCollection('logo_light')
->singleFile();
$this->addMediaCollection('logo_dark')
->singleFile();
$this->addMediaCollection('icon_light')
->singleFile();
$this->addMediaCollection('icon_dark')
->singleFile();
}
protected function logoLightUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('logo_light') ?: null,
);
}
protected function logoDarkUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('logo_dark') ?: null,
);
}
protected function iconLightUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('icon_light') ?: null,
);
}
protected function iconDarkUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('icon_dark') ?: 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);
}
}
}

View File

@ -2,6 +2,7 @@
namespace App\Observers;
use App\Enums\OrderStatus;
use App\Models\Order;
class OrderObserver
@ -40,8 +41,14 @@ public function updating(Order $order): void
*/
public function deleting(Order $order): void
{
foreach ($order->items as $item) {
$item->product->increment('stock', $item->qty);
$isCancelled = ($order->order_status instanceof OrderStatus ? $order->order_status->value : $order->order_status) === OrderStatus::CANCELLED->value;
if (! $isCancelled) {
foreach ($order->items as $item) {
$item->product->increment('stock', $item->qty);
}
}
$order->items()->delete();
}
}

View File

@ -0,0 +1,75 @@
<?php
/**
* View Service Provider
*
* This provider handles global data sharing for both Inertia and Blade views.
*/
namespace App\Providers;
use App\Settings\GeneralSettings;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
class ViewServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
if ($this->app->runningInConsole() && ! $this->app->environment('testing')) {
return;
}
// Share settings with Inertia
Inertia::share('setting', function () {
try {
$settings = app(GeneralSettings::class);
return [
'site_name' => $settings->site_name,
'site_description' => $settings->site_description,
'site_address' => $settings->site_address,
'site_phone' => $settings->site_phone,
'logo_light_url' => $settings->logo_light ? Storage::url($settings->logo_light) : null,
'logo_dark_url' => $settings->logo_dark ? Storage::url($settings->logo_dark) : null,
'icon_light_url' => $settings->icon_light ? Storage::url($settings->icon_light) : null,
'icon_dark_url' => $settings->icon_dark ? Storage::url($settings->icon_dark) : null,
];
} catch (\Exception $e) {
return [];
}
});
// Share settings with Blade views
View::composer('*', function ($view) {
try {
$settings = app(GeneralSettings::class);
$view->with('setting', [
'site_name' => $settings->site_name,
'site_description' => $settings->site_description,
'site_address' => $settings->site_address,
'site_phone' => $settings->site_phone,
'logo_light_url' => $settings->logo_light ? Storage::url($settings->logo_light) : null,
'logo_dark_url' => $settings->logo_dark ? Storage::url($settings->logo_dark) : null,
'icon_light_url' => $settings->icon_light ? Storage::url($settings->icon_light) : null,
'icon_dark_url' => $settings->icon_dark ? Storage::url($settings->icon_dark) : null,
]);
} catch (\Exception $e) {
$view->with('setting', []);
}
});
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Settings;
use Spatie\LaravelSettings\Settings;
class GeneralSettings extends Settings
{
public string $site_name;
public string $site_description;
public string $site_address;
public string $site_phone;
public ?string $logo_light;
public ?string $logo_dark;
public ?string $icon_light;
public ?string $icon_dark;
public static function group(): string
{
return 'general';
}
}

View File

@ -2,8 +2,10 @@
use App\Providers\AppServiceProvider;
use App\Providers\FortifyServiceProvider;
use App\Providers\ViewServiceProvider;
return [
AppServiceProvider::class,
FortifyServiceProvider::class,
ViewServiceProvider::class,
];

View File

@ -19,6 +19,7 @@
"spatie/laravel-activitylog": "^4.12",
"spatie/laravel-medialibrary": "^11.21",
"spatie/laravel-permission": "^7.3",
"spatie/laravel-settings": "^3.8",
"spatie/laravel-sluggable": "^3.8"
},
"require-dev": {

405
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": "1e97290615923580b9a565671ca7bfae",
"content-hash": "a42046c1187875d3d873b061a01fa885",
"packages": [
{
"name": "archtechx/enums",
@ -445,6 +445,54 @@
},
"time": "2024-07-08T12:26:09+00:00"
},
{
"name": "doctrine/deprecations",
"version": "1.1.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"phpunit/phpunit": "<=7.5 || >=14"
},
"require-dev": {
"doctrine/coding-standard": "^9 || ^12 || ^14",
"phpstan/phpstan": "1.4.10 || 2.1.30",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Deprecations\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
},
"time": "2026-02-07T07:09:04+00:00"
},
{
"name": "doctrine/inflector",
"version": "2.1.0",
@ -3976,6 +4024,117 @@
},
"time": "2025-09-24T15:06:41+00:00"
},
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionCommon.git",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-2.x": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"phpDocumentor\\Reflection\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jaap van Otterdijk",
"email": "opensource@ijaap.nl"
}
],
"description": "Common reflection classes used by phpdocumentor to reflect the code structure",
"homepage": "http://www.phpdoc.org",
"keywords": [
"FQSEN",
"phpDocumentor",
"phpdoc",
"reflection",
"static analysis"
],
"support": {
"issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
"source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
},
"time": "2020-06-27T09:03:43+00:00"
},
{
"name": "phpdocumentor/type-resolver",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/TypeResolver.git",
"reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9",
"reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9",
"shasum": ""
},
"require": {
"doctrine/deprecations": "^1.0",
"php": "^7.4 || ^8.0",
"phpdocumentor/reflection-common": "^2.0",
"phpstan/phpdoc-parser": "^2.0"
},
"require-dev": {
"ext-tokenizer": "*",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psalm/phar": "^4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-1.x": "1.x-dev",
"dev-2.x": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"phpDocumentor\\Reflection\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mike van Riel",
"email": "me@mikevanriel.com"
}
],
"description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
"support": {
"issues": "https://github.com/phpDocumentor/TypeResolver/issues",
"source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0"
},
"time": "2026-01-06T21:53:42+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",
@ -5316,6 +5475,91 @@
],
"time": "2026-04-07T15:19:42+00:00"
},
{
"name": "spatie/laravel-settings",
"version": "3.8.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-settings.git",
"reference": "09b788ee96d205699420dedb8a6aa8c4c8af84fe"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-settings/zipball/09b788ee96d205699420dedb8a6aa8c4c8af84fe",
"reference": "09b788ee96d205699420dedb8a6aa8c4c8af84fe",
"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.8.0"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-04-28T07:05:06+00:00"
},
{
"name": "spatie/laravel-sluggable",
"version": "3.8.1",
@ -8245,54 +8489,6 @@
],
"time": "2026-03-29T15:46:14+00:00"
},
{
"name": "doctrine/deprecations",
"version": "1.1.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"phpunit/phpunit": "<=7.5 || >=14"
},
"require-dev": {
"doctrine/coding-standard": "^9 || ^12 || ^14",
"phpstan/phpstan": "1.4.10 || 2.1.30",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Deprecations\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
},
"time": "2026-02-07T07:09:04+00:00"
},
{
"name": "fakerphp/faker",
"version": "v1.24.1",
@ -9630,59 +9826,6 @@
},
"time": "2022-02-21T01:04:05+00:00"
},
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionCommon.git",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-2.x": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"phpDocumentor\\Reflection\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jaap van Otterdijk",
"email": "opensource@ijaap.nl"
}
],
"description": "Common reflection classes used by phpdocumentor to reflect the code structure",
"homepage": "http://www.phpdoc.org",
"keywords": [
"FQSEN",
"phpDocumentor",
"phpdoc",
"reflection",
"static analysis"
],
"support": {
"issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
"source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
},
"time": "2020-06-27T09:03:43+00:00"
},
{
"name": "phpdocumentor/reflection-docblock",
"version": "6.0.3",
@ -9748,64 +9891,6 @@
},
"time": "2026-03-18T20:49:53+00:00"
},
{
"name": "phpdocumentor/type-resolver",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/TypeResolver.git",
"reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9",
"reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9",
"shasum": ""
},
"require": {
"doctrine/deprecations": "^1.0",
"php": "^7.4 || ^8.0",
"phpdocumentor/reflection-common": "^2.0",
"phpstan/phpdoc-parser": "^2.0"
},
"require-dev": {
"ext-tokenizer": "*",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psalm/phar": "^4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-1.x": "1.x-dev",
"dev-2.x": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"phpDocumentor\\Reflection\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mike van Riel",
"email": "me@mikevanriel.com"
}
],
"description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
"support": {
"issues": "https://github.com/phpDocumentor/TypeResolver/issues",
"source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0"
},
"time": "2026-01-06T21:53:42+00:00"
},
{
"name": "phpunit/php-code-coverage",
"version": "12.5.6",

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create(config('settings.repositories.database.table') ?? 'settings', function (Blueprint $table): void {
$table->id();
$table->string('group');
$table->string('name');
$table->boolean('locked')->default(false);
$table->json('payload');
$table->timestamps();
$table->unique(['group', 'name']);
});
}
};

View File

@ -16,7 +16,7 @@ public function up(): void
$table->string('name', 100);
$table->string('slug', 100);
$table->text('description')->nullable();
$table->integer('stock')->default(0);
$table->unsignedInteger('stock')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();

View File

@ -1,31 +0,0 @@
<?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', 50);
$table->text('description');
$table->text('address');
$table->string('phone', 20);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('general_settings');
}
};

View File

@ -2,7 +2,7 @@
namespace Database\Seeders;
use App\Models\GeneralSetting;
use App\Settings\GeneralSettings;
use Illuminate\Database\Seeder;
class GeneralSettingSeeder extends Seeder
@ -12,11 +12,11 @@ class GeneralSettingSeeder extends Seeder
*/
public function run(): void
{
GeneralSetting::updateOrCreate([
'name' => 'VN Grup',
'description' => 'VN Grup adalah Toko Baju Daster Online yang menghadirkan koleksi baju daster berkualitas tinggi untuk wanita yang mengutamakan kenyamanan tanpa mengorbankan gaya. Kami menyediakan berbagai macam daster dengan desain yang beragam, mulai dari yang sederhana dan klasik hingga yang modern dan trendi, cocok untuk berbagai kebutuhan, baik di rumah maupun saat bersantai di luar.',
'address' => 'Jl. Raya Pabuaran-Cipeunduy, Kec. Pabuaran, Kab. Subang, Jawa Barat',
'phone' => '089679965828',
]);
$settings = app(GeneralSettings::class);
$settings->site_name = 'VN Grup';
$settings->site_description = 'VN Grup adalah Toko Baju Daster Online yang menghadirkan koleksi baju daster berkualitas tinggi untuk wanita yang mengutamakan kenyamanan tanpa mengorbankan gaya. Kami menyediakan berbagai macam daster dengan desain yang beragam, mulai dari yang sederhana dan klasik hingga yang modern dan trendi, cocok untuk berbagai kebutuhan, baik di rumah maupun saat bersantai di luar.';
$settings->site_address = 'Jl. Raya Pabuaran-Cipeunduy, Kec. Pabuaran, Kab. Subang, Jawa Barat';
$settings->site_phone = '089679965828';
$settings->save();
}
}

View File

@ -0,0 +1,18 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->add('general.site_name', 'VN Grup');
$this->migrator->add('general.site_description', 'VN Grup adalah Toko Baju Daster Online yang menghadirkan koleksi baju daster berkualitas tinggi untuk wanita yang mengutamakan kenyamanan tanpa mengorbankan gaya.');
$this->migrator->add('general.site_address', 'Jl. Raya Pabuaran-Cipeunduy, Kec. Pabuaran, Kab. Subang, Jawa Barat');
$this->migrator->add('general.site_phone', '089679965828');
$this->migrator->add('general.logo_light', null);
$this->migrator->add('general.logo_dark', null);
$this->migrator->add('general.icon_light', null);
$this->migrator->add('general.icon_dark', null);
}
};

View File

@ -67,39 +67,39 @@ @theme {
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--background: oklch(0.962 0.012 85.3);
--foreground: oklch(0.334 0.082 245.2);
--card: oklch(0.962 0.012 85.3);
--card-foreground: oklch(0.334 0.082 245.2);
--popover: oklch(0.962 0.012 85.3);
--popover-foreground: oklch(0.334 0.082 245.2);
--primary: oklch(0.334 0.082 245.2);
--primary-foreground: oklch(0.962 0.012 85.3);
--secondary: oklch(0.94 0.01 85);
--secondary-foreground: oklch(0.334 0.082 245.2);
--muted: oklch(0.94 0.01 85);
--muted-foreground: oklch(0.45 0.05 245);
--accent: oklch(0.94 0.01 85);
--accent-foreground: oklch(0.334 0.082 245.2);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.87 0 0);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.334 0.082 245.2 / 0.1);
--input: oklch(0.334 0.082 245.2 / 0.1);
--ring: oklch(0.334 0.082 245.2 / 0.5);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.87 0 0);
--sidebar: oklch(0.962 0.012 85.3);
--sidebar-foreground: oklch(0.334 0.082 245.2);
--sidebar-primary: oklch(0.334 0.082 245.2);
--sidebar-primary-foreground: oklch(0.962 0.012 85.3);
--sidebar-accent: oklch(0.334 0.082 245.2 / 0.05);
--sidebar-accent-foreground: oklch(0.334 0.082 245.2);
--sidebar-border: oklch(0.334 0.082 245.2 / 0.1);
--sidebar-ring: oklch(0.334 0.082 245.2 / 0.5);
}
.dark {
@ -145,4 +145,9 @@ @layer base {
body {
@apply bg-background text-foreground;
}
::selection {
background-color: var(--primary);
color: var(--primary-foreground);
}
}

View File

@ -9,20 +9,20 @@ export default function AppLogo() {
{setting?.logo_light_url ? (
<img
src={setting.logo_light_url}
alt={setting.name}
alt={setting.site_name}
className="max-h-8 w-auto dark:hidden"
/>
) : null}
{setting?.logo_dark_url ? (
<img
src={setting.logo_dark_url}
alt={setting.name}
alt={setting.site_name}
className="hidden max-h-8 w-auto dark:block"
/>
) : null}
{!setting?.logo_light_url && !setting?.logo_dark_url && (
<span className="truncate font-semibold tracking-tight">
{setting?.name || 'VN Grup'}
{setting?.site_name || 'VN Grup'}
</span>
)}
</div>

View File

@ -147,7 +147,7 @@ export function AppSidebar() {
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href={dashboard().url} prefetch>
<Link href={dashboard().url}>
<AppLogo />
</Link>
</SidebarMenuButton>

View File

@ -29,7 +29,7 @@ export function NavMain({
isActive={item.isActive || isCurrentOrParentUrl(item.href)}
tooltip={{ children: item.title }}
>
<Link href={item.href} prefetch>
<Link href={item.href}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>

View File

@ -180,7 +180,12 @@ return;
post(orderRoutes.store().url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
});
};

View File

@ -195,7 +195,12 @@ return;
patch(orderRoutes.update(order.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
});
};

View File

@ -12,7 +12,7 @@ import type { Order } from '@/types/order';
import { ORDER_CHANNEL, ORDER_STATUS, PAYMENT_METHOD } from '@/types/order';
import { Link } from '@inertiajs/react';
import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Printer, Trash2 } from 'lucide-react';
import { FileText, Pencil, Printer, Trash2 } from 'lucide-react';
interface ColumnProps {
onDelete: (order: Order) => void;
@ -182,6 +182,22 @@ export const getColumns = ({
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Link href={orderRoutes.show(order.id).url}>
<Button
variant="ghost"
size="icon"
className="text-emerald-600 hover:bg-emerald-50 hover:text-emerald-700 dark:hover:bg-emerald-950/20"
>
<FileText className="size-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
<p>Invoice</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@ -0,0 +1,194 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Head, Link } from '@inertiajs/react';
import { ArrowLeft, Mail } from 'lucide-react';
import type { Order } from '@/types/order';
import { ORDER_STATUS, PAYMENT_METHOD, ORDER_CHANNEL } from '@/types/order';
import * as orderRoutes from '@/routes/order';
import type { GeneralSetting } from '@/types/general-setting';
interface ShowProps {
order: Order;
setting: GeneralSetting;
}
export default function OrderShow({ order, setting }: ShowProps) {
return (
<div className="flex flex-col gap-6 p-6 print:p-8">
<Head title={`Invoice #${order.order_number}`} />
{/* Header / Actions */}
<div className="relative z-10 flex items-center justify-between print:hidden">
<div className="flex items-center gap-4">
<Link
href={orderRoutes.index().url}
className="flex h-10 w-10 items-center justify-center rounded-full border border-input bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Detail Pesanan
</h1>
<p className="text-sm text-muted-foreground">
Invoice #{order.order_number}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Actions removed as requested */}
</div>
</div>
{/* Invoice Card */}
<Card className="relative mx-auto w-full max-w-4xl overflow-hidden border-none bg-card shadow-xl print:shadow-none print:border print:max-w-none">
<div className="pointer-events-none absolute inset-0 bg-grid-slate-100 [mask-image:linear-gradient(0deg,#fff,rgba(255,255,255,0.6))] dark:bg-grid-slate-700/25 dark:[mask-image:linear-gradient(0deg,rgba(255,255,255,0.1),rgba(255,255,255,0.5))] print:hidden" />
<CardHeader className="relative space-y-6 pb-8 pt-10">
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-start">
<div className="space-y-4">
<div className="flex items-center gap-3">
{setting?.logo_light_url ? (
<img src={setting.logo_light_url} alt="Logo" className="h-10 w-auto" />
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-2xl font-bold text-primary-foreground">
{setting?.site_name?.charAt(0) || 'V'}
</div>
)}
<div>
<h2 className="text-2xl font-bold text-foreground">{setting?.site_name || 'VN Grup'}</h2>
<p className="text-sm text-muted-foreground italic">Your Style, Our Passion</p>
</div>
</div>
<div className="max-w-xs space-y-1 text-sm text-muted-foreground">
<p>{setting?.site_address || 'Alamat Toko Belum Diatur'}</p>
<p>{setting?.site_phone || '-'}</p>
</div>
</div>
<div className="space-y-4 text-left md:text-right">
<div className="space-y-1">
<h3 className="text-3xl font-black uppercase tracking-tighter text-primary">INVOICE</h3>
<p className="text-lg font-mono font-medium">#{order.order_number}</p>
</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p>Tanggal Pesanan: <span className="font-medium text-foreground">{order.created_at_formatted}</span></p>
<p>Channel: <span className="font-medium text-foreground">{ORDER_CHANNEL[order.order_channel] || order.order_channel}</span></p>
<div className="flex items-center gap-2 md:justify-end">
<span>Status:</span>
<Badge variant={ORDER_STATUS[order.order_status]?.color ?? 'outline'}>
{ORDER_STATUS[order.order_status]?.label ?? order.order_status}
</Badge>
</div>
</div>
</div>
</div>
<Separator className="bg-primary/10" />
<div className="grid gap-8 md:grid-cols-2">
<div className="space-y-3">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Tagihan Untuk:</h4>
<div className="space-y-1">
<p className="text-lg font-bold text-foreground">{order.customer_name}</p>
<p className="text-sm text-muted-foreground">Pelanggan Setia</p>
</div>
</div>
<div className="space-y-3 text-left md:text-right">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Pembayaran:</h4>
<div className="space-y-1">
<p className="text-lg font-bold text-foreground">{PAYMENT_METHOD[order.payment_method] || order.payment_method}</p>
</div>
</div>
</div>
</CardHeader>
<CardContent className="relative space-y-8 pb-12">
<div className="rounded-xl border bg-card/50 overflow-hidden shadow-sm">
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead>Produk</TableHead>
<TableHead className="text-right">Harga</TableHead>
<TableHead className="text-center">Jumlah</TableHead>
<TableHead className="text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{order.items?.map((item, index) => (
<TableRow key={item.id} className="hover:bg-muted/30">
<TableCell className="text-center text-muted-foreground">{index + 1}</TableCell>
<TableCell>
<div className="font-medium">{item.product?.name || 'Produk Tidak Diketahui'}</div>
<div className="text-xs text-muted-foreground uppercase">{item.price_type}</div>
</TableCell>
<TableCell className="text-right">{item.price_formatted}</TableCell>
<TableCell className="text-center">{item.qty}</TableCell>
<TableCell className="text-right font-medium">{item.total_formatted}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col justify-between gap-8 md:flex-row">
<div className="flex-1 space-y-4">
<div className="rounded-xl bg-muted/30 p-4 space-y-2 border border-dashed">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Catatan:</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
Terima kasih telah berbelanja di {setting?.site_name || 'VN Grup'}.
Barang yang sudah dibeli tidak dapat ditukar atau dikembalikan kecuali ada perjanjian sebelumnya.
Simpan invoice ini sebagai bukti pembelian yang sah.
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Mail className="size-3" />
<span>Dicatat oleh: <span className="font-medium text-foreground">{order.user?.name || '-'}</span></span>
</div>
</div>
<div className="w-full md:w-80 space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal</span>
<span className="font-medium">{order.subtotal_formatted}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Potongan / Diskon</span>
<span className="font-medium text-red-500">- {order.discount_formatted}</span>
</div>
<Separator />
<div className="flex justify-between items-center pt-1">
<span className="text-lg font-bold">Total Tagihan</span>
<span className="text-2xl font-black text-primary">{order.total_formatted}</span>
</div>
<div className="flex justify-between items-center rounded-lg bg-primary/5 px-3 py-2 border border-primary/10 mt-4">
<span className="text-xs font-bold uppercase text-primary/70">Sudah Dibayar</span>
<span className="text-sm font-bold text-primary">{order.payment_formatted}</span>
</div>
</div>
</div>
<div className="pt-10 text-center text-sm text-muted-foreground print:pt-20">
<p className="font-medium">Semoga hari Anda menyenangkan!</p>
<p className="text-[10px] mt-1 opacity-50">Generated by {setting?.site_name || 'VN Grup'} System</p>
</div>
</CardContent>
</Card>
</div>
);
}
OrderShow.layout = (page: any) => (
<div className="min-h-screen bg-muted/20 dark:bg-muted/5">{page}</div>
);

View File

@ -165,7 +165,12 @@ return;
post(purchaseRoutes.store().url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
});
};

View File

@ -170,7 +170,12 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
patch(purchaseRoutes.update(purchase.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
});
};

View File

@ -20,7 +20,12 @@ export function usePurchaseIndex() {
if (purchaseToDelete) {
router.delete(purchaseRoutes.destroy(purchaseToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsDeleteDialogOpen(false);
setPurchaseToDelete(null);
setRowSelection({});
@ -35,7 +40,12 @@ export function usePurchaseIndex() {
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});

View File

@ -113,7 +113,12 @@ export default function ProductCreate({ categories }: { categories: Category[] }
post(productRoutes.store().url, {
forceFormData: true,
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
});
};

View File

@ -165,7 +165,12 @@ export default function ProductEdit({ product, categories }: { product: Product,
post(productRoutes.update(product.id).url, {
forceFormData: true,
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
});
};

View File

@ -21,7 +21,12 @@ export function useProductIndex() {
if (productToDelete) {
router.delete(productRoutes.destroy(productToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsDeleteDialogOpen(false);
setProductToDelete(null);
setRowSelection({});
@ -36,7 +41,12 @@ export function useProductIndex() {
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});
@ -48,7 +58,14 @@ export function useProductIndex() {
router.patch(productRoutes.toggleStatus(id).url, {}, {
preserveState: true,
preserveScroll: true,
onSuccess: (response: any) => toast.success(response.props.flash.success),
onSuccess: (response: any) => {
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
});
};

View File

@ -50,7 +50,12 @@ export default function UserCreate({ roles }: { roles: any[] }) {
e.preventDefault();
post(userRoutes.store().url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
});
};
@ -384,7 +389,9 @@ export default function UserCreate({ roles }: { roles: any[] }) {
</Field>
<Field>
<Label htmlFor="roles">Peran</Label>
<Label htmlFor="roles" required>
Peran
</Label>
<Combobox
multiple
autoHighlight

View File

@ -51,7 +51,12 @@ export default function UserEdit({ user, roles }: { user: any, roles: any[] }) {
e.preventDefault();
post(userRoutes.update(user.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
});
};
@ -237,7 +242,9 @@ export default function UserEdit({ user, roles }: { user: any, roles: any[] }) {
</Field>
<Field>
<Label htmlFor="roles">Peran</Label>
<Label htmlFor="roles" required>
Peran
</Label>
<Combobox
multiple
autoHighlight

View File

@ -22,7 +22,12 @@ export function useUserIndex() {
if (userToReset) {
router.patch(userRoutes.resetPassword(userToReset.id).url, {}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsResetPasswordOpen(false);
setUserToReset(null);
},
@ -39,7 +44,12 @@ export function useUserIndex() {
if (userToDelete) {
router.delete(userRoutes.destroy(userToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsDeleteDialogOpen(false);
setUserToDelete(null);
setRowSelection({});
@ -54,7 +64,12 @@ export function useUserIndex() {
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});
@ -67,7 +82,12 @@ export function useUserIndex() {
preserveState: true,
preserveScroll: true,
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
});
};

View File

@ -30,7 +30,12 @@ export default function RoleCreate({
e.preventDefault();
post(roleRoutes.store().url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
});
};

View File

@ -32,7 +32,12 @@ export default function RoleEdit({
e.preventDefault();
put(roleRoutes.update(role.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
});
};

View File

@ -20,7 +20,12 @@ export function useRoleIndex() {
if (roleToDelete) {
router.delete(roleRoutes.destroy(roleToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsDeleteDialogOpen(false);
setRoleToDelete(null);
setRowSelection({});
@ -35,7 +40,12 @@ export function useRoleIndex() {
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});

View File

@ -17,10 +17,10 @@ export default function SettingIndex({
setting: GeneralSetting | null;
}) {
const { data, setData, post, processing, errors } = useForm({
name: setting?.name || '',
description: setting?.description || '',
address: setting?.address || '',
phone: setting?.phone || '',
site_name: setting?.site_name || '',
site_description: setting?.site_description || '',
site_address: setting?.site_address || '',
site_phone: setting?.site_phone || '',
logo_light: null as File | null,
logo_dark: null as File | null,
icon_light: null as File | null,
@ -107,41 +107,41 @@ export default function SettingIndex({
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<Field>
<Label htmlFor="phone" required>
<Label htmlFor="site_phone" required>
No. Telepon
</Label>
<Input
id="phone"
value={data.phone || ''}
id="site_phone"
value={data.site_phone || ''}
onChange={(e) =>
setData('phone', e.target.value)
setData('site_phone', e.target.value)
}
placeholder="0812xxxx"
/>
<FieldError
error={errors.phone}
error={errors.site_phone}
label="No. Telepon"
className="text-xs"
/>
</Field>
<Field>
<Label htmlFor="name" required>
<Label htmlFor="site_name" required>
Nama Aplikasi
</Label>
<Input
id="name"
name="name"
value={data.name}
id="site_name"
name="site_name"
value={data.site_name}
onChange={(e) =>
setData('name', e.target.value)
setData('site_name', e.target.value)
}
autoComplete="off"
placeholder="Contoh: VN Grup Dress"
maxLength={100}
/>
<FieldError
error={errors.name}
error={errors.site_name}
label="Nama Aplikasi"
className="text-xs"
/>
@ -149,15 +149,15 @@ export default function SettingIndex({
</div>
<Field>
<Label htmlFor="description" required>
<Label htmlFor="site_description" required>
Deskripsi
</Label>
<Textarea
id="description"
value={data.description || ''}
id="site_description"
value={data.site_description || ''}
onChange={(e) =>
setData(
'description',
'site_description',
e.target.value,
)
}
@ -165,27 +165,27 @@ export default function SettingIndex({
rows={4}
/>
<FieldError
error={errors.description}
error={errors.site_description}
label="Deskripsi"
className="mt-1 text-xs"
/>
</Field>
<Field>
<Label htmlFor="address" required>
<Label htmlFor="site_address" required>
Alamat
</Label>
<Textarea
id="address"
value={data.address || ''}
id="site_address"
value={data.site_address || ''}
onChange={(e) =>
setData('address', e.target.value)
setData('site_address', e.target.value)
}
placeholder="Alamat lengkap toko"
rows={3}
/>
<FieldError
error={errors.address}
error={errors.site_address}
label="Alamat"
className="mt-1 text-xs"
/>

View File

@ -141,11 +141,11 @@ export default function Homepage({ categories = [], bestSellers, topSellingProdu
{setting?.logo_light_url ? (
<img
src={setting.logo_light_url}
alt={setting.name}
alt={setting.site_name}
className="max-h-8 w-auto"
/>
) : (
setting?.name || 'VN Grup'
setting?.site_name || 'VN Grup'
)}
</div>
@ -334,7 +334,7 @@ export default function Homepage({ categories = [], bestSellers, topSellingProdu
<p className="mb-4 text-sm font-medium opacity-60">{formatCurrency(item.retail_price)}</p>
<a
href={`https://wa.me/628123456789?text=${encodeURIComponent(`Halo ${setting?.name || 'VN Grup'}, saya ingin memesan ${item.name} seharga ${formatCurrency(item.retail_price)}. Mohon info detail pembayarannya.`)}`}
href={`https://wa.me/628123456789?text=${encodeURIComponent(`Halo ${setting?.site_name || 'VN Grup'}, saya ingin memesan ${item.name} seharga ${formatCurrency(item.retail_price)}. Mohon info detail pembayarannya.`)}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 border border-[#1e4a6d] px-6 py-2 text-[8px] font-bold uppercase tracking-widest transition-all hover:bg-[#1e4a6d] hover:text-white"
@ -412,10 +412,10 @@ export default function Homepage({ categories = [], bestSellers, topSellingProdu
{/* Brand Info */}
<div className="lg:col-span-6">
<div className="mb-6 flex items-center gap-2 text-xl font-bold uppercase tracking-tighter">
{setting?.logo_light_url ? <img src={setting.logo_light_url} alt={setting.name} className="max-h-8" /> : (setting?.name || 'VN Grup')}
{setting?.logo_light_url ? <img src={setting.logo_light_url} alt={setting.site_name} className="max-h-8" /> : (setting?.site_name || 'VN Grup')}
</div>
<p className="mb-8 text-sm leading-relaxed opacity-60">
{setting?.description || 'Menghadirkan produk esensial yang tak lekang oleh waktu dengan fokus pada kualitas, keberlanjutan, dan gaya yang abadi.'}
{setting?.site_description || 'Menghadirkan produk esensial yang tak lekang oleh waktu dengan fokus pada kualitas, keberlanjutan, dan gaya yang abadi.'}
</p>
<div className="flex gap-4">
<Facebook size={18} className="cursor-pointer hover:opacity-50" />
@ -443,13 +443,13 @@ export default function Homepage({ categories = [], bestSellers, topSellingProdu
<Search size={14} className="rotate-45" />
</div>
<p className="leading-relaxed whitespace-pre-line">
{setting?.address || 'Jl. Raya Utama No. 123, \n Kec. Lengkong, Kota Bandung, \n Jawa Barat 40262'}
{setting?.site_address || 'Jl. Raya Utama No. 123, \n Kec. Lengkong, Kota Bandung, \n Jawa Barat 40262'}
</p>
</div>
<div className="flex items-center gap-3">
<Phone size={14} />
<a href={`https://wa.me/${(setting?.phone || '+628123456789').replace(/\D/g, '')}`} target="_blank" className="hover:underline">
{setting?.phone || '+62 812 3456 789'}
<a href={`https://wa.me/${(setting?.site_phone || '+628123456789').replace(/\D/g, '')}`} target="_blank" className="hover:underline">
{setting?.site_phone || '+62 812 3456 789'}
</a>
</div>
</div>
@ -458,7 +458,7 @@ export default function Homepage({ categories = [], bestSellers, topSellingProdu
{/* Bottom Bar */}
<div className="px-6 py-6 text-center text-[10px] font-bold uppercase tracking-widest lg:px-12 lg:text-left">
<p className="opacity-50">© {new Date().getFullYear()} {setting?.name || 'VN Grup'}.</p>
<p className="opacity-50">© {new Date().getFullYear()} {setting?.site_name || 'VN Grup'}.</p>
</div>
</div>
</footer>
@ -564,7 +564,7 @@ export default function Homepage({ categories = [], bestSellers, topSellingProdu
<div className="mt-auto pt-8 border-t border-[#1e4a6d]/10">
<a
href={`https://wa.me/628123456789?text=${encodeURIComponent(`Halo ${setting?.name || 'VN Grup'}, saya tertarik dengan produk ${selectedProduct?.name} seharga ${formatCurrency(selectedProduct?.retail_price || 0)}. Bisa tanya-tanya dulu?`)}`}
href={`https://wa.me/628123456789?text=${encodeURIComponent(`Halo ${setting?.site_name || 'VN Grup'}, saya tertarik dengan produk ${selectedProduct?.name} seharga ${formatCurrency(selectedProduct?.retail_price || 0)}. Bisa tanya-tanya dulu?`)}`}
target="_blank"
rel="noopener noreferrer"
className="w-full bg-[#1e4a6d] py-4 text-[10px] font-bold uppercase tracking-[0.2em] text-white transition-all hover:bg-[#15344e] flex items-center justify-center gap-2"

View File

@ -1,13 +1,10 @@
export interface GeneralSetting {
id: number;
name: string;
description: string | null;
address: string | null;
phone: string | null;
site_name: string;
site_description: string | null;
site_address: string | null;
site_phone: string | null;
logo_light_url: string | null;
logo_dark_url: string | null;
icon_light_url: string | null;
icon_dark_url: string | null;
created_at: string;
updated_at: string;
}

View File

@ -31,12 +31,8 @@
}
</style>
@php
$setting = \App\Models\GeneralSetting::first();
@endphp
@if ($setting && $setting->icon_light_url)
<link rel="icon" href="{{ $setting->icon_light_url }}" sizes="any">
@if ($setting['icon_light_url'] ?? null)
<link rel="icon" href="{{ $setting['icon_light_url'] }}" sizes="any">
@else
<link rel="icon" href="/favicon.ico" sizes="any">
@endif
@ -47,7 +43,7 @@
@viteReactRefresh
@vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
<x-inertia::head>
<title>{{ config('app.name', 'VN Grup') }}</title>
<title>{{ $setting['site_name'] ?? config('app.name', 'VN Grup') }}</title>
</x-inertia::head>
</head>

View File

@ -24,6 +24,7 @@
Route::get('orders', [OrderController::class, 'index'])->name('order.index')->middleware('can:View:Order');
Route::get('order/create', [OrderController::class, 'create'])->name('order.create')->middleware('can:Create:Order');
Route::post('order/store', [OrderController::class, 'store'])->name('order.store')->middleware('can:Create:Order');
Route::get('order/show/{order}', [OrderController::class, 'show'])->name('order.show')->middleware('can:View:Order');
Route::get('order/edit/{order}', [OrderController::class, 'edit'])->name('order.edit')->middleware('can:Edit:Order');
Route::patch('order/update/{order}', [OrderController::class, 'update'])->name('order.update')->middleware('can:Edit:Order');
Route::delete('order/destroy/{order}', [OrderController::class, 'destroy'])->name('order.destroy')->middleware('can:Delete:Order');

View File

@ -7,6 +7,7 @@
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Settings\GeneralSettings;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\assertDatabaseHas;
@ -45,6 +46,13 @@
'DeleteAny:Order',
]);
actingAs($user);
$settings = app(GeneralSettings::class);
$settings->site_name = 'VN Grup';
$settings->site_phone = '08123456789';
$settings->site_address = 'Jl. Test No. 1';
$settings->site_description = 'Test Description';
$settings->save();
});
it('can access order index page', function () {
@ -71,6 +79,45 @@
->component('admin/manage/order/create')
->has('products')
->has('cartItems')
->where('orderStatus', function ($status) {
return collect($status)->every(fn ($item) => $item['value'] !== OrderStatus::CANCELLED->value);
})
);
});
it('can access order show page', function () {
$order = Order::factory()
->has(OrderItem::factory()->count(2), 'items')
->create();
get(route('order.show', $order))
->assertOk()
->assertInertia(fn ($page) => $page
->component('admin/manage/order/show')
->has('order', fn ($page) => $page
->where('id', $order->id)
->has('items', 2)
->has('items.0.product')
->has('user')
->etc()
)
->has('setting')
);
});
it('can access order edit page', function () {
$order = Order::factory()->create();
get(route('order.edit', $order))
->assertOk()
->assertInertia(fn ($page) => $page
->component('admin/manage/order/edit')
->has('order')
->has('products')
->has('orderStatus')
->has('orderChannels')
->has('paymentMethods')
->has('priceTypes')
);
});
@ -169,6 +216,65 @@
->assertJsonValidationErrors(['customer_name', 'items']);
});
it('returns flash error when stock is insufficient on order store', function () {
$product = Product::factory()->create(['stock' => 1]);
$data = [
'customer_name' => 'John Doe',
'subtotal' => 20000,
'discount' => 0,
'payment' => 20000,
'payment_method' => PaymentMethod::CASH->value,
'order_status' => OrderStatus::PENDING->value,
'order_channel' => OrderChannel::STORE->value,
'items' => [
[
'product_id' => $product->id,
'qty' => 5, // lebih dari stok
'price' => 10000,
'total' => 50000,
'price_type' => PriceType::RETAIL->value,
],
],
];
postJson(route('order.store'), $data)
->assertRedirect()
->assertSessionHas('error');
expect($product->fresh()->stock)->toBe(1);
});
it('returns flash error when stock is insufficient on order update', function () {
$product = Product::factory()->create(['stock' => 2]);
$order = Order::factory()->create();
$newData = [
'customer_name' => 'Updated Customer',
'subtotal' => 30000,
'discount' => 0,
'payment' => 30000,
'payment_method' => PaymentMethod::CASH->value,
'order_status' => OrderStatus::PROCESSING->value,
'order_channel' => OrderChannel::STORE->value,
'items' => [
[
'product_id' => $product->id,
'qty' => 10, // lebih dari stok
'price' => 3000,
'total' => 30000,
'price_type' => PriceType::RETAIL->value,
],
],
];
patchJson(route('order.update', $order), $newData)
->assertRedirect()
->assertSessionHas('error');
expect($product->fresh()->stock)->toBe(2);
});
it('can update an order', function () {
$product1 = Product::factory()->create(['stock' => 10]);
$product2 = Product::factory()->create(['stock' => 10]);
@ -234,6 +340,54 @@
expect($product2->fresh()->stock)->toBe(7);
});
it('restores stock and preserves items when order status is changed to gagal', function () {
$product = Product::factory()->create(['stock' => 10]);
$order = Order::factory()->create(['order_status' => OrderStatus::PENDING->value]);
$item = $order->items()->create([
'user_id' => auth()->id(),
'product_id' => $product->id,
'qty' => 3,
'price' => 1000,
'total' => 3000,
'price_type' => PriceType::RETAIL->value,
]);
$product->decrement('stock', 3);
expect($product->fresh()->stock)->toBe(7);
$newData = [
'customer_name' => $order->customer_name,
'subtotal' => 3000,
'discount' => 0,
'payment' => 3000,
'payment_method' => $order->payment_method,
'order_status' => OrderStatus::CANCELLED->value, // Change to Gagal
'order_channel' => $order->order_channel,
'items' => [
// Even if we send items, they shouldn't be deleted/updated based on my early return
[
'product_id' => $product->id,
'qty' => 5, // different qty
'price' => 1000,
'total' => 5000,
'price_type' => PriceType::RETAIL->value,
],
],
];
patchJson(route('order.update', $order), $newData)
->assertRedirect();
// Stock should be restored (7 + 3 = 10)
expect($product->fresh()->stock)->toBe(10);
// Items should be preserved (original item with qty 3, NOT deleted/recreated with qty 5)
expect($order->items()->count())->toBe(1);
expect($order->items()->first()->qty)->toBe(3);
expect($order->items()->first()->id)->toBe($item->id); // Same ID means not deleted/recreated
});
it('can delete an order and restore stock', function () {
$product = Product::factory()->create(['stock' => 5]);
$order = Order::factory()->create();
@ -275,6 +429,12 @@
actingAs(createUnauthorizedUser());
});
it('cannot view an order without permission', function () {
$order = Order::factory()->create();
get(route('order.show', $order))
->assertStatus(403);
});
it('cannot store an order without permission', function () {
postJson(route('order.store'), ['customer_name' => 'Unauthorized'])
->assertStatus(403);

View File

@ -139,6 +139,61 @@
->assertJsonValidationErrors(['purchase_date', 'items']);
});
it('returns flash error when stock is insufficient on purchase update', function () {
$product = Product::factory()->create(['stock' => 0]);
$purchase = Purchase::create([
'purchase_date' => now()->format('Y-m-d'),
'note' => 'Old Note',
'total' => 5000,
]);
$purchase->items()->create([
'user_id' => auth()->id(),
'product_id' => $product->id,
'quantity' => 5,
'unit_price' => 1000,
'total_price' => 5000,
]);
$newData = [
'purchase_date' => now()->format('Y-m-d'),
'note' => 'Updated Note',
'items' => [
[
'product_id' => $product->id,
'quantity' => 10,
'unit_price' => 2000,
],
],
];
patchJson(route('purchase.update', $purchase), $newData)
->assertRedirect()
->assertSessionHas('error');
expect($purchase->fresh()->note)->toBe('Old Note');
});
it('returns flash error when stock is insufficient on purchase delete', function () {
$product = Product::factory()->create(['stock' => 0]);
$purchase = Purchase::factory()->create();
$purchase->items()->create([
'user_id' => auth()->id(),
'product_id' => $product->id,
'quantity' => 5,
'unit_price' => 1000,
'total_price' => 5000,
]);
deleteJson(route('purchase.destroy', $purchase))
->assertRedirect()
->assertSessionHas('error');
expect(Purchase::find($purchase->id))->not->toBeNull();
});
it('can access purchase edit page', function () {
$purchase = Purchase::factory()->create();

View File

@ -50,17 +50,38 @@
->assertInertia(fn ($page) => $page
->component('admin/master/user/index')
->has('users', fn ($page) => $page->has('data')->etc())
->has('roles')
->has('defaultPassword')
);
});
it('excludes users with Developer role from index', function () {
$developerRole = Role::create(['name' => 'Developer']);
$developer = User::factory()->create();
$developer->assignRole($developerRole);
$regularUser = User::factory()->create();
get(route('user.index'))
->assertOk()
->assertInertia(fn ($page) => $page
->has('users.data', fn ($data) => $data
->each(fn ($user) => $user->where('id', fn ($id) => $id !== $developer->id)->etc())
->etc()
)
);
});
it('can access user create page', function () {
Role::create(['name' => 'Developer']);
Role::create(['name' => 'Admin']);
get(route('user.create'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('admin/master/user/create')
->has('roles')
->has('roles', fn ($roles) => $roles
->each(fn ($role) => $role->where('name', fn ($name) => $name !== 'Developer')->etc())
)
);
});
@ -101,10 +122,12 @@
it('validates user creation', function () {
postJson(route('user.store'), [])
->assertStatus(422)
->assertJsonValidationErrors(['username', 'email', 'nik', 'full_name', 'phone_number', 'address', 'birth_place', 'birth_date', 'base_salary']);
->assertJsonValidationErrors(['username', 'email', 'nik', 'full_name', 'phone_number', 'address', 'birth_place', 'birth_date', 'base_salary', 'roles']);
});
it('can access user edit page', function () {
Role::create(['name' => 'Developer']);
Role::create(['name' => 'Admin']);
$user = User::factory()->create();
get(route('user.edit', $user))
@ -112,7 +135,9 @@
->assertInertia(fn ($page) => $page
->component('admin/master/user/edit')
->has('user')
->has('roles')
->has('roles', fn ($roles) => $roles
->each(fn ($role) => $role->where('name', fn ($name) => $name !== 'Developer')->etc())
)
);
});

View File

@ -1,6 +1,6 @@
<?php
use App\Models\GeneralSetting;
use App\Settings\GeneralSettings;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
@ -49,19 +49,11 @@
it('can update general settings with logo and icon variants', function () {
Storage::fake('public');
// Ensure a setting exists first for testing update logic in Controller
$setting = GeneralSetting::create([
'name' => 'Old Name',
'description' => 'Old Description',
'address' => 'Old Address',
'phone' => '000',
]);
$data = [
'name' => 'VNGrup Dress',
'description' => 'Toko baju premium',
'address' => 'Jakarta, Indonesia',
'phone' => '081234567890',
'site_name' => 'VNGrup Dress',
'site_description' => 'Toko baju premium',
'site_address' => 'Jakarta, Indonesia',
'site_phone' => '081234567890',
'logo_light' => UploadedFile::fake()->image('logo_light.png'),
'logo_dark' => UploadedFile::fake()->image('logo_dark.png'),
'icon_light' => UploadedFile::fake()->image('icon_light.png'),
@ -72,26 +64,34 @@
->assertRedirect()
->assertSessionHas('success');
assertDatabaseHas('general_settings', [
'id' => $setting->id,
'name' => 'VNGrup Dress',
'phone' => '081234567890',
assertDatabaseHas('settings', [
'group' => 'general',
'name' => 'site_name',
'payload' => json_encode('VNGrup Dress'),
]);
$setting->refresh();
expect($setting->getFirstMediaUrl('logo_light'))->not->toBeEmpty();
expect($setting->getFirstMediaUrl('logo_dark'))->not->toBeEmpty();
expect($setting->getFirstMediaUrl('icon_light'))->not->toBeEmpty();
expect($setting->getFirstMediaUrl('icon_dark'))->not->toBeEmpty();
assertDatabaseHas('settings', [
'group' => 'general',
'name' => 'site_phone',
'payload' => json_encode('081234567890'),
]);
$settings = app(GeneralSettings::class);
expect($settings->logo_light)->not->toBeNull();
expect($settings->logo_dark)->not->toBeNull();
expect($settings->icon_light)->not->toBeNull();
expect($settings->icon_dark)->not->toBeNull();
Storage::disk('public')->assertExists($settings->logo_light);
Storage::disk('public')->assertExists($settings->logo_dark);
Storage::disk('public')->assertExists($settings->icon_light);
Storage::disk('public')->assertExists($settings->icon_dark);
});
it('validates settings update with new variants', function () {
// If setting doesn't exist, logo_light and icon_light are required
GeneralSetting::truncate();
postJson(route('system.settings.update'), [])
->assertStatus(422)
->assertJsonValidationErrors(['name', 'description', 'address', 'phone', 'logo_light', 'icon_light']);
->assertJsonValidationErrors(['site_name', 'site_description', 'site_address', 'site_phone']);
});
});
@ -101,7 +101,7 @@
});
it('cannot update settings without permission', function () {
postJson(route('system.settings.update'), ['name' => 'Unauthorized'])
postJson(route('system.settings.update'), ['site_name' => 'Unauthorized'])
->assertStatus(403);
});
});