Compare commits
6 Commits
910e6b97b6
...
4d94fbe7c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d94fbe7c6 | ||
|
|
3e715518fb | ||
|
|
42f7214efa | ||
|
|
a301e96f33 | ||
|
|
f32544ccce | ||
|
|
1f47ec1939 |
@ -10,6 +10,7 @@ enum CashTransactionType: string
|
||||
|
||||
case DEPOSIT = 'deposit';
|
||||
case EXPENSE = 'expense';
|
||||
case TRANSACTION = 'transaction';
|
||||
case WITHDRAWAL = 'withdrawal';
|
||||
case EMPLOYEE_ADVANCE = 'employee_advance';
|
||||
case SALARY = 'salary';
|
||||
@ -19,6 +20,7 @@ public function label(): string
|
||||
return match ($this) {
|
||||
self::DEPOSIT => 'Deposit',
|
||||
self::EXPENSE => 'Pengeluaran',
|
||||
self::TRANSACTION => 'Transaksi',
|
||||
self::WITHDRAWAL => 'Withdrawal',
|
||||
self::EMPLOYEE_ADVANCE => 'Kasbon',
|
||||
self::SALARY => 'Gaji',
|
||||
|
||||
@ -45,6 +45,7 @@ public function index(Request $request): Response
|
||||
$marketingSales = $this->service->getMarketingSales($startDate, $endDate, $user);
|
||||
$orderStats = $this->service->getOrderStats($startDate, $endDate, $user);
|
||||
$revenueTrend = $this->service->getRevenueTrend($startDate, $endDate, $user);
|
||||
$monthlyRetailRevenue = $this->service->getMonthlyRetailRevenue($startDate, $endDate, $user);
|
||||
|
||||
return Inertia::render('admin/analysis/index', [
|
||||
'filters' => [
|
||||
@ -72,6 +73,7 @@ public function index(Request $request): Response
|
||||
'marketingSales' => $marketingSales,
|
||||
'orderStats' => $orderStats,
|
||||
'revenueTrend' => $revenueTrend,
|
||||
'monthlyRetailRevenue' => $monthlyRetailRevenue,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,6 +75,12 @@ protected function expenseType(Builder $query): void
|
||||
$query->where('type', CashTransactionType::EXPENSE);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function transactionType(Builder $query): void
|
||||
{
|
||||
$query->where('type', CashTransactionType::TRANSACTION);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function transfer(Builder $query): void
|
||||
{
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Admin\Manage;
|
||||
|
||||
use App\Enums\CashTransactionType;
|
||||
use App\Enums\OrderChannel;
|
||||
use App\Enums\OrderStatus;
|
||||
use App\Enums\PaymentType;
|
||||
@ -14,6 +15,8 @@
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\HasStockAdjustment;
|
||||
use App\Services\Concerns\HandlesCashTransactions;
|
||||
use App\Services\Concerns\LogsFormHistory;
|
||||
use App\Services\Concerns\RegistersMedia;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
@ -23,7 +26,7 @@
|
||||
|
||||
class TransactionService
|
||||
{
|
||||
use HasStockAdjustment, RegistersMedia;
|
||||
use HasStockAdjustment, HandlesCashTransactions, LogsFormHistory, RegistersMedia;
|
||||
|
||||
private const SELLING_PRICE_MAP = [
|
||||
PriceType::DISTRIBUTOR->value => PriceType::DISTRIBUTOR,
|
||||
@ -233,7 +236,14 @@ public function store(array $data): Order
|
||||
|
||||
$this->applyStock($data['items'], $stockType, -1);
|
||||
|
||||
if ($paymentType !== PaymentType::CASH->value) {
|
||||
if ($paymentType === PaymentType::CASH->value) {
|
||||
$cashTransaction = $this->creditCash(
|
||||
amount: $totalAmount,
|
||||
description: 'Pembayaran tunai: '.$order->order_number,
|
||||
type: CashTransactionType::TRANSACTION,
|
||||
);
|
||||
$order->update(['cash_transaction_id' => $cashTransaction->id]);
|
||||
} else {
|
||||
$this->syncPhoto($order, $data);
|
||||
}
|
||||
|
||||
@ -244,15 +254,20 @@ public function store(array $data): Order
|
||||
url: route('admin.manage.transactions.index', ['highlight' => $order->id]),
|
||||
);
|
||||
|
||||
$this->logCreated($order, 'Transaksi', $this->getOrderLogValues($order));
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
public function update(Order $order, array $data): Order
|
||||
{
|
||||
$oldValues = $this->getOrderLogValues($order);
|
||||
|
||||
$order = DB::transaction(function () use ($order, $data) {
|
||||
$order->load('orderItems');
|
||||
|
||||
$oldPaymentType = $order->payment_type;
|
||||
$oldStockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
||||
|
||||
$order->orderItems->each(function (OrderItem $item) use ($oldStockType) {
|
||||
@ -284,13 +299,15 @@ public function update(Order $order, array $data): Order
|
||||
}
|
||||
DB::table('order_items')->insert($itemRows);
|
||||
|
||||
$newPaymentType = $data['payment_type'] ?? $order->payment_type->value;
|
||||
|
||||
$order->update([
|
||||
'customer_id' => $data['customer_id'] ?? null,
|
||||
'marketing_id' => $data['marketing_id'] ?? null,
|
||||
'channel' => $data['channel'] ?? $order->channel->value,
|
||||
'price_type' => $priceType,
|
||||
'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING,
|
||||
'payment_type' => $data['payment_type'] ?? $order->payment_type->value,
|
||||
'payment_type' => $newPaymentType,
|
||||
'tiktok_order_id' => $data['tiktok_order_id'] ?? null,
|
||||
'shopee_order_id' => $data['shopee_order_id'] ?? null,
|
||||
'subtotal' => $subtotal,
|
||||
@ -303,11 +320,39 @@ public function update(Order $order, array $data): Order
|
||||
|
||||
$this->applyStock($data['items'], $stockType, -1);
|
||||
|
||||
$paymentType = $data['payment_type'] ?? $order->payment_type->value;
|
||||
if ($paymentType !== PaymentType::CASH->value) {
|
||||
$this->syncPhoto($order, $data);
|
||||
} else {
|
||||
if ($oldPaymentType === PaymentType::CASH && $newPaymentType !== PaymentType::CASH->value) {
|
||||
if ($order->cash_transaction_id) {
|
||||
$order->cashTransaction()->delete();
|
||||
$order->update(['cash_transaction_id' => null]);
|
||||
}
|
||||
} elseif ($oldPaymentType !== PaymentType::CASH && $newPaymentType === PaymentType::CASH->value) {
|
||||
$cashTransaction = $this->creditCash(
|
||||
amount: $totalAmount,
|
||||
description: 'Pembayaran tunai: '.$order->order_number,
|
||||
type: CashTransactionType::TRANSACTION,
|
||||
);
|
||||
$order->update(['cash_transaction_id' => $cashTransaction->id]);
|
||||
} elseif ($oldPaymentType === PaymentType::CASH && $newPaymentType === PaymentType::CASH->value && $order->cash_transaction_id) {
|
||||
$cashTransaction = $order->cashTransaction;
|
||||
$oldAmount = $cashTransaction->amount;
|
||||
$difference = $totalAmount - $oldAmount;
|
||||
|
||||
if ($difference !== 0) {
|
||||
$cashAccount = \App\Models\CashAccount::firstOrFail();
|
||||
$newBalance = $cashAccount->balance + $difference;
|
||||
$cashAccount->update(['balance' => $newBalance]);
|
||||
|
||||
$cashTransaction->update([
|
||||
'amount' => $totalAmount,
|
||||
'balance_after' => $newBalance,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($newPaymentType === PaymentType::CASH->value) {
|
||||
$order->clearMediaCollection('photos');
|
||||
} else {
|
||||
$this->syncPhoto($order, $data);
|
||||
}
|
||||
|
||||
return $order;
|
||||
@ -320,11 +365,15 @@ public function update(Order $order, array $data): Order
|
||||
url: route('admin.manage.transactions.index', ['highlight' => $order->id]),
|
||||
);
|
||||
|
||||
$this->logUpdated($order, 'Transaksi', $oldValues, $this->getOrderLogValues($order));
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
public function destroy(Order $order): bool
|
||||
{
|
||||
$oldValues = $this->getOrderLogValues($order);
|
||||
|
||||
$result = DB::transaction(function () use ($order) {
|
||||
$order->load('orderItems');
|
||||
|
||||
@ -336,6 +385,14 @@ public function destroy(Order $order): bool
|
||||
});
|
||||
}
|
||||
|
||||
if ($order->payment_type === PaymentType::CASH && $order->cash_transaction_id) {
|
||||
$this->debitCash(
|
||||
amount: $order->total_amount,
|
||||
description: 'Pembatalan transaksi: '.$order->order_number,
|
||||
type: CashTransactionType::EXPENSE,
|
||||
);
|
||||
}
|
||||
|
||||
$order->orderItems()->delete();
|
||||
$order->delete();
|
||||
|
||||
@ -349,11 +406,14 @@ public function destroy(Order $order): bool
|
||||
url: route('admin.manage.transactions.index'),
|
||||
);
|
||||
|
||||
$this->logDeleted($order, 'Transaksi', $oldValues);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function updateStatus(Order $order, string $status): Order
|
||||
{
|
||||
$oldValues = $this->getOrderLogValues($order);
|
||||
$oldStatus = $order->status->value;
|
||||
|
||||
$order->update(['status' => $status]);
|
||||
@ -365,8 +425,18 @@ public function updateStatus(Order $order, string $status): Order
|
||||
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
|
||||
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
|
||||
});
|
||||
|
||||
if ($order->payment_type === PaymentType::CASH && $order->cash_transaction_id) {
|
||||
$this->debitCash(
|
||||
amount: $order->total_amount,
|
||||
description: 'Pembatalan transaksi: '.$order->order_number,
|
||||
type: CashTransactionType::EXPENSE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logUpdated($order, 'Transaksi', $oldValues, $this->getOrderLogValues($order));
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
@ -468,4 +538,30 @@ private function isMarketingUser(User $user): bool
|
||||
Role::MARKETING_ONLINE->value,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getOrderLogValues(Order $order): array
|
||||
{
|
||||
$order->load(['customer:id,name', 'marketing:id', 'marketing.userProfile:id,user_id,full_name', 'orderItems.productVariant.product:id,name']);
|
||||
|
||||
return [
|
||||
'No. Transaksi' => $order->order_number,
|
||||
'Customer' => $order->customer?->name ?? '-',
|
||||
'Marketing' => $order->marketing?->userProfile?->full_name ?? '-',
|
||||
'Channel' => $order->channel?->label(),
|
||||
'Tipe Harga' => $order->price_type?->label(),
|
||||
'Status' => $order->status?->label(),
|
||||
'Tipe Pembayaran' => $order->payment_type?->label(),
|
||||
'Subtotal' => $this->formatCurrency($order->subtotal),
|
||||
'Diskon' => $this->formatCurrency($order->discount),
|
||||
'Harga Nego' => $order->nego_price ? $this->formatCurrency($order->nego_price) : null,
|
||||
'Total' => $this->formatCurrency($order->total_amount),
|
||||
'Catatan' => $order->notes,
|
||||
'Item' => $order->orderItems->map(fn ($item) => [
|
||||
'Nama' => $item->productVariant?->product?->name.' - '.$item->productVariant?->name ?? '-',
|
||||
'Qty' => $item->quantity,
|
||||
'Harga' => $this->formatCurrency($item->unit_price),
|
||||
'Subtotal' => $this->formatCurrency($item->subtotal),
|
||||
])->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,14 @@
|
||||
namespace App\Services\Admin\Master;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Services\Concerns\LogsFormHistory;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class CustomerService
|
||||
{
|
||||
use LogsFormHistory;
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
return Customer::query()
|
||||
@ -24,18 +27,44 @@ public function getAll(): Collection
|
||||
|
||||
public function store(array $data): Customer
|
||||
{
|
||||
return Customer::create($data);
|
||||
$customer = Customer::create($data);
|
||||
|
||||
$this->logCreated($customer, 'Customer', [
|
||||
'Nama' => $customer->name,
|
||||
'No. Telepon' => $customer->phone_number,
|
||||
'Alamat' => $customer->address,
|
||||
]);
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
public function update(Customer $customer, array $data): Customer
|
||||
{
|
||||
$oldValues = [
|
||||
'Nama' => $customer->name,
|
||||
'No. Telepon' => $customer->phone_number,
|
||||
'Alamat' => $customer->address,
|
||||
];
|
||||
|
||||
$customer->update($data);
|
||||
|
||||
$this->logUpdated($customer, 'Customer', $oldValues, [
|
||||
'Nama' => $customer->name,
|
||||
'No. Telepon' => $customer->phone_number,
|
||||
'Alamat' => $customer->address,
|
||||
]);
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
public function destroy(Customer $customer): bool
|
||||
{
|
||||
$this->logDeleted($customer, 'Customer', [
|
||||
'Nama' => $customer->name,
|
||||
'No. Telepon' => $customer->phone_number,
|
||||
'Alamat' => $customer->address,
|
||||
]);
|
||||
|
||||
return $customer->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@ -468,6 +468,8 @@ public function toggleStatus(Product $product): void
|
||||
{
|
||||
$this->assertNotPending($product);
|
||||
|
||||
$oldValues = $this->getProductLogValues($product);
|
||||
|
||||
$product->update([
|
||||
'status' => $product->status->value === 'active' ? 'inactive' : 'active',
|
||||
]);
|
||||
@ -480,12 +482,16 @@ public function toggleStatus(Product $product): void
|
||||
body: "Produk \"{$product->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
||||
);
|
||||
|
||||
$this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product));
|
||||
}
|
||||
|
||||
public function toggleFeatured(Product $product): void
|
||||
{
|
||||
$this->assertNotPending($product);
|
||||
|
||||
$oldValues = $this->getProductLogValues($product);
|
||||
|
||||
$product->update([
|
||||
'is_featured' => ! $product->is_featured,
|
||||
]);
|
||||
@ -498,10 +504,14 @@ public function toggleFeatured(Product $product): void
|
||||
body: "Produk \"{$product->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
||||
);
|
||||
|
||||
$this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product));
|
||||
}
|
||||
|
||||
public function approve(Product $product): void
|
||||
{
|
||||
$oldValues = $this->getProductLogValues($product);
|
||||
|
||||
$product->update([
|
||||
'status' => ProductStatus::ACTIVE,
|
||||
]);
|
||||
@ -513,10 +523,14 @@ public function approve(Product $product): void
|
||||
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
||||
additionalUser: $product->createdBy ?? null,
|
||||
);
|
||||
|
||||
$this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product));
|
||||
}
|
||||
|
||||
public function reject(Product $product, string $reason = ''): void
|
||||
{
|
||||
$oldValues = $this->getProductLogValues($product);
|
||||
|
||||
$product->update([
|
||||
'status' => ProductStatus::REJECTED,
|
||||
'rejection_reason' => $reason,
|
||||
@ -529,10 +543,14 @@ public function reject(Product $product, string $reason = ''): void
|
||||
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
||||
additionalUser: $product->createdBy ?? null,
|
||||
);
|
||||
|
||||
$this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product));
|
||||
}
|
||||
|
||||
public function resubmit(Product $product): void
|
||||
{
|
||||
$oldValues = $this->getProductLogValues($product);
|
||||
|
||||
$product->update([
|
||||
'status' => ProductStatus::PENDING,
|
||||
'rejection_reason' => null,
|
||||
@ -544,6 +562,8 @@ public function resubmit(Product $product): void
|
||||
body: "Produk \"{$product->name}\" telah diajukan ulang oleh ".auth()->user()->full_name.'.',
|
||||
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
||||
);
|
||||
|
||||
$this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product));
|
||||
}
|
||||
|
||||
private function assertNotPending(Product $product): void
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Enums\Role;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Services\Concerns\LogsFormHistory;
|
||||
use App\Services\Concerns\RegistersMedia;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
@ -18,7 +19,7 @@
|
||||
|
||||
class ProductVariantService
|
||||
{
|
||||
use HasRoleChecks, RegistersMedia;
|
||||
use HasRoleChecks, LogsFormHistory, RegistersMedia;
|
||||
|
||||
public function __construct(
|
||||
private S3PresignedService $s3Service,
|
||||
@ -171,6 +172,8 @@ public function update(ProductVariant $variant, array $data): ProductVariant
|
||||
{
|
||||
$this->assertNotPending($variant->product);
|
||||
|
||||
$oldValues = $this->getProductLogValues($variant->product);
|
||||
|
||||
DB::transaction(function () use ($variant, $data) {
|
||||
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
|
||||
|
||||
@ -210,6 +213,9 @@ public function update(ProductVariant $variant, array $data): ProductVariant
|
||||
url: route('admin.master.products.index', ['highlight' => $variant->product_id]),
|
||||
);
|
||||
|
||||
$product = $variant->product()->with(['categories:id,name', 'productVariants.productPrices'])->first();
|
||||
$this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product));
|
||||
|
||||
return $variant->fresh();
|
||||
}
|
||||
|
||||
@ -217,6 +223,8 @@ public function destroy(Product $product, ProductVariant $variant): bool
|
||||
{
|
||||
$this->assertNotPending($product);
|
||||
|
||||
$oldValues = $this->getProductLogValues($product);
|
||||
|
||||
$result = DB::transaction(function () use ($variant) {
|
||||
return $variant->delete();
|
||||
});
|
||||
@ -228,6 +236,9 @@ public function destroy(Product $product, ProductVariant $variant): bool
|
||||
url: route('admin.master.products.index'),
|
||||
);
|
||||
|
||||
$product->refresh()->load(['categories:id,name', 'productVariants.productPrices']);
|
||||
$this->logDeleted($product, 'Produk', $oldValues);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@ -237,6 +248,8 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
|
||||
|
||||
$this->assertNotPending($variant->product);
|
||||
|
||||
$oldValues = $this->getProductLogValues($variant->product);
|
||||
|
||||
if ($variant->stock < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => "Stok bagus tidak mencukupi. Stok tersedia: {$variant->stock}.",
|
||||
@ -270,6 +283,9 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
|
||||
url: route('admin.master.products.index', ['highlight' => $variant->product_id]),
|
||||
);
|
||||
|
||||
$product = $variant->product()->with(['categories:id,name', 'productVariants.productPrices'])->first();
|
||||
$this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product));
|
||||
|
||||
return $variant->fresh();
|
||||
}
|
||||
|
||||
@ -281,4 +297,45 @@ private function assertNotPending(Product $product): void
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function getProductLogValues(Product $product): array
|
||||
{
|
||||
$product->load(['categories:id,name', 'productVariants.productPrices']);
|
||||
|
||||
$priceTypeLabels = [
|
||||
'distributor' => 'Harga Distributor',
|
||||
'agent' => 'Harga Agen',
|
||||
'sub_agent' => 'Harga Sub Agen',
|
||||
'wholesale' => 'Harga Grosir',
|
||||
'retail' => 'Harga Ecer',
|
||||
'tiktok' => 'Harga TikTok',
|
||||
'shopee' => 'Harga Shopee',
|
||||
'capital' => 'Harga Modal',
|
||||
'reject_capital' => 'Harga Reject Modal',
|
||||
'reject_selling' => 'Harga Reject Jual',
|
||||
];
|
||||
|
||||
return [
|
||||
'Nama Produk' => $product->name,
|
||||
'Status' => $product->status?->label(),
|
||||
'Unggulan' => $this->formatBoolean($product->is_featured),
|
||||
'Kategori' => $product->categories->pluck('name')->toArray(),
|
||||
'Deskripsi' => $product->description,
|
||||
'Varian' => $product->productVariants->map(function ($variant) use ($priceTypeLabels) {
|
||||
$variantData = [
|
||||
'Nama Varian' => $variant->name,
|
||||
'Stok Bagus' => $variant->stock,
|
||||
'Stok Reject' => $variant->reject_stock,
|
||||
'Stok Retail' => $variant->retail_stock,
|
||||
];
|
||||
|
||||
foreach ($variant->productPrices as $price) {
|
||||
$label = $priceTypeLabels[$price->type->value] ?? $price->type->label();
|
||||
$variantData[$label] = $this->formatCurrency($price->price);
|
||||
}
|
||||
|
||||
return $variantData;
|
||||
})->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,14 @@
|
||||
namespace App\Services\Admin\Master;
|
||||
|
||||
use App\Models\Supplier;
|
||||
use App\Services\Concerns\LogsFormHistory;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class SupplierService
|
||||
{
|
||||
use LogsFormHistory;
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
return Supplier::query()
|
||||
@ -24,18 +27,44 @@ public function getAll(): Collection
|
||||
|
||||
public function store(array $data): Supplier
|
||||
{
|
||||
return Supplier::create($data);
|
||||
$supplier = Supplier::create($data);
|
||||
|
||||
$this->logCreated($supplier, 'Supplier', [
|
||||
'Nama' => $supplier->name,
|
||||
'No. Telepon' => $supplier->phone_number,
|
||||
'Alamat' => $supplier->address,
|
||||
]);
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
public function update(Supplier $supplier, array $data): Supplier
|
||||
{
|
||||
$oldValues = [
|
||||
'Nama' => $supplier->name,
|
||||
'No. Telepon' => $supplier->phone_number,
|
||||
'Alamat' => $supplier->address,
|
||||
];
|
||||
|
||||
$supplier->update($data);
|
||||
|
||||
$this->logUpdated($supplier, 'Supplier', $oldValues, [
|
||||
'Nama' => $supplier->name,
|
||||
'No. Telepon' => $supplier->phone_number,
|
||||
'Alamat' => $supplier->address,
|
||||
]);
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
public function destroy(Supplier $supplier): bool
|
||||
{
|
||||
$this->logDeleted($supplier, 'Supplier', [
|
||||
'Nama' => $supplier->name,
|
||||
'No. Telepon' => $supplier->phone_number,
|
||||
'Alamat' => $supplier->address,
|
||||
]);
|
||||
|
||||
return $supplier->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@ -445,6 +445,75 @@ public function getMonthlyRevenue(?string $startDate, ?string $endDate, ?User $u
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getMonthlyRetailRevenue(?string $startDate, ?string $endDate, ?User $user = null): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
|
||||
|
||||
if ($user && $this->isMarketingUser($user)) {
|
||||
$query->where('orders.marketing_id', $user->id);
|
||||
}
|
||||
|
||||
if ($user && $this->isCashierUser($user)) {
|
||||
$query->whereIn('orders.created_by_id', $this->getCashierUserIds());
|
||||
}
|
||||
|
||||
$retailOrderIds = OrderItem::where('stock_quality', 'retail')
|
||||
->pluck('order_id')
|
||||
->unique();
|
||||
|
||||
$query->whereIn('orders.id', $retailOrderIds);
|
||||
|
||||
$monthly = (clone $query)
|
||||
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
|
||||
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
|
||||
->selectRaw('COALESCE(SUM(discount), 0) as discount')
|
||||
->selectRaw('COALESCE(SUM(nego_price), 0) as deduction')
|
||||
->selectRaw('COALESCE(SUM(cogs), 0) as cogs')
|
||||
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
|
||||
->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
|
||||
->get()
|
||||
->keyBy('month');
|
||||
|
||||
$summary = (clone $query)
|
||||
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
|
||||
->selectRaw('COALESCE(SUM(discount), 0) as discount')
|
||||
->selectRaw('COALESCE(SUM(nego_price), 0) as deduction')
|
||||
->selectRaw('COALESCE(SUM(cogs), 0) as cogs')
|
||||
->first();
|
||||
|
||||
$gross = (int) $summary->total - (int) $summary->cogs;
|
||||
|
||||
$result = [];
|
||||
foreach ($monthly as $month => $row) {
|
||||
$total = (int) $row->total;
|
||||
$discount = (int) $row->discount;
|
||||
$deduction = (int) $row->deduction;
|
||||
$cogs = (int) $row->cogs;
|
||||
$itemGross = $total - $cogs;
|
||||
|
||||
$result[] = [
|
||||
'month' => $month,
|
||||
'total' => $total,
|
||||
'gross' => $itemGross,
|
||||
'discount' => $discount,
|
||||
'deduction' => $deduction,
|
||||
'cogs' => $cogs,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'monthly' => $result,
|
||||
'summary' => [
|
||||
'total' => (int) $summary->total,
|
||||
'discount' => (int) $summary->discount,
|
||||
'deduction' => (int) $summary->deduction,
|
||||
'cogs' => (int) $summary->cogs,
|
||||
'gross' => $gross,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate, ?User $user = null): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||
|
||||
@ -10,56 +10,191 @@ trait LogsFormHistory
|
||||
{
|
||||
private function logCreated(Model $model, string $module, array $newValues): void
|
||||
{
|
||||
$this->createFormHistory(
|
||||
module: $module,
|
||||
event: 'created',
|
||||
description: "{$module} ditambahkan",
|
||||
newValues: $newValues,
|
||||
);
|
||||
}
|
||||
|
||||
private function logUpdated(Model $model, string $module, array $oldValues, array $newValues): void
|
||||
{
|
||||
$this->createFormHistory(
|
||||
module: $module,
|
||||
event: 'updated',
|
||||
description: "{$module} diperbarui",
|
||||
newValues: $newValues,
|
||||
oldValues: $oldValues,
|
||||
);
|
||||
}
|
||||
|
||||
private function logDeleted(Model $model, string $module, array $oldValues): void
|
||||
{
|
||||
$this->createFormHistory(
|
||||
module: $module,
|
||||
event: 'deleted',
|
||||
description: "{$module} dihapus",
|
||||
oldValues: $oldValues,
|
||||
);
|
||||
}
|
||||
|
||||
private function createFormHistory(
|
||||
string $module,
|
||||
string $event,
|
||||
string $description,
|
||||
array $newValues = [],
|
||||
array $oldValues = [],
|
||||
): void {
|
||||
$attributeChanges = array_filter([
|
||||
'new' => $newValues ?: null,
|
||||
'old' => $oldValues ?: null,
|
||||
]);
|
||||
$changes = $this->buildCreatedChanges($newValues);
|
||||
|
||||
FormHistory::create([
|
||||
'causer_id' => Auth::id(),
|
||||
'module' => $module,
|
||||
'event' => $event,
|
||||
'description' => $description,
|
||||
'attribute_changes' => $attributeChanges ?: null,
|
||||
'event' => 'created',
|
||||
'description' => "{$module} ditambahkan",
|
||||
'attribute_changes' => $changes ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function logUpdated(Model $model, string $module, array $oldValues, array $newValues): void
|
||||
{
|
||||
$changes = $this->buildUpdatedChanges($oldValues, $newValues);
|
||||
|
||||
FormHistory::create([
|
||||
'causer_id' => Auth::id(),
|
||||
'module' => $module,
|
||||
'event' => 'updated',
|
||||
'description' => "{$module} diperbarui",
|
||||
'attribute_changes' => $changes ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function logDeleted(Model $model, string $module, array $oldValues): void
|
||||
{
|
||||
$changes = $this->buildDeletedChanges($oldValues);
|
||||
|
||||
FormHistory::create([
|
||||
'causer_id' => Auth::id(),
|
||||
'module' => $module,
|
||||
'event' => 'deleted',
|
||||
'description' => "{$module} dihapus",
|
||||
'attribute_changes' => $changes ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveItemName(array $item): string
|
||||
{
|
||||
$nameKeys = ['Nama Varian', 'Nama', 'name', 'variant', 'Variant', 'variant_name'];
|
||||
|
||||
foreach ($nameKeys as $nameKey) {
|
||||
if (! empty($item[$nameKey])) {
|
||||
return (string) $item[$nameKey];
|
||||
}
|
||||
}
|
||||
|
||||
return '?';
|
||||
}
|
||||
|
||||
private function buildCreatedChanges(array $newValues): array
|
||||
{
|
||||
$changes = [];
|
||||
|
||||
foreach ($newValues as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $item) {
|
||||
if (is_array($item)) {
|
||||
$itemName = $this->resolveItemName($item);
|
||||
foreach ($item as $subKey => $subValue) {
|
||||
$changes["{$key}: {$itemName} — {$subKey}"] = ['new' => $subValue];
|
||||
}
|
||||
} else {
|
||||
$changes["{$key}: {$item}"] = ['new' => null];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$changes[$key] = ['new' => $value];
|
||||
}
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
private function buildUpdatedChanges(array $oldValues, array $newValues): array
|
||||
{
|
||||
$changes = [];
|
||||
$allKeys = array_unique(array_merge(array_keys($oldValues), array_keys($newValues)));
|
||||
|
||||
foreach ($allKeys as $key) {
|
||||
$old = $oldValues[$key] ?? null;
|
||||
$new = $newValues[$key] ?? null;
|
||||
|
||||
if (is_array($old) && is_array($new)) {
|
||||
$nestedChanges = $this->diffArray($old, $new, $key);
|
||||
$changes = array_merge($changes, $nestedChanges);
|
||||
} elseif ($old !== $new) {
|
||||
$changes[$key] = array_filter([
|
||||
'old' => $old ?? null,
|
||||
'new' => $new ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
private function buildDeletedChanges(array $oldValues): array
|
||||
{
|
||||
$changes = [];
|
||||
|
||||
foreach ($oldValues as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $item) {
|
||||
if (is_array($item)) {
|
||||
$itemName = $this->resolveItemName($item);
|
||||
foreach ($item as $subKey => $subValue) {
|
||||
$changes["{$key}: {$itemName} — {$subKey}"] = ['old' => $subValue];
|
||||
}
|
||||
} else {
|
||||
$changes["{$key}: {$item}"] = ['old' => null];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$changes[$key] = ['old' => $value];
|
||||
}
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
private function diffArray(array $old, array $new, string $prefix = ''): array
|
||||
{
|
||||
$changes = [];
|
||||
|
||||
$isAssociative = ! empty($old) && array_is_list($old) === false;
|
||||
|
||||
if ($isAssociative) {
|
||||
$allKeys = array_unique(array_merge(array_keys($old), array_keys($new)));
|
||||
|
||||
foreach ($allKeys as $key) {
|
||||
$fullKey = "{$prefix}.{$key}";
|
||||
|
||||
if (isset($old[$key]) && is_array($old[$key]) && isset($new[$key]) && is_array($new[$key])) {
|
||||
$nested = $this->diffArray($old[$key], $new[$key], $fullKey);
|
||||
$changes = array_merge($changes, $nested);
|
||||
} else {
|
||||
$oldVal = $old[$key] ?? null;
|
||||
$newVal = $new[$key] ?? null;
|
||||
|
||||
if ($oldVal !== $newVal) {
|
||||
$changes[$fullKey] = array_filter([
|
||||
'old' => $oldVal ?? null,
|
||||
'new' => $newVal ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$max = max(count($old), count($new));
|
||||
|
||||
for ($i = 0; $i < $max; $i++) {
|
||||
$oldItem = $old[$i] ?? null;
|
||||
$newItem = $new[$i] ?? null;
|
||||
|
||||
if (is_array($oldItem) && is_array($newItem)) {
|
||||
$itemName = $this->resolveItemName($newItem);
|
||||
$itemPrefix = "{$prefix}: {$itemName}";
|
||||
$itemKeys = array_unique(array_merge(array_keys($oldItem), array_keys($newItem)));
|
||||
|
||||
foreach ($itemKeys as $subKey) {
|
||||
$fullKey = "{$itemPrefix} — {$subKey}";
|
||||
$oldVal = $oldItem[$subKey] ?? null;
|
||||
$newVal = $newItem[$subKey] ?? null;
|
||||
|
||||
if ($oldVal !== $newVal) {
|
||||
$changes[$fullKey] = array_filter([
|
||||
'old' => $oldVal ?? null,
|
||||
'new' => $newVal ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} elseif ($oldItem !== $newItem) {
|
||||
$itemName = $this->resolveItemName(is_array($oldItem) ? $oldItem : (is_array($newItem) ? $newItem : []));
|
||||
$changes["{$prefix}: {$itemName}"] = array_filter([
|
||||
'old' => $oldItem,
|
||||
'new' => $newItem,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
private function formatCurrency(int $value): string
|
||||
{
|
||||
return 'Rp ' . number_format($value, 0, ',', '.');
|
||||
|
||||
@ -203,6 +203,23 @@ type AnalysisProps = {
|
||||
date: string;
|
||||
qty: number;
|
||||
}>;
|
||||
monthlyRetailRevenue: {
|
||||
monthly: Array<{
|
||||
month: string;
|
||||
total: number;
|
||||
gross: number;
|
||||
discount: number;
|
||||
deduction: number;
|
||||
cogs: number;
|
||||
}>;
|
||||
summary: {
|
||||
total: number;
|
||||
discount: number;
|
||||
deduction: number;
|
||||
cogs: number;
|
||||
gross: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const revenueChartConfig = (() => {
|
||||
@ -219,6 +236,19 @@ const revenueChartConfig = (() => {
|
||||
|
||||
const revenueKeys = ['total', 'deduction', 'discount', 'cogs', 'gross', 'net'] as const;
|
||||
|
||||
const retailRevenueChartConfig = (() => {
|
||||
const colors = generateRandomColors(5);
|
||||
return {
|
||||
total: { label: 'Total', color: colors[0] },
|
||||
gross: { label: 'Keuntungan Kotor', color: colors[1] },
|
||||
deduction: { label: 'Potongan Nego', color: colors[2] },
|
||||
discount: { label: 'Diskon', color: colors[3] },
|
||||
cogs: { label: 'HPP', color: colors[4] },
|
||||
} satisfies ChartConfig;
|
||||
})();
|
||||
|
||||
const retailRevenueKeys = ['total', 'deduction', 'discount', 'cogs', 'gross'] as const;
|
||||
|
||||
const expenseChartConfig = (() => {
|
||||
const colors = generateRandomColors(4);
|
||||
return {
|
||||
@ -450,12 +480,14 @@ export default function Analysis({
|
||||
marketingSales,
|
||||
orderStats,
|
||||
revenueTrend,
|
||||
monthlyRetailRevenue,
|
||||
}: AnalysisProps) {
|
||||
const { can, hasAnyRole, hasRole } = useCan();
|
||||
const [startDate, setStartDate] = useState(initialFilters.start_date ?? '');
|
||||
const [endDate, setEndDate] = useState(initialFilters.end_date ?? '');
|
||||
const [selectedPreset, setSelectedPreset] = useState('');
|
||||
const [activeRevenueKey, setActiveRevenueKey] = useState<keyof typeof revenueChartConfig>('total');
|
||||
const [activeRetailRevenueKey, setActiveRetailRevenueKey] = useState<keyof typeof retailRevenueChartConfig>('total');
|
||||
const [activeExpenseKey, setActiveExpenseKey] = useState<keyof typeof expenseChartConfig>('total');
|
||||
const [activeStockKey, setActiveStockKey] = useState<'good' | 'reject' | 'retail'>('good');
|
||||
|
||||
@ -745,6 +777,11 @@ export default function Analysis({
|
||||
</span>
|
||||
<span className="text-xs leading-none font-semibold sm:text-sm">
|
||||
Rp{formatRupiah(value)}
|
||||
{key === 'net' && revenueSummary.total_revenue > 0 && (
|
||||
<span className="ml-1 text-[10px] font-normal text-muted-foreground">
|
||||
({((revenueSummary.net / revenueSummary.total_revenue) * 100).toFixed(1)}%)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@ -873,6 +910,70 @@ export default function Analysis({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can('analysis.revenue') && (
|
||||
<Card className="py-0" style={{ order: (sectionOrder.revenueByChannel ?? 99) + 0.5 }}>
|
||||
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
||||
<CardTitle>Pendapatan Penjualan Ecer</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
{retailRevenueKeys.map((key) => {
|
||||
const value = key === 'total' ? monthlyRetailRevenue.summary.total : key === 'discount' ? monthlyRetailRevenue.summary.discount : key === 'gross' ? monthlyRetailRevenue.summary.gross : key === 'cogs' ? monthlyRetailRevenue.summary.cogs : monthlyRetailRevenue.summary.deduction;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
data-active={activeRetailRevenueKey === key}
|
||||
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l data-[active=true]:bg-muted/30 sm:border-t-0 sm:border-l sm:px-5 sm:py-3"
|
||||
onClick={() => setActiveRetailRevenueKey(key)}
|
||||
>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{retailRevenueChartConfig[key].label}
|
||||
</span>
|
||||
<span className="text-xs leading-none font-semibold sm:text-sm">
|
||||
Rp{formatRupiah(value)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
{monthlyRetailRevenue.monthly.length > 0 ? (
|
||||
<ChartContainer config={retailRevenueChartConfig} className="aspect-auto h-[250px] w-full">
|
||||
<BarChart data={monthlyRetailRevenue.monthly} margin={{ left: 12, right: 12 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
formatter={(value, name, item, index) => (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
||||
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
||||
/>
|
||||
<span className="text-muted-foreground">{retailRevenueChartConfig[activeRetailRevenueKey]?.label ?? name}</span>
|
||||
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
||||
Rp{Number(value).toLocaleString('id-ID')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey={activeRetailRevenueKey} fill={`var(--color-${activeRetailRevenueKey})`} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pendapatan ecer</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{can('analysis.revenue') && (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4" style={{ order: sectionOrder.orderPie ?? 99 }}>
|
||||
<DashboardPieChart
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { RowActions } from '@/components/data-display';
|
||||
import { ImagePreviewButton } from '@/components/dialogs';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { ImagePreviewButton } from '@/components/dialogs';
|
||||
import { RowActions } from '@/components/data-display';
|
||||
|
||||
export type CashTransaction = {
|
||||
id: number;
|
||||
@ -32,7 +32,8 @@ function getTypeLabel(type: string): string {
|
||||
withdrawal: 'Withdrawal',
|
||||
expense: 'Pengeluaran',
|
||||
employee_advance: 'Kasbon',
|
||||
salary : 'Gaji'
|
||||
salary: 'Gaji',
|
||||
transaction: 'Transaksi'
|
||||
};
|
||||
|
||||
return labels[type] ?? type;
|
||||
@ -77,13 +78,13 @@ export function createTransactionColumns(
|
||||
header: () => <span>Jumlah</span>,
|
||||
cell: ({ row }) => {
|
||||
const transaction = row.original;
|
||||
const isDeposit = transaction.type === 'deposit';
|
||||
const isDeposit = transaction.type === 'deposit' || transaction.type === 'transaction';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
isDeposit
|
||||
? 'font-medium text-green-600'
|
||||
? 'foknt-medium text-green-600'
|
||||
: 'font-medium text-red-600'
|
||||
}
|
||||
>
|
||||
|
||||
@ -342,12 +342,12 @@ export default function CuttingCreate({ rawMaterials }: Props) {
|
||||
}}>
|
||||
{({ errors, processing }) => (
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<div className="min-w-0 space-y-6 md:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pilih Bahan Baku</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[calc(100vh-16rem)] space-y-4 overflow-y-auto">
|
||||
<CardContent className="max-h-[calc(100vh-16rem)] space-y-4 overflow-x-hidden overflow-y-auto">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Nama Bahan Baku <span className="text-destructive">*</span>
|
||||
|
||||
@ -319,12 +319,12 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
|
||||
}}>
|
||||
{({ errors, processing }) => (
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<div className="min-w-0 space-y-6 md:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pilih Bahan Baku</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[calc(100vh-16rem)] space-y-4 overflow-y-auto">
|
||||
<CardContent className="max-h-[calc(100vh-16rem)] space-y-4 overflow-x-hidden overflow-y-auto">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Nama Bahan Baku <span className="text-destructive">*</span>
|
||||
|
||||
@ -33,7 +33,7 @@ const eventLabel: Record<string, string> = {
|
||||
deleted: 'Dihapus',
|
||||
};
|
||||
|
||||
function renderValue(key: string, value: unknown): React.ReactNode {
|
||||
function renderValue(value: unknown): React.ReactNode {
|
||||
if (value === null || value === undefined) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
@ -47,10 +47,6 @@ function renderValue(key: string, value: unknown): React.ReactNode {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
if (typeof value[0] === 'object' && value[0] !== null) {
|
||||
return <RenderObjectArray items={value} />;
|
||||
}
|
||||
|
||||
return value.join(', ');
|
||||
}
|
||||
|
||||
@ -61,41 +57,18 @@ function renderValue(key: string, value: unknown): React.ReactNode {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function RenderObjectArray({ items }: { items: Record<string, unknown>[] }) {
|
||||
if (!items.length) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
function flattenChanges(attributeChanges: Record<string, { old?: unknown; new?: unknown }>): { field: string; old?: unknown; new?: unknown }[] {
|
||||
const entries: { field: string; old?: unknown; new?: unknown }[] = [];
|
||||
|
||||
for (const [key, change] of Object.entries(attributeChanges)) {
|
||||
entries.push({
|
||||
field: key,
|
||||
old: change.old,
|
||||
new: change.new,
|
||||
});
|
||||
}
|
||||
|
||||
const keys = Object.keys(items[0]);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<div className="max-h-[300px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{keys.map((key) => (
|
||||
<TableHead key={key} className="h-8 text-xs">
|
||||
{key}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
{keys.map((key) => (
|
||||
<TableCell key={key} className="py-1.5 text-xs">
|
||||
{renderValue(key, item[key])}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function AttributeChangesDialog({ open, onOpenChange, item }: AttributeChangesDialogProps) {
|
||||
@ -104,8 +77,8 @@ export function AttributeChangesDialog({ open, onOpenChange, item }: AttributeCh
|
||||
}
|
||||
|
||||
const changes = item.attribute_changes;
|
||||
const hasNew = changes?.new && Object.keys(changes.new).length > 0;
|
||||
const hasOld = changes?.old && Object.keys(changes.old).length > 0;
|
||||
const hasChanges = changes && Object.keys(changes).length > 0;
|
||||
const flattened = hasChanges ? flattenChanges(changes) : [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@ -125,39 +98,50 @@ export function AttributeChangesDialog({ open, onOpenChange, item }: AttributeCh
|
||||
<div>{item.formatted_created_at}</div>
|
||||
</div>
|
||||
|
||||
{hasNew && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Nilai Baru</h4>
|
||||
<div className="rounded-md border p-3 bg-muted/50">
|
||||
<dl className="space-y-2">
|
||||
{Object.entries(changes!.new!).map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col">
|
||||
<dt className="text-xs text-muted-foreground">{key}</dt>
|
||||
<dd className="text-sm font-medium">{renderValue(key, value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{hasChanges && (
|
||||
<div className="rounded-md border">
|
||||
<div className="max-h-[300px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-8 text-xs w-[30%]">Field</TableHead>
|
||||
<TableHead className="h-8 text-xs w-[35%]">Lama</TableHead>
|
||||
<TableHead className="h-8 text-xs w-[35%]">Baru</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{flattened.map((row) => (
|
||||
<TableRow key={row.field}>
|
||||
<TableCell className="py-1.5 text-xs font-medium">
|
||||
{row.field}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs">
|
||||
{row.old !== undefined ? (
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
{renderValue(row.old)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs">
|
||||
{row.new !== undefined ? (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
{renderValue(row.new)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasOld && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Nilai Lama</h4>
|
||||
<div className="rounded-md border p-3 bg-muted/50">
|
||||
<dl className="space-y-2">
|
||||
{Object.entries(changes!.old!).map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col">
|
||||
<dt className="text-xs text-muted-foreground">{key}</dt>
|
||||
<dd className="text-sm font-medium">{renderValue(key, value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasNew && !hasOld && (
|
||||
{!hasChanges && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Tidak ada perubahan data.
|
||||
</p>
|
||||
|
||||
@ -9,10 +9,7 @@ export type FormHistory = {
|
||||
module: string;
|
||||
event: string;
|
||||
description: string;
|
||||
attribute_changes: {
|
||||
new?: Record<string, unknown>;
|
||||
old?: Record<string, unknown>;
|
||||
} | null;
|
||||
attribute_changes: Record<string, { old?: unknown; new?: unknown }> | null;
|
||||
created_at: string;
|
||||
formatted_created_at: string;
|
||||
causer: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user