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 DEPOSIT = 'deposit';
|
||||||
case EXPENSE = 'expense';
|
case EXPENSE = 'expense';
|
||||||
|
case TRANSACTION = 'transaction';
|
||||||
case WITHDRAWAL = 'withdrawal';
|
case WITHDRAWAL = 'withdrawal';
|
||||||
case EMPLOYEE_ADVANCE = 'employee_advance';
|
case EMPLOYEE_ADVANCE = 'employee_advance';
|
||||||
case SALARY = 'salary';
|
case SALARY = 'salary';
|
||||||
@ -19,6 +20,7 @@ public function label(): string
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::DEPOSIT => 'Deposit',
|
self::DEPOSIT => 'Deposit',
|
||||||
self::EXPENSE => 'Pengeluaran',
|
self::EXPENSE => 'Pengeluaran',
|
||||||
|
self::TRANSACTION => 'Transaksi',
|
||||||
self::WITHDRAWAL => 'Withdrawal',
|
self::WITHDRAWAL => 'Withdrawal',
|
||||||
self::EMPLOYEE_ADVANCE => 'Kasbon',
|
self::EMPLOYEE_ADVANCE => 'Kasbon',
|
||||||
self::SALARY => 'Gaji',
|
self::SALARY => 'Gaji',
|
||||||
|
|||||||
@ -45,6 +45,7 @@ public function index(Request $request): Response
|
|||||||
$marketingSales = $this->service->getMarketingSales($startDate, $endDate, $user);
|
$marketingSales = $this->service->getMarketingSales($startDate, $endDate, $user);
|
||||||
$orderStats = $this->service->getOrderStats($startDate, $endDate, $user);
|
$orderStats = $this->service->getOrderStats($startDate, $endDate, $user);
|
||||||
$revenueTrend = $this->service->getRevenueTrend($startDate, $endDate, $user);
|
$revenueTrend = $this->service->getRevenueTrend($startDate, $endDate, $user);
|
||||||
|
$monthlyRetailRevenue = $this->service->getMonthlyRetailRevenue($startDate, $endDate, $user);
|
||||||
|
|
||||||
return Inertia::render('admin/analysis/index', [
|
return Inertia::render('admin/analysis/index', [
|
||||||
'filters' => [
|
'filters' => [
|
||||||
@ -72,6 +73,7 @@ public function index(Request $request): Response
|
|||||||
'marketingSales' => $marketingSales,
|
'marketingSales' => $marketingSales,
|
||||||
'orderStats' => $orderStats,
|
'orderStats' => $orderStats,
|
||||||
'revenueTrend' => $revenueTrend,
|
'revenueTrend' => $revenueTrend,
|
||||||
|
'monthlyRetailRevenue' => $monthlyRetailRevenue,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,6 +75,12 @@ protected function expenseType(Builder $query): void
|
|||||||
$query->where('type', CashTransactionType::EXPENSE);
|
$query->where('type', CashTransactionType::EXPENSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Scope]
|
||||||
|
protected function transactionType(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where('type', CashTransactionType::TRANSACTION);
|
||||||
|
}
|
||||||
|
|
||||||
#[Scope]
|
#[Scope]
|
||||||
protected function transfer(Builder $query): void
|
protected function transfer(Builder $query): void
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services\Admin\Manage;
|
namespace App\Services\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Enums\CashTransactionType;
|
||||||
use App\Enums\OrderChannel;
|
use App\Enums\OrderChannel;
|
||||||
use App\Enums\OrderStatus;
|
use App\Enums\OrderStatus;
|
||||||
use App\Enums\PaymentType;
|
use App\Enums\PaymentType;
|
||||||
@ -14,6 +15,8 @@
|
|||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Concerns\HasStockAdjustment;
|
use App\Services\Concerns\HasStockAdjustment;
|
||||||
|
use App\Services\Concerns\HandlesCashTransactions;
|
||||||
|
use App\Services\Concerns\LogsFormHistory;
|
||||||
use App\Services\Concerns\RegistersMedia;
|
use App\Services\Concerns\RegistersMedia;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\S3PresignedService;
|
use App\Services\S3PresignedService;
|
||||||
@ -23,7 +26,7 @@
|
|||||||
|
|
||||||
class TransactionService
|
class TransactionService
|
||||||
{
|
{
|
||||||
use HasStockAdjustment, RegistersMedia;
|
use HasStockAdjustment, HandlesCashTransactions, LogsFormHistory, RegistersMedia;
|
||||||
|
|
||||||
private const SELLING_PRICE_MAP = [
|
private const SELLING_PRICE_MAP = [
|
||||||
PriceType::DISTRIBUTOR->value => PriceType::DISTRIBUTOR,
|
PriceType::DISTRIBUTOR->value => PriceType::DISTRIBUTOR,
|
||||||
@ -233,7 +236,14 @@ public function store(array $data): Order
|
|||||||
|
|
||||||
$this->applyStock($data['items'], $stockType, -1);
|
$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);
|
$this->syncPhoto($order, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,15 +254,20 @@ public function store(array $data): Order
|
|||||||
url: route('admin.manage.transactions.index', ['highlight' => $order->id]),
|
url: route('admin.manage.transactions.index', ['highlight' => $order->id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logCreated($order, 'Transaksi', $this->getOrderLogValues($order));
|
||||||
|
|
||||||
return $order;
|
return $order;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Order $order, array $data): Order
|
public function update(Order $order, array $data): Order
|
||||||
{
|
{
|
||||||
|
$oldValues = $this->getOrderLogValues($order);
|
||||||
|
|
||||||
$order = DB::transaction(function () use ($order, $data) {
|
$order = DB::transaction(function () use ($order, $data) {
|
||||||
$order->load('orderItems');
|
$order->load('orderItems');
|
||||||
|
|
||||||
|
$oldPaymentType = $order->payment_type;
|
||||||
$oldStockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
$oldStockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
||||||
|
|
||||||
$order->orderItems->each(function (OrderItem $item) use ($oldStockType) {
|
$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);
|
DB::table('order_items')->insert($itemRows);
|
||||||
|
|
||||||
|
$newPaymentType = $data['payment_type'] ?? $order->payment_type->value;
|
||||||
|
|
||||||
$order->update([
|
$order->update([
|
||||||
'customer_id' => $data['customer_id'] ?? null,
|
'customer_id' => $data['customer_id'] ?? null,
|
||||||
'marketing_id' => $data['marketing_id'] ?? null,
|
'marketing_id' => $data['marketing_id'] ?? null,
|
||||||
'channel' => $data['channel'] ?? $order->channel->value,
|
'channel' => $data['channel'] ?? $order->channel->value,
|
||||||
'price_type' => $priceType,
|
'price_type' => $priceType,
|
||||||
'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING,
|
'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,
|
'tiktok_order_id' => $data['tiktok_order_id'] ?? null,
|
||||||
'shopee_order_id' => $data['shopee_order_id'] ?? null,
|
'shopee_order_id' => $data['shopee_order_id'] ?? null,
|
||||||
'subtotal' => $subtotal,
|
'subtotal' => $subtotal,
|
||||||
@ -303,11 +320,39 @@ public function update(Order $order, array $data): Order
|
|||||||
|
|
||||||
$this->applyStock($data['items'], $stockType, -1);
|
$this->applyStock($data['items'], $stockType, -1);
|
||||||
|
|
||||||
$paymentType = $data['payment_type'] ?? $order->payment_type->value;
|
if ($oldPaymentType === PaymentType::CASH && $newPaymentType !== PaymentType::CASH->value) {
|
||||||
if ($paymentType !== PaymentType::CASH->value) {
|
if ($order->cash_transaction_id) {
|
||||||
$this->syncPhoto($order, $data);
|
$order->cashTransaction()->delete();
|
||||||
} else {
|
$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');
|
$order->clearMediaCollection('photos');
|
||||||
|
} else {
|
||||||
|
$this->syncPhoto($order, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $order;
|
return $order;
|
||||||
@ -320,11 +365,15 @@ public function update(Order $order, array $data): Order
|
|||||||
url: route('admin.manage.transactions.index', ['highlight' => $order->id]),
|
url: route('admin.manage.transactions.index', ['highlight' => $order->id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logUpdated($order, 'Transaksi', $oldValues, $this->getOrderLogValues($order));
|
||||||
|
|
||||||
return $order;
|
return $order;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Order $order): bool
|
public function destroy(Order $order): bool
|
||||||
{
|
{
|
||||||
|
$oldValues = $this->getOrderLogValues($order);
|
||||||
|
|
||||||
$result = DB::transaction(function () use ($order) {
|
$result = DB::transaction(function () use ($order) {
|
||||||
$order->load('orderItems');
|
$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->orderItems()->delete();
|
||||||
$order->delete();
|
$order->delete();
|
||||||
|
|
||||||
@ -349,11 +406,14 @@ public function destroy(Order $order): bool
|
|||||||
url: route('admin.manage.transactions.index'),
|
url: route('admin.manage.transactions.index'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logDeleted($order, 'Transaksi', $oldValues);
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateStatus(Order $order, string $status): Order
|
public function updateStatus(Order $order, string $status): Order
|
||||||
{
|
{
|
||||||
|
$oldValues = $this->getOrderLogValues($order);
|
||||||
$oldStatus = $order->status->value;
|
$oldStatus = $order->status->value;
|
||||||
|
|
||||||
$order->update(['status' => $status]);
|
$order->update(['status' => $status]);
|
||||||
@ -365,8 +425,18 @@ public function updateStatus(Order $order, string $status): Order
|
|||||||
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
|
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
|
||||||
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $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;
|
return $order;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -468,4 +538,30 @@ private function isMarketingUser(User $user): bool
|
|||||||
Role::MARKETING_ONLINE->value,
|
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;
|
namespace App\Services\Admin\Master;
|
||||||
|
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
|
use App\Services\Concerns\LogsFormHistory;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class CustomerService
|
class CustomerService
|
||||||
{
|
{
|
||||||
|
use LogsFormHistory;
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Customer::query()
|
return Customer::query()
|
||||||
@ -24,18 +27,44 @@ public function getAll(): Collection
|
|||||||
|
|
||||||
public function store(array $data): Customer
|
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
|
public function update(Customer $customer, array $data): Customer
|
||||||
{
|
{
|
||||||
|
$oldValues = [
|
||||||
|
'Nama' => $customer->name,
|
||||||
|
'No. Telepon' => $customer->phone_number,
|
||||||
|
'Alamat' => $customer->address,
|
||||||
|
];
|
||||||
|
|
||||||
$customer->update($data);
|
$customer->update($data);
|
||||||
|
|
||||||
|
$this->logUpdated($customer, 'Customer', $oldValues, [
|
||||||
|
'Nama' => $customer->name,
|
||||||
|
'No. Telepon' => $customer->phone_number,
|
||||||
|
'Alamat' => $customer->address,
|
||||||
|
]);
|
||||||
|
|
||||||
return $customer;
|
return $customer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Customer $customer): bool
|
public function destroy(Customer $customer): bool
|
||||||
{
|
{
|
||||||
|
$this->logDeleted($customer, 'Customer', [
|
||||||
|
'Nama' => $customer->name,
|
||||||
|
'No. Telepon' => $customer->phone_number,
|
||||||
|
'Alamat' => $customer->address,
|
||||||
|
]);
|
||||||
|
|
||||||
return $customer->delete();
|
return $customer->delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -468,6 +468,8 @@ public function toggleStatus(Product $product): void
|
|||||||
{
|
{
|
||||||
$this->assertNotPending($product);
|
$this->assertNotPending($product);
|
||||||
|
|
||||||
|
$oldValues = $this->getProductLogValues($product);
|
||||||
|
|
||||||
$product->update([
|
$product->update([
|
||||||
'status' => $product->status->value === 'active' ? 'inactive' : 'active',
|
'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.'.',
|
body: "Produk \"{$product->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.',
|
||||||
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toggleFeatured(Product $product): void
|
public function toggleFeatured(Product $product): void
|
||||||
{
|
{
|
||||||
$this->assertNotPending($product);
|
$this->assertNotPending($product);
|
||||||
|
|
||||||
|
$oldValues = $this->getProductLogValues($product);
|
||||||
|
|
||||||
$product->update([
|
$product->update([
|
||||||
'is_featured' => ! $product->is_featured,
|
'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.'.',
|
body: "Produk \"{$product->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.',
|
||||||
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function approve(Product $product): void
|
public function approve(Product $product): void
|
||||||
{
|
{
|
||||||
|
$oldValues = $this->getProductLogValues($product);
|
||||||
|
|
||||||
$product->update([
|
$product->update([
|
||||||
'status' => ProductStatus::ACTIVE,
|
'status' => ProductStatus::ACTIVE,
|
||||||
]);
|
]);
|
||||||
@ -513,10 +523,14 @@ public function approve(Product $product): void
|
|||||||
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
||||||
additionalUser: $product->createdBy ?? null,
|
additionalUser: $product->createdBy ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reject(Product $product, string $reason = ''): void
|
public function reject(Product $product, string $reason = ''): void
|
||||||
{
|
{
|
||||||
|
$oldValues = $this->getProductLogValues($product);
|
||||||
|
|
||||||
$product->update([
|
$product->update([
|
||||||
'status' => ProductStatus::REJECTED,
|
'status' => ProductStatus::REJECTED,
|
||||||
'rejection_reason' => $reason,
|
'rejection_reason' => $reason,
|
||||||
@ -529,10 +543,14 @@ public function reject(Product $product, string $reason = ''): void
|
|||||||
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
||||||
additionalUser: $product->createdBy ?? null,
|
additionalUser: $product->createdBy ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resubmit(Product $product): void
|
public function resubmit(Product $product): void
|
||||||
{
|
{
|
||||||
|
$oldValues = $this->getProductLogValues($product);
|
||||||
|
|
||||||
$product->update([
|
$product->update([
|
||||||
'status' => ProductStatus::PENDING,
|
'status' => ProductStatus::PENDING,
|
||||||
'rejection_reason' => null,
|
'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.'.',
|
body: "Produk \"{$product->name}\" telah diajukan ulang oleh ".auth()->user()->full_name.'.',
|
||||||
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logUpdated($product, 'Produk', $oldValues, $this->getProductLogValues($product));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function assertNotPending(Product $product): void
|
private function assertNotPending(Product $product): void
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
|
use App\Services\Concerns\LogsFormHistory;
|
||||||
use App\Services\Concerns\RegistersMedia;
|
use App\Services\Concerns\RegistersMedia;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\S3PresignedService;
|
use App\Services\S3PresignedService;
|
||||||
@ -18,7 +19,7 @@
|
|||||||
|
|
||||||
class ProductVariantService
|
class ProductVariantService
|
||||||
{
|
{
|
||||||
use HasRoleChecks, RegistersMedia;
|
use HasRoleChecks, LogsFormHistory, RegistersMedia;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private S3PresignedService $s3Service,
|
private S3PresignedService $s3Service,
|
||||||
@ -171,6 +172,8 @@ public function update(ProductVariant $variant, array $data): ProductVariant
|
|||||||
{
|
{
|
||||||
$this->assertNotPending($variant->product);
|
$this->assertNotPending($variant->product);
|
||||||
|
|
||||||
|
$oldValues = $this->getProductLogValues($variant->product);
|
||||||
|
|
||||||
DB::transaction(function () use ($variant, $data) {
|
DB::transaction(function () use ($variant, $data) {
|
||||||
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
|
$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]),
|
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();
|
return $variant->fresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,6 +223,8 @@ public function destroy(Product $product, ProductVariant $variant): bool
|
|||||||
{
|
{
|
||||||
$this->assertNotPending($product);
|
$this->assertNotPending($product);
|
||||||
|
|
||||||
|
$oldValues = $this->getProductLogValues($product);
|
||||||
|
|
||||||
$result = DB::transaction(function () use ($variant) {
|
$result = DB::transaction(function () use ($variant) {
|
||||||
return $variant->delete();
|
return $variant->delete();
|
||||||
});
|
});
|
||||||
@ -228,6 +236,9 @@ public function destroy(Product $product, ProductVariant $variant): bool
|
|||||||
url: route('admin.master.products.index'),
|
url: route('admin.master.products.index'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$product->refresh()->load(['categories:id,name', 'productVariants.productPrices']);
|
||||||
|
$this->logDeleted($product, 'Produk', $oldValues);
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,6 +248,8 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
|
|||||||
|
|
||||||
$this->assertNotPending($variant->product);
|
$this->assertNotPending($variant->product);
|
||||||
|
|
||||||
|
$oldValues = $this->getProductLogValues($variant->product);
|
||||||
|
|
||||||
if ($variant->stock < $quantity) {
|
if ($variant->stock < $quantity) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'quantity' => "Stok bagus tidak mencukupi. Stok tersedia: {$variant->stock}.",
|
'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]),
|
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();
|
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;
|
namespace App\Services\Admin\Master;
|
||||||
|
|
||||||
use App\Models\Supplier;
|
use App\Models\Supplier;
|
||||||
|
use App\Services\Concerns\LogsFormHistory;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class SupplierService
|
class SupplierService
|
||||||
{
|
{
|
||||||
|
use LogsFormHistory;
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Supplier::query()
|
return Supplier::query()
|
||||||
@ -24,18 +27,44 @@ public function getAll(): Collection
|
|||||||
|
|
||||||
public function store(array $data): Supplier
|
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
|
public function update(Supplier $supplier, array $data): Supplier
|
||||||
{
|
{
|
||||||
|
$oldValues = [
|
||||||
|
'Nama' => $supplier->name,
|
||||||
|
'No. Telepon' => $supplier->phone_number,
|
||||||
|
'Alamat' => $supplier->address,
|
||||||
|
];
|
||||||
|
|
||||||
$supplier->update($data);
|
$supplier->update($data);
|
||||||
|
|
||||||
|
$this->logUpdated($supplier, 'Supplier', $oldValues, [
|
||||||
|
'Nama' => $supplier->name,
|
||||||
|
'No. Telepon' => $supplier->phone_number,
|
||||||
|
'Alamat' => $supplier->address,
|
||||||
|
]);
|
||||||
|
|
||||||
return $supplier;
|
return $supplier;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Supplier $supplier): bool
|
public function destroy(Supplier $supplier): bool
|
||||||
{
|
{
|
||||||
|
$this->logDeleted($supplier, 'Supplier', [
|
||||||
|
'Nama' => $supplier->name,
|
||||||
|
'No. Telepon' => $supplier->phone_number,
|
||||||
|
'Alamat' => $supplier->address,
|
||||||
|
]);
|
||||||
|
|
||||||
return $supplier->delete();
|
return $supplier->delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -445,6 +445,75 @@ public function getMonthlyRevenue(?string $startDate, ?string $endDate, ?User $u
|
|||||||
return $result;
|
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
|
public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate, ?User $user = null): array
|
||||||
{
|
{
|
||||||
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||||
|
|||||||
@ -10,56 +10,191 @@ trait LogsFormHistory
|
|||||||
{
|
{
|
||||||
private function logCreated(Model $model, string $module, array $newValues): void
|
private function logCreated(Model $model, string $module, array $newValues): void
|
||||||
{
|
{
|
||||||
$this->createFormHistory(
|
$changes = $this->buildCreatedChanges($newValues);
|
||||||
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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
FormHistory::create([
|
FormHistory::create([
|
||||||
'causer_id' => Auth::id(),
|
'causer_id' => Auth::id(),
|
||||||
'module' => $module,
|
'module' => $module,
|
||||||
'event' => $event,
|
'event' => 'created',
|
||||||
'description' => $description,
|
'description' => "{$module} ditambahkan",
|
||||||
'attribute_changes' => $attributeChanges ?: null,
|
'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
|
private function formatCurrency(int $value): string
|
||||||
{
|
{
|
||||||
return 'Rp ' . number_format($value, 0, ',', '.');
|
return 'Rp ' . number_format($value, 0, ',', '.');
|
||||||
|
|||||||
@ -203,6 +203,23 @@ type AnalysisProps = {
|
|||||||
date: string;
|
date: string;
|
||||||
qty: number;
|
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 = (() => {
|
const revenueChartConfig = (() => {
|
||||||
@ -219,6 +236,19 @@ const revenueChartConfig = (() => {
|
|||||||
|
|
||||||
const revenueKeys = ['total', 'deduction', 'discount', 'cogs', 'gross', 'net'] as const;
|
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 expenseChartConfig = (() => {
|
||||||
const colors = generateRandomColors(4);
|
const colors = generateRandomColors(4);
|
||||||
return {
|
return {
|
||||||
@ -450,12 +480,14 @@ export default function Analysis({
|
|||||||
marketingSales,
|
marketingSales,
|
||||||
orderStats,
|
orderStats,
|
||||||
revenueTrend,
|
revenueTrend,
|
||||||
|
monthlyRetailRevenue,
|
||||||
}: AnalysisProps) {
|
}: AnalysisProps) {
|
||||||
const { can, hasAnyRole, hasRole } = useCan();
|
const { can, hasAnyRole, hasRole } = useCan();
|
||||||
const [startDate, setStartDate] = useState(initialFilters.start_date ?? '');
|
const [startDate, setStartDate] = useState(initialFilters.start_date ?? '');
|
||||||
const [endDate, setEndDate] = useState(initialFilters.end_date ?? '');
|
const [endDate, setEndDate] = useState(initialFilters.end_date ?? '');
|
||||||
const [selectedPreset, setSelectedPreset] = useState('');
|
const [selectedPreset, setSelectedPreset] = useState('');
|
||||||
const [activeRevenueKey, setActiveRevenueKey] = useState<keyof typeof revenueChartConfig>('total');
|
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 [activeExpenseKey, setActiveExpenseKey] = useState<keyof typeof expenseChartConfig>('total');
|
||||||
const [activeStockKey, setActiveStockKey] = useState<'good' | 'reject' | 'retail'>('good');
|
const [activeStockKey, setActiveStockKey] = useState<'good' | 'reject' | 'retail'>('good');
|
||||||
|
|
||||||
@ -745,6 +777,11 @@ export default function Analysis({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-xs leading-none font-semibold sm:text-sm">
|
<span className="text-xs leading-none font-semibold sm:text-sm">
|
||||||
Rp{formatRupiah(value)}
|
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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@ -873,6 +910,70 @@ export default function Analysis({
|
|||||||
</div>
|
</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') && (
|
{can('analysis.revenue') && (
|
||||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4" style={{ order: sectionOrder.orderPie ?? 99 }}>
|
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4" style={{ order: sectionOrder.orderPie ?? 99 }}>
|
||||||
<DashboardPieChart
|
<DashboardPieChart
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
import { RowActions } from '@/components/data-display';
|
||||||
|
import { ImagePreviewButton } from '@/components/dialogs';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
import { ImagePreviewButton } from '@/components/dialogs';
|
|
||||||
import { RowActions } from '@/components/data-display';
|
|
||||||
|
|
||||||
export type CashTransaction = {
|
export type CashTransaction = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -32,7 +32,8 @@ function getTypeLabel(type: string): string {
|
|||||||
withdrawal: 'Withdrawal',
|
withdrawal: 'Withdrawal',
|
||||||
expense: 'Pengeluaran',
|
expense: 'Pengeluaran',
|
||||||
employee_advance: 'Kasbon',
|
employee_advance: 'Kasbon',
|
||||||
salary : 'Gaji'
|
salary: 'Gaji',
|
||||||
|
transaction: 'Transaksi'
|
||||||
};
|
};
|
||||||
|
|
||||||
return labels[type] ?? type;
|
return labels[type] ?? type;
|
||||||
@ -77,13 +78,13 @@ export function createTransactionColumns(
|
|||||||
header: () => <span>Jumlah</span>,
|
header: () => <span>Jumlah</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const transaction = row.original;
|
const transaction = row.original;
|
||||||
const isDeposit = transaction.type === 'deposit';
|
const isDeposit = transaction.type === 'deposit' || transaction.type === 'transaction';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
isDeposit
|
isDeposit
|
||||||
? 'font-medium text-green-600'
|
? 'foknt-medium text-green-600'
|
||||||
: 'font-medium text-red-600'
|
: 'font-medium text-red-600'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -342,12 +342,12 @@ export default function CuttingCreate({ rawMaterials }: Props) {
|
|||||||
}}>
|
}}>
|
||||||
{({ errors, processing }) => (
|
{({ errors, processing }) => (
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Pilih Bahan Baku</CardTitle>
|
<CardTitle>Pilih Bahan Baku</CardTitle>
|
||||||
</CardHeader>
|
</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">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Nama Bahan Baku <span className="text-destructive">*</span>
|
Nama Bahan Baku <span className="text-destructive">*</span>
|
||||||
|
|||||||
@ -319,12 +319,12 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
|
|||||||
}}>
|
}}>
|
||||||
{({ errors, processing }) => (
|
{({ errors, processing }) => (
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Pilih Bahan Baku</CardTitle>
|
<CardTitle>Pilih Bahan Baku</CardTitle>
|
||||||
</CardHeader>
|
</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">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Nama Bahan Baku <span className="text-destructive">*</span>
|
Nama Bahan Baku <span className="text-destructive">*</span>
|
||||||
|
|||||||
@ -33,7 +33,7 @@ const eventLabel: Record<string, string> = {
|
|||||||
deleted: 'Dihapus',
|
deleted: 'Dihapus',
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderValue(key: string, value: unknown): React.ReactNode {
|
function renderValue(value: unknown): React.ReactNode {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return <span className="text-muted-foreground">-</span>;
|
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>;
|
return <span className="text-muted-foreground">-</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value[0] === 'object' && value[0] !== null) {
|
|
||||||
return <RenderObjectArray items={value} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.join(', ');
|
return value.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,41 +57,18 @@ function renderValue(key: string, value: unknown): React.ReactNode {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RenderObjectArray({ items }: { items: Record<string, unknown>[] }) {
|
function flattenChanges(attributeChanges: Record<string, { old?: unknown; new?: unknown }>): { field: string; old?: unknown; new?: unknown }[] {
|
||||||
if (!items.length) {
|
const entries: { field: string; old?: unknown; new?: unknown }[] = [];
|
||||||
return <span className="text-muted-foreground">-</span>;
|
|
||||||
|
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 entries;
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttributeChangesDialog({ open, onOpenChange, item }: AttributeChangesDialogProps) {
|
export function AttributeChangesDialog({ open, onOpenChange, item }: AttributeChangesDialogProps) {
|
||||||
@ -104,8 +77,8 @@ export function AttributeChangesDialog({ open, onOpenChange, item }: AttributeCh
|
|||||||
}
|
}
|
||||||
|
|
||||||
const changes = item.attribute_changes;
|
const changes = item.attribute_changes;
|
||||||
const hasNew = changes?.new && Object.keys(changes.new).length > 0;
|
const hasChanges = changes && Object.keys(changes).length > 0;
|
||||||
const hasOld = changes?.old && Object.keys(changes.old).length > 0;
|
const flattened = hasChanges ? flattenChanges(changes) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@ -125,39 +98,50 @@ export function AttributeChangesDialog({ open, onOpenChange, item }: AttributeCh
|
|||||||
<div>{item.formatted_created_at}</div>
|
<div>{item.formatted_created_at}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasNew && (
|
{hasChanges && (
|
||||||
<div className="space-y-2">
|
<div className="rounded-md border">
|
||||||
<h4 className="text-sm font-medium">Nilai Baru</h4>
|
<div className="max-h-[300px] overflow-auto">
|
||||||
<div className="rounded-md border p-3 bg-muted/50">
|
<Table>
|
||||||
<dl className="space-y-2">
|
<TableHeader>
|
||||||
{Object.entries(changes!.new!).map(([key, value]) => (
|
<TableRow>
|
||||||
<div key={key} className="flex flex-col">
|
<TableHead className="h-8 text-xs w-[30%]">Field</TableHead>
|
||||||
<dt className="text-xs text-muted-foreground">{key}</dt>
|
<TableHead className="h-8 text-xs w-[35%]">Lama</TableHead>
|
||||||
<dd className="text-sm font-medium">{renderValue(key, value)}</dd>
|
<TableHead className="h-8 text-xs w-[35%]">Baru</TableHead>
|
||||||
</div>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</dl>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasOld && (
|
{!hasChanges && (
|
||||||
<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 && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
Tidak ada perubahan data.
|
Tidak ada perubahan data.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -9,10 +9,7 @@ export type FormHistory = {
|
|||||||
module: string;
|
module: string;
|
||||||
event: string;
|
event: string;
|
||||||
description: string;
|
description: string;
|
||||||
attribute_changes: {
|
attribute_changes: Record<string, { old?: unknown; new?: unknown }> | null;
|
||||||
new?: Record<string, unknown>;
|
|
||||||
old?: Record<string, unknown>;
|
|
||||||
} | null;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
formatted_created_at: string;
|
formatted_created_at: string;
|
||||||
causer: {
|
causer: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user