Compare commits

...

6 Commits

16 changed files with 663 additions and 135 deletions

View File

@ -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',

View File

@ -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,
]);
}
}

View File

@ -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
{

View File

@ -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(),
];
}
}

View File

@ -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();
}
}

View File

@ -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

View File

@ -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(),
];
}
}

View File

@ -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();
}
}

View File

@ -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);

View File

@ -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, ',', '.');

View File

@ -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

View File

@ -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'
}
>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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: {