From 72848d51d898e82717293fa85de8d563b490a079 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Tue, 4 Aug 2026 10:37:28 +0700 Subject: [PATCH] feat: add transaction management page with filtering and CRUD functionality - Implemented TransactionIndex component for displaying transactions with pagination and filtering options. - Created TransactionCardRow component for rendering individual transaction details. - Added TransactionItemSubRow component for displaying detailed order items within a transaction. - Integrated delete confirmation dialog for transaction deletion. - Updated routes to include transaction management with appropriate permissions. --- .env.example | 19 +- app/Enums/OrderChannel.php | 13 +- app/Enums/OrderStatus.php | 11 + app/Enums/PaymentType.php | 14 +- .../Admin/Manage/TransactionController.php | 78 ++ .../Admin/Manage/TransactionRequest.php | 87 ++ app/Models/Order.php | 33 +- .../Admin/Manage/TransactionService.php | 440 +++++++ config/database.php | 20 + database/factories/OrderFactory.php | 4 +- .../2026_06_12_100001_create_orders_table.php | 1 + database/seeders/RolePermissionSeeder.php | 2 + resources/js/components/app-sidebar.tsx | 2 + resources/js/components/rupiah-input.tsx | 3 + resources/js/hooks/use-transaction-draft.ts | 23 + resources/js/lib/transaction-draft.ts | 45 + .../admin/manage/transaction/columns.tsx | 134 +++ .../pages/admin/manage/transaction/create.tsx | 1042 +++++++++++++++++ .../pages/admin/manage/transaction/edit.tsx | 977 ++++++++++++++++ .../pages/admin/manage/transaction/index.tsx | 374 ++++++ .../manage/transaction/transaction-card.tsx | 195 +++ .../transaction/transaction-sub-row.tsx | 87 ++ routes/web.php | 2 + 23 files changed, 3569 insertions(+), 37 deletions(-) create mode 100644 app/Http/Controllers/Admin/Manage/TransactionController.php create mode 100644 app/Http/Requests/Admin/Manage/TransactionRequest.php create mode 100644 app/Services/Admin/Manage/TransactionService.php create mode 100644 resources/js/hooks/use-transaction-draft.ts create mode 100644 resources/js/lib/transaction-draft.ts create mode 100644 resources/js/pages/admin/manage/transaction/columns.tsx create mode 100644 resources/js/pages/admin/manage/transaction/create.tsx create mode 100644 resources/js/pages/admin/manage/transaction/edit.tsx create mode 100644 resources/js/pages/admin/manage/transaction/index.tsx create mode 100644 resources/js/pages/admin/manage/transaction/transaction-card.tsx create mode 100644 resources/js/pages/admin/manage/transaction/transaction-sub-row.tsx diff --git a/.env.example b/.env.example index 40bb13b..481f9c9 100644 --- a/.env.example +++ b/.env.example @@ -20,12 +20,19 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=laravel +DB_USERNAME=root +DB_PASSWORD= + +DB_OLD_CONNECTION=mysql +DB_OLD_HOST=127.0.0.1 +DB_OLD_PORT=3306 +DB_OLD_DATABASE=dst_old +DB_OLD_USERNAME=root +DB_OLD_PASSWORD= SESSION_DRIVER=database SESSION_LIFETIME=120 diff --git a/app/Enums/OrderChannel.php b/app/Enums/OrderChannel.php index 89ff76e..8f84533 100644 --- a/app/Enums/OrderChannel.php +++ b/app/Enums/OrderChannel.php @@ -8,9 +8,16 @@ enum OrderChannel: string { use HasValues; - case OFFLINE = 'offline'; - case ONLINE = 'online'; - case WHATSAPP = 'whatsapp'; + case STORE = 'store'; case SHOPEE = 'shopee'; case TIKTOK = 'tiktok'; + + public function label(): string + { + return match ($this) { + self::STORE => 'Toko', + self::SHOPEE => 'Shopee', + self::TIKTOK => 'TikTok', + }; + } } diff --git a/app/Enums/OrderStatus.php b/app/Enums/OrderStatus.php index 37c06ef..c23d2f6 100644 --- a/app/Enums/OrderStatus.php +++ b/app/Enums/OrderStatus.php @@ -13,4 +13,15 @@ enum OrderStatus: string case COMPLETED = 'completed'; case CANCELLED = 'cancelled'; case REFUNDED = 'refunded'; + + public function label(): string + { + return match ($this) { + self::PENDING => 'Pending', + self::PROCESSING => 'Diproses', + self::COMPLETED => 'Selesai', + self::CANCELLED => 'Dibatalkan', + self::REFUNDED => 'Dikembalikan', + }; + } } diff --git a/app/Enums/PaymentType.php b/app/Enums/PaymentType.php index f73138f..e7302bb 100644 --- a/app/Enums/PaymentType.php +++ b/app/Enums/PaymentType.php @@ -10,6 +10,16 @@ enum PaymentType: string case CASH = 'cash'; case TRANSFER = 'transfer'; - case DEBIT = 'debit'; - case CREDIT = 'credit'; + case MARKETPLACE = 'marketplace'; + case QRIS = 'qris'; + + public function label(): string + { + return match ($this) { + self::CASH => 'Tunai', + self::TRANSFER => 'Transfer', + self::MARKETPLACE => 'Marketplace', + self::QRIS => 'QRIS', + }; + } } diff --git a/app/Http/Controllers/Admin/Manage/TransactionController.php b/app/Http/Controllers/Admin/Manage/TransactionController.php new file mode 100644 index 0000000..d2312a0 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/TransactionController.php @@ -0,0 +1,78 @@ +only(['status', 'channel', 'payment_type', 'customer_id', 'marketing_id', 'created_by_id']); + + return Inertia::render('admin/manage/transaction/index', [ + 'transactions' => $this->service->paginated( + ...$request->validatedWithDefaults(), + filters: $filters, + ), + 'filters' => $filters, + 'filterOptions' => $this->service->getFilterOptions(), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/manage/transaction/create', [ + 'data' => $this->service->getForCreate(), + ]); + } + + public function store(TransactionRequest $request): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->create($request->validated()), + 'Transaksi berhasil ditambahkan.', + 'admin.manage.transactions.index', + 'admin.manage.transactions.create' + ); + } + + public function edit(Order $transaction): Response + { + return Inertia::render('admin/manage/transaction/edit', [ + 'transaction' => $this->service->getForEdit($transaction), + 'data' => $this->service->getForCreate(), + ]); + } + + public function update(TransactionRequest $request, Order $transaction): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->update($transaction, $request->validated()), + 'Transaksi berhasil diperbarui.', + 'admin.manage.transactions.index', + 'admin.manage.transactions.edit', + ['transaction' => $transaction] + ); + } + + public function destroy(Order $transaction): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->delete($transaction), + 'Transaksi berhasil dihapus.', + 'admin.manage.transactions.index' + ); + } +} diff --git a/app/Http/Requests/Admin/Manage/TransactionRequest.php b/app/Http/Requests/Admin/Manage/TransactionRequest.php new file mode 100644 index 0000000..4c001eb --- /dev/null +++ b/app/Http/Requests/Admin/Manage/TransactionRequest.php @@ -0,0 +1,87 @@ +has('discount')) { + $this->merge([ + 'discount' => str_replace('.', '', $this->discount), + ]); + } + + if ($this->has('nego_price')) { + $this->merge([ + 'nego_price' => str_replace('.', '', $this->nego_price), + ]); + } + } + + public function rules(): array + { + $sellingPriceTypes = array_diff(PriceType::values(), [PriceType::CAPITAL->value]); + + return [ + 'stock_type' => ['sometimes', 'required', Rule::in(ProductStockQuality::values())], + 'channel' => ['sometimes', 'required', Rule::in(OrderChannel::values())], + 'price_type' => ['sometimes', 'required', Rule::in($sellingPriceTypes)], + 'payment_type' => ['sometimes', 'required', Rule::in(PaymentType::values())], + 'customer_id' => ['nullable', 'integer', 'exists:customers,id'], + 'marketing_id' => ['nullable', 'integer', 'exists:users,id'], + 'discount' => ['nullable', 'integer', 'min:0'], + 'nego_price' => ['nullable', 'integer'], + 'is_completed' => ['sometimes', 'boolean'], + 'is_affiliate' => ['sometimes', 'boolean'], + 'tiktok_order_id' => ['nullable', 'string', 'max:100'], + 'shopee_order_id' => ['nullable', 'string', 'max:100'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + 'notes' => ['nullable', 'string', 'max:100'], + 'photo_key' => [ + Rule::when( + in_array($this->input('payment_type'), [PaymentType::TRANSFER->value, PaymentType::QRIS->value]), + ['required', 'string', 'max:500'], + ['nullable', 'string', 'max:500'], + ), + ], + ]; + } + + public function attributes(): array + { + return [ + 'stock_type' => 'Tipe Stok', + 'channel' => 'Channel', + 'price_type' => 'Tipe Harga', + 'payment_type' => 'Tipe Pembayaran', + 'customer_id' => 'Pelanggan', + 'marketing_id' => 'Marketing', + 'discount' => 'Diskon', + 'nego_price' => 'Harga Nego', + 'is_completed' => 'Pesanan Selesai', + 'is_affiliate' => 'Affiliasi', + 'tiktok_order_id' => 'ID Pesanan TikTok', + 'shopee_order_id' => 'ID Pesanan Shopee', + 'items' => 'Item Produk', + 'items.*.product_variant_id' => 'Varian Produk', + 'items.*.quantity' => 'Jumlah', + 'notes' => 'Keterangan', + 'photo_key' => 'Foto', + ]; + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php index d821349..a1d0e65 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -14,11 +14,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; #[Guarded(['id'])] -class Order extends Model +class Order extends Model implements HasMedia { - use HasFactory, SoftDeletes; + use HasFactory, InteractsWithMedia, SoftDeletes; protected function casts(): array { @@ -32,6 +34,7 @@ protected function casts(): array 'discount' => 'integer', 'nego_price' => 'integer', 'total_amount' => 'integer', + 'cogs' => 'integer', ]; } @@ -54,27 +57,15 @@ protected function completed(Builder $query): void } #[Scope] - protected function credit(Builder $query): void + protected function qris(Builder $query): void { - $query->where('payment_type', PaymentType::CREDIT); + $query->where('payment_type', PaymentType::QRIS); } #[Scope] - protected function debit(Builder $query): void + protected function store(Builder $query): void { - $query->where('payment_type', PaymentType::DEBIT); - } - - #[Scope] - protected function offline(Builder $query): void - { - $query->where('channel', OrderChannel::OFFLINE); - } - - #[Scope] - protected function online(Builder $query): void - { - $query->where('channel', OrderChannel::ONLINE); + $query->where('channel', OrderChannel::STORE); } #[Scope] @@ -119,12 +110,6 @@ protected function transfer(Builder $query): void $query->where('payment_type', PaymentType::TRANSFER); } - #[Scope] - protected function whatsapp(Builder $query): void - { - $query->where('channel', OrderChannel::WHATSAPP); - } - #[Scope] protected function wholesale(Builder $query): void { diff --git a/app/Services/Admin/Manage/TransactionService.php b/app/Services/Admin/Manage/TransactionService.php new file mode 100644 index 0000000..6c2c2e8 --- /dev/null +++ b/app/Services/Admin/Manage/TransactionService.php @@ -0,0 +1,440 @@ +value => 'stock', + ProductStockQuality::REJECT->value => 'reject_stock', + ]; + + private const SELLING_PRICE_MAP = [ + PriceType::DISTRIBUTOR->value => PriceType::DISTRIBUTOR, + PriceType::AGEN->value => PriceType::AGEN, + PriceType::SUB_AGEN->value => PriceType::SUB_AGEN, + PriceType::WHOLESALE->value => PriceType::WHOLESALE, + PriceType::RETAIL->value => PriceType::RETAIL, + PriceType::TIKTOK->value => PriceType::TIKTOK, + PriceType::SHOPEE->value => PriceType::SHOPEE, + ]; + + public function __construct( + private S3PresignedService $s3Service = new S3PresignedService, + ) {} + + public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator + { + $paginator = Order::query() + ->select('id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'subtotal', 'discount', 'nego_price', 'total_amount', 'cogs', 'notes', 'created_at') + ->with([ + 'createdBy:id', + 'createdBy.userProfile:id,user_id,full_name', + 'customer:id,name', + 'marketing:id', + 'marketing.userProfile:id,user_id,full_name', + 'orderItems:id,order_id,product_variant_id,stock_quality,quantity,unit_price,subtotal', + 'orderItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock', + 'orderItems.productVariant.product:id,name', + ]) + ->when($search, function ($q) use ($search) { + $q->whereHas('orderItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%")) + ->orWhere('order_number', 'like', "%{$search}%") + ->orWhere('notes', 'like', "%{$search}%"); + }) + ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status)) + ->when($filters['channel'] ?? null, fn ($q, $channel) => $q->where('channel', $channel)) + ->when($filters['payment_type'] ?? null, fn ($q, $paymentType) => $q->where('payment_type', $paymentType)) + ->when($filters['customer_id'] ?? null, fn ($q, $customerId) => $q->where('customer_id', $customerId)) + ->when($filters['marketing_id'] ?? null, fn ($q, $marketingId) => $q->where('marketing_id', $marketingId)) + ->when($filters['created_by_id'] ?? null, fn ($q, $createdById) => $q->where('created_by_id', $createdById)) + ->orderBy($sort, $direction) + ->paginate($perPage); + + $paginator->getCollection()->each(function (Order $order) { + $order->status_label = $order->status->label(); + $order->payment_type_label = $order->payment_type->label(); + $order->channel_label = $order->channel->label(); + $order->price_type_label = $order->price_type->label(); + + $order->orderItems->each(function (OrderItem $item) { + if (! $item->productVariant) { + return; + } + + $media = $item->productVariant->getFirstMedia('photos'); + $item->productVariant->photo_url = $media + ? $this->s3Service->getTemporaryUrl($media->file_name) + : null; + }); + + $order->profit = $order->total_amount - $order->cogs; + }); + + return $paginator; + } + + public function getFilterOptions(): array + { + return [ + 'customers' => Customer::query() + ->select('id', 'name') + ->orderBy('name') + ->get(), + 'employees' => User::query() + ->select('id') + ->active() + ->with('userProfile:id,user_id,full_name') + ->orderBy('id') + ->get() + ->filter(fn (User $user) => $user->userProfile?->full_name) + ->values() + ->map(fn (User $user) => [ + 'id' => $user->id, + 'name' => $user->userProfile->full_name, + ]), + ]; + } + + public function getForCreate(): array + { + return [ + 'products' => Product::query() + ->select('id', 'name', 'status') + ->with([ + 'productVariants:id,product_id,name,stock,reject_stock', + 'productVariants.productPrices:id,variant_id,type,price', + ]) + ->orderBy('name') + ->get() + ->each(function (Product $product) { + $product->productVariants->each(function (ProductVariant $variant) { + $media = $variant->getFirstMedia('photos'); + $variant->photo_url = $media + ? $this->s3Service->getTemporaryUrl($media->file_name) + : null; + + $prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]); + $variant->prices = $prices; + }); + }), + 'customers' => Customer::query() + ->select('id', 'name') + ->orderBy('name') + ->get(), + 'employees' => User::query() + ->select('id') + ->active() + ->with('userProfile:id,user_id,full_name') + ->orderBy('id') + ->get() + ->filter(fn (User $user) => $user->userProfile?->full_name) + ->values(), + 'channelOptions' => collect(OrderChannel::cases())->map(fn ($c) => ['value' => $c->value, 'label' => $c->label()])->values(), + 'paymentTypeOptions' => collect(PaymentType::cases())->map(fn ($p) => ['value' => $p->value, 'label' => $p->label()])->values(), + 'priceTypeOptions' => collect(PriceType::cases())->filter(fn ($p) => ! in_array($p, [PriceType::CAPITAL]))->map(fn ($p) => ['value' => $p->value, 'label' => $p->label()])->values(), + ]; + } + + public function getForEdit(Order $order): array + { + $order->load('orderItems.productVariant.product'); + + $media = $order->getFirstMedia('photos'); + + return [ + 'id' => $order->id, + 'order_number' => $order->order_number, + 'stock_type' => $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value, + 'channel' => $order->channel?->value ?? OrderChannel::STORE->value, + 'price_type' => $order->price_type?->value ?? PriceType::RETAIL->value, + 'payment_type' => $order->payment_type?->value ?? PaymentType::CASH->value, + 'customer_id' => $order->customer_id, + 'marketing_id' => $order->marketing_id, + 'discount' => $order->discount, + 'nego_price' => $order->nego_price, + 'is_completed' => $order->status === OrderStatus::COMPLETED, + 'is_affiliate' => $order->is_affiliate, + 'tiktok_order_id' => $order->tiktok_order_id, + 'shopee_order_id' => $order->shopee_order_id, + 'notes' => $order->notes, + 'photo_key' => $media?->file_name, + 'photo_url' => $media + ? $this->s3Service->getTemporaryUrl($media->file_name) + : null, + 'items' => $order->orderItems->map(fn (OrderItem $item) => [ + 'id' => $item->id, + 'product_variant_id' => $item->product_variant_id, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + ])->values(), + ]; + } + + public function create(array $data): Order + { + return DB::transaction(function () use ($data) { + $now = now(); + $stockType = $data['stock_type'] ?? ProductStockQuality::GOOD->value; + $priceType = $data['price_type'] ?? PriceType::RETAIL->value; + $channel = $data['channel'] ?? OrderChannel::STORE->value; + $paymentType = $data['payment_type'] ?? PaymentType::CASH->value; + + $subtotal = 0; + $totalCost = 0; + $itemRows = $this->buildItemRows($data['items'], $stockType, $priceType, $now, $subtotal, $totalCost); + + $discount = (int) ($data['discount'] ?? 0); + $negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null; + $totalAmount = $subtotal - $discount + ($negoPrice ?? 0); + + $order = Order::create([ + 'created_by_id' => auth()->id(), + 'customer_id' => $data['customer_id'] ?? null, + 'marketing_id' => $data['marketing_id'] ?? null, + 'order_number' => $this->generateOrderNumber(), + 'channel' => $channel, + 'price_type' => $priceType, + 'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING, + 'payment_type' => $paymentType, + 'is_affiliate' => $data['is_affiliate'] ?? false, + 'tiktok_order_id' => $data['tiktok_order_id'] ?? null, + 'shopee_order_id' => $data['shopee_order_id'] ?? null, + 'subtotal' => $subtotal, + 'discount' => $discount, + 'nego_price' => $negoPrice, + 'total_amount' => $totalAmount, + 'cogs' => $totalCost, + 'notes' => $data['notes'] ?? null, + ]); + + foreach ($itemRows as &$row) { + $row['order_id'] = $order->id; + } + DB::table('order_items')->insert($itemRows); + + $this->applyStock($data['items'], $stockType, -1); + + if ($paymentType !== PaymentType::CASH->value) { + $this->syncPhoto($order, $data); + } + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Admin Toko'], + title: 'Transaksi Baru', + body: 'Transaksi '.$order->order_number.' sebesar Rp '.number_format($totalAmount, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.', + url: route('admin.manage.transactions.index'), + ); + + return $order; + }); + } + + public function update(Order $order, array $data): Order + { + return DB::transaction(function () use ($order, $data) { + $order->load('orderItems'); + + $oldStockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value; + + $order->orderItems->each(function (OrderItem $item) use ($oldStockType) { + $this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $oldStockType); + }); + + $order->orderItems()->delete(); + + $now = now(); + $stockType = $data['stock_type'] ?? ProductStockQuality::GOOD->value; + $priceType = $data['price_type'] ?? PriceType::RETAIL->value; + + $subtotal = 0; + $totalCost = 0; + $itemRows = $this->buildItemRows($data['items'], $stockType, $priceType, $now, $subtotal, $totalCost); + + $discount = (int) ($data['discount'] ?? 0); + $negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null; + $totalAmount = $subtotal - $discount + ($negoPrice ?? 0); + + foreach ($itemRows as &$row) { + $row['order_id'] = $order->id; + } + DB::table('order_items')->insert($itemRows); + + $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, + 'is_affiliate' => $data['is_affiliate'] ?? false, + 'tiktok_order_id' => $data['tiktok_order_id'] ?? null, + 'shopee_order_id' => $data['shopee_order_id'] ?? null, + 'subtotal' => $subtotal, + 'discount' => $discount, + 'nego_price' => $negoPrice, + 'total_amount' => $totalAmount, + 'cogs' => $totalCost, + 'notes' => $data['notes'] ?? null, + ]); + + $this->applyStock($data['items'], $stockType, -1); + + $paymentType = $data['payment_type'] ?? $order->payment_type->value; + if ($paymentType !== PaymentType::CASH->value) { + $this->syncPhoto($order, $data); + } else { + $order->clearMediaCollection('photos'); + } + + return $order; + }); + } + + public function delete(Order $order): bool + { + return DB::transaction(function () use ($order) { + $order->load('orderItems'); + + $stockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value; + + $order->orderItems->each(function (OrderItem $item) use ($stockType) { + $this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType); + }); + + $order->orderItems()->delete(); + $order->clearMediaCollection('photos'); + $order->delete(); + + return true; + }); + } + + private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array + { + $resolvedPriceType = $stockType === ProductStockQuality::REJECT->value + ? PriceType::REJECT + : (self::SELLING_PRICE_MAP[$priceType] ?? PriceType::RETAIL); + + $variantIds = collect($items)->pluck('product_variant_id')->unique()->all(); + $variants = ProductVariant::query() + ->whereKey($variantIds) + ->with('productPrices:id,variant_id,type,price') + ->get(); + + $prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) { + $price = $variant->productPrices + ->first(fn ($p) => $p->type === $resolvedPriceType); + + return [$variant->id => $price?->price ?? 0]; + }); + + $capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) { + $price = $variant->productPrices + ->first(fn ($p) => $p->type === PriceType::CAPITAL); + + return [$variant->id => $price?->price ?? 0]; + }); + + return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $stockType, &$subtotal, &$totalCost) { + $quantity = (int) $item['quantity']; + $unitPrice = (int) ($prices[$item['product_variant_id']] ?? 0); + $itemSubtotal = $unitPrice * $quantity; + $subtotal += $itemSubtotal; + + $capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0); + $totalCost += $capitalPrice * $quantity; + + return [ + 'order_id' => null, + 'user_id' => auth()->id(), + 'product_variant_id' => $item['product_variant_id'], + 'stock_quality' => $stockType, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'subtotal' => $itemSubtotal, + 'created_at' => $now, + 'updated_at' => $now, + ]; + })->toArray(); + } + + private function applyStock(array $items, string $stockType, int $sign): void + { + foreach ($items as $item) { + $this->adjustVariantStock($item['product_variant_id'], $item['quantity'], $sign, $stockType); + } + } + + private function adjustVariantStock(int $variantId, int $quantity, int $sign, string $stockType): void + { + $field = self::QUALITY_STOCK_MAP[$stockType] ?? 'stock'; + + if ($sign > 0) { + ProductVariant::whereKey($variantId)->increment($field, $quantity); + } else { + ProductVariant::whereKey($variantId)->decrement($field, $quantity); + } + } + + private function syncPhoto(Order $order, array $data): void + { + if (! array_key_exists('photo_key', $data)) { + return; + } + + $currentKey = $order->getFirstMedia('photos')?->file_name; + + if ($data['photo_key'] === $currentKey) { + return; + } + + $order->clearMediaCollection('photos'); + + if (! empty($data['photo_key'])) { + $this->registerMedia( + model: $order, + s3Key: $data['photo_key'], + collectionName: 'photos', + orderColumn: 1, + ); + } + } + + private function generateOrderNumber(): string + { + $prefix = 'TRX'; + $date = now()->format('ymd'); + $lastOrder = Order::where('order_number', 'like', "{$prefix}{$date}%") + ->orderByDesc('order_number') + ->first(); + + if ($lastOrder) { + $lastSequence = (int) substr($lastOrder->order_number, -4); + $sequence = $lastSequence + 1; + } else { + $sequence = 1; + } + + return $prefix.$date.str_pad($sequence, 4, '0', STR_PAD_LEFT); + } +} diff --git a/config/database.php b/config/database.php index abbb88e..b545f49 100644 --- a/config/database.php +++ b/config/database.php @@ -64,6 +64,26 @@ ]) : [], ], + 'mysql_old' => [ + 'driver' => 'mysql', + 'url' => env('DB_OLD_URL'), + 'host' => env('DB_OLD_HOST', '127.0.0.1'), + 'port' => env('DB_OLD_PORT', '3306'), + 'database' => env('DB_OLD_DATABASE', 'laravel'), + 'username' => env('DB_OLD_USERNAME', 'root'), + 'password' => env('DB_OLD_PASSWORD', ''), + 'unix_socket' => env('DB_OLD_SOCKET', ''), + 'charset' => env('DB_OLD_CHARSET', 'utf8mb4'), + 'collation' => env('DB_OLD_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + 'mariadb' => [ 'driver' => 'mariadb', 'url' => env('DB_URL'), diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php index 7cca64b..10d52c2 100644 --- a/database/factories/OrderFactory.php +++ b/database/factories/OrderFactory.php @@ -17,10 +17,10 @@ public function definition(): array 'customer_id' => Customer::factory(), 'created_by_id' => User::factory(), 'order_number' => fake()->unique()->numerify('ORD-####'), - 'channel' => fake()->randomElement(['offline', 'online', 'whatsapp', 'shopee', 'tiktok']), + 'channel' => fake()->randomElement(['store', 'shopee', 'tiktok']), 'price_type' => fake()->randomElement(['retail', 'wholesale']), 'status' => 'pending', - 'payment_type' => fake()->randomElement(['cash', 'transfer', 'debit', 'credit']), + 'payment_type' => fake()->randomElement(['cash', 'transfer', 'marketplace', 'qris']), 'is_affiliate' => false, 'subtotal' => $subtotal, 'discount' => $discount, diff --git a/database/migrations/2026_06_12_100001_create_orders_table.php b/database/migrations/2026_06_12_100001_create_orders_table.php index 293f0c4..6062a2a 100644 --- a/database/migrations/2026_06_12_100001_create_orders_table.php +++ b/database/migrations/2026_06_12_100001_create_orders_table.php @@ -33,6 +33,7 @@ public function up(): void $table->unsignedBigInteger('nego_price')->nullable(); $table->json('marketplace_settings_snapshot')->nullable(); $table->unsignedBigInteger('total_amount'); + $table->unsignedBigInteger('cogs')->default(0); $table->text('notes')->nullable(); $table->timestamp('created_at')->useCurrent(); diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 54cf36d..b79a34d 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -30,6 +30,8 @@ public function run(): void 'purchase' => ['view', 'create', 'update', 'delete'], 'cutting' => ['view', 'create', 'update', 'delete'], 'restock' => ['view', 'create', 'update', 'delete'], + 'transaction' => ['view', 'create', 'update', 'delete'], + 'transaction' => ['view', 'create', 'update', 'delete'], 'dashboard' => ['attendance', 'revenue', 'expense', 'orders_channel', 'orders_payment', 'orders_marketing', 'orders_status'], 'analysis' => ['attendance', 'cash', 'raw_materials', 'product_stock', 'revenue', 'expense', 'profit_gross', 'profit_hpp', 'profit_orders', 'marketing_sales', 'top_suppliers', 'top_products', 'top_customers', 'busy_hours'], ]; diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index aad42e4..1a1ffd8 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -49,6 +49,7 @@ import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests'; import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings'; import { index as purchasesIndex } from '@/routes/admin/manage/purchases'; import { index as restocksIndex } from '@/routes/admin/manage/restocks'; +import { index as transactionsIndex } from '@/routes/admin/manage/transactions'; import { index as categoriesIndex } from '@/routes/admin/master/categories'; import { index as customersIndex } from '@/routes/admin/master/customers'; import { index as productsIndex } from '@/routes/admin/master/products'; @@ -81,6 +82,7 @@ const masterItems: NavMenuItem[] = [ const kelolaItems: NavMenuItem[] = [ { title: 'Belanja', href: purchasesIndex.url(), icon: ShoppingCart }, { title: 'Cutting', href: cuttingsIndex.url(), icon: Scissors }, + { title: 'Transaksi', href: transactionsIndex.url(), icon: ShoppingCart }, { title: 'Restock', href: restocksIndex.url(), icon: RefreshCw }, { title: 'Stok Opname', href: '#', icon: ClipboardCheck }, ]; diff --git a/resources/js/components/rupiah-input.tsx b/resources/js/components/rupiah-input.tsx index 6cdb151..e4f734c 100644 --- a/resources/js/components/rupiah-input.tsx +++ b/resources/js/components/rupiah-input.tsx @@ -8,6 +8,7 @@ import { type RupiahInputProps = { name?: string; + id?: string; defaultValue?: number; value?: number; onValueChange?: (value: number) => void; @@ -30,6 +31,7 @@ function parseRupiah(value: string): number { export function RupiahInput({ name, + id, defaultValue = 0, value, onValueChange, @@ -85,6 +87,7 @@ export function RupiahInput({ ; + notes: string; + photo?: string; +}; + +export const transactionDraftStore = + createDraftStore('transaction-draft'); + +export function saveTransactionDraft( + type: 'create' | 'edit', + data: TransactionDraftData, + userId?: number, +): boolean { + return transactionDraftStore.save(type, data, userId); +} + +export function loadTransactionDraft( + type: 'create' | 'edit', + userId?: number, +): TransactionDraftData | null { + return transactionDraftStore.load(type, userId); +} + +export function clearTransactionDraft( + type: 'create' | 'edit', + userId?: number, +): void { + transactionDraftStore.clear(type, userId); +} diff --git a/resources/js/pages/admin/manage/transaction/columns.tsx b/resources/js/pages/admin/manage/transaction/columns.tsx new file mode 100644 index 0000000..a77c51e --- /dev/null +++ b/resources/js/pages/admin/manage/transaction/columns.tsx @@ -0,0 +1,134 @@ +export type TransactionStockType = 'good' | 'reject'; + +export type TransactionChannel = 'offline' | 'online' | 'whatsapp' | 'shopee' | 'tiktok'; + +export type TransactionPriceType = 'distributor' | 'agent' | 'sub_agent' | 'wholesale' | 'retail' | 'tiktok' | 'shopee'; + +export type TransactionPaymentType = 'cash' | 'transfer' | 'marketplace' | 'qris'; + +export type TransactionStatus = 'pending' | 'processing' | 'completed' | 'cancelled' | 'refunded'; + +export type TransactionItem = { + id: number; + product_variant_id: number; + stock_quality: TransactionStockType; + quantity: number; + unit_price: number; + subtotal: number; + product_variant: { + id: number; + name: string; + photo_url: string | null; + product: { + id: number; + name: string; + }; + }; +}; + +export type Transaction = { + id: number; + created_by_id: number; + customer_id: number | null; + marketing_id: number | null; + order_number: string; + channel: TransactionChannel; + price_type: TransactionPriceType; + status: TransactionStatus; + status_label: string; + payment_type: TransactionPaymentType; + payment_type_label: string; + channel_label: string; + price_type_label: string; + profit: number; + cogs: number; + subtotal: number; + discount: number; + nego_price: number | null; + total_amount: number; + notes: string | null; + created_at: string; + created_by: { + id: number; + user_profile: { + full_name: string; + }; + }; + customer: { + id: number; + name: string; + } | null; + marketing: { + id: number; + user_profile: { + full_name: string; + }; + } | null; + order_items: TransactionItem[]; +}; + +export type TransactionForEdit = { + id: number; + order_number: string; + stock_type: TransactionStockType; + channel: TransactionChannel; + price_type: TransactionPriceType; + payment_type: TransactionPaymentType; + customer_id: number | null; + marketing_id: number | null; + discount: number; + nego_price: number | null; + is_completed: boolean; + is_affiliate: boolean; + tiktok_order_id: string | null; + shopee_order_id: string | null; + notes: string | null; + photo_key: string | null; + photo_url: string | null; + items: { + id: number; + product_variant_id: number; + quantity: number; + unit_price: number; + }[]; +}; + +export type ProductForTransaction = { + id: number; + name: string; + status: string; + product_variants: { + id: number; + name: string; + stock: number; + reject_stock: number; + photo_url: string | null; + prices: Record; + }[]; +}; + +export type CustomerForTransaction = { + id: number; + name: string; +}; + +export type EmployeeForTransaction = { + id: number; + user_profile: { + full_name: string; + } | null; +}; + +export type OptionItem = { + value: string; + label: string; +}; + +export type TransactionCreateData = { + products: ProductForTransaction[]; + customers: CustomerForTransaction[]; + employees: EmployeeForTransaction[]; + channelOptions: OptionItem[]; + paymentTypeOptions: OptionItem[]; + priceTypeOptions: OptionItem[]; +}; diff --git a/resources/js/pages/admin/manage/transaction/create.tsx b/resources/js/pages/admin/manage/transaction/create.tsx new file mode 100644 index 0000000..486106c --- /dev/null +++ b/resources/js/pages/admin/manage/transaction/create.tsx @@ -0,0 +1,1042 @@ +'use no memo'; + +import { Form, Head, usePage } from '@inertiajs/react'; +import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { FileUpload } from '@/components/file-upload'; +import { ImagePreviewModal } from '@/components/image-preview-modal'; +import InputError from '@/components/input-error'; +import { NumberInput } from '@/components/number-input'; +import { RupiahInput } from '@/components/rupiah-input'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from '@/components/ui/combobox'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { Textarea } from '@/components/ui/textarea'; +import { useTransactionDraftSave } from '@/hooks/use-transaction-draft'; +import { formatNumber } from '@/lib/format'; +import { loadTransactionDraft } from '@/lib/transaction-draft'; +import { getTemporaryUrl } from '@/lib/upload'; +import { formatCurrency } from '@/lib/utils'; +import { index as transactionIndex, store } from '@/routes/admin/manage/transactions'; +import type { ProductForTransaction, TransactionCreateData } from './columns'; + +type CartLine = { + key: string; + photoUrl: string | null; + title: string; + subtitle: string; + price: number; + quantity: number; + onAdjust: (delta: number) => void; + onSet: (value: number) => void; + onRemove: () => void; +}; + +type Props = { + data: TransactionCreateData; +}; + +const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee']; + +export default function TransactionCreate({ data }: Props) { + const { products, customers, employees, channelOptions, paymentTypeOptions, priceTypeOptions } = data; + const { auth } = usePage().props as { auth: { user?: { id?: number } } }; + const userId = auth.user?.id; + + const draft = loadTransactionDraft('create', userId); + + const [stockType, setStockType] = useState<'good' | 'reject'>( + draft?.stockType === 'reject' ? 'reject' : 'good', + ); + const [channel, setChannel] = useState(draft?.channel ?? 'store'); + const [priceType, setPriceType] = useState(draft?.priceType ?? 'retail'); + const [paymentType, setPaymentType] = useState(draft?.paymentType ?? 'cash'); + const [customerId, setCustomerId] = useState(draft?.customerId ?? null); + const [marketingId, setMarketingId] = useState(draft?.marketingId ?? null); + const [discount, setDiscount] = useState(draft?.discount ?? 0); + const [negoPrice, setNegoPrice] = useState(draft?.negoPrice ?? null); + const [isCompleted, setIsCompleted] = useState(draft?.isCompleted ?? false); + const [isAffiliate, setIsAffiliate] = useState(draft?.isAffiliate ?? false); + const [notes, setNotes] = useState(draft?.notes ?? ''); + const [photo, setPhoto] = useState(draft?.photo ?? null); + const [photoUrl, setPhotoUrl] = useState( + draft?.photo ? getTemporaryUrl(draft.photo) : null, + ); + const [uploading, setUploading] = useState(false); + const [selectedProductId, setSelectedProductId] = useState(draft?.selectedProductId ?? ''); + const [quantities, setQuantities] = useState>(() => + Object.fromEntries( + Object.entries(draft?.quantities ?? {}).map(([id, qty]) => [ + Number(id), + qty, + ]), + ), + ); + const [cartOpen, setCartOpen] = useState(false); + const [previewKey, setPreviewKey] = useState(null); + const [cartRemoveKey, setCartRemoveKey] = useState(null); + const [tiktokOrderId, setTiktokOrderId] = useState(draft?.tiktokOrderId ?? ''); + const [shopeeOrderId, setShopeeOrderId] = useState(draft?.shopeeOrderId ?? ''); + + const quantitiesRef = useRef(quantities); + + useEffect(() => { + quantitiesRef.current = quantities; + }, [quantities]); + + const draftData = useMemo( + () => ({ + stockType, + channel, + priceType, + paymentType, + customerId, + marketingId, + discount, + negoPrice, + isCompleted, + isAffiliate, + tiktokOrderId: channel === 'tiktok' ? tiktokOrderId : '', + shopeeOrderId: channel === 'shopee' ? shopeeOrderId : '', + selectedProductId, + quantities: Object.fromEntries( + Object.entries(quantities).map(([id, qty]) => [ + String(id), + qty, + ]), + ), + notes, + photo: photo ?? undefined, + }), + [ + stockType, channel, priceType, paymentType, + customerId, marketingId, discount, negoPrice, + isCompleted, isAffiliate, tiktokOrderId, shopeeOrderId, + selectedProductId, quantities, notes, photo, + ], + ); + + useTransactionDraftSave('create', draftData, userId); + + const selectedProduct = useMemo( + () => products.find((p) => String(p.id) === selectedProductId) ?? null, + [products, selectedProductId], + ); + + const variantById = useMemo( + () => + new Map( + products.flatMap((p: ProductForTransaction) => + p.product_variants.map((v) => [v.id, v]), + ), + ), + [products], + ); + + useEffect(() => { + if (channel === 'tiktok') { + setPriceType('tiktok'); + setPaymentType('marketplace'); + } else if (channel === 'shopee') { + setPriceType('shopee'); + setPaymentType('marketplace'); + } + }, [channel]); + + useEffect(() => { + if (stockType === 'reject') { + setPriceType('reject'); + } else if (priceType === 'reject') { + setPriceType('retail'); + } + }, [stockType]); + + const showPhoto = paymentType === 'transfer' || paymentType === 'qris'; + + const availablePriceTypes = useMemo(() => { + if (stockType === 'reject') { + return priceTypeOptions.filter((o) => o.value === 'reject'); + } + return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value)); + }, [stockType, priceTypeOptions]); + + const getUnitPrice = useCallback( + (variantId: number) => { + const variant = variantById.get(variantId); + + if (!variant) { + return 0; + } + + if (stockType === 'reject') { + return variant.prices?.reject ?? 0; + } + + return variant.prices?.[priceType] ?? 0; + }, + [variantById, stockType, priceType], + ); + + const subtotal = Object.entries(quantities).reduce( + (sum, [variantId, quantity]) => { + const unitPrice = getUnitPrice(Number(variantId)); + + return sum + unitPrice * quantity; + }, + 0, + ); + + const total = subtotal - discount + (negoPrice ?? 0); + + const updateQuantity = useCallback((variantId: number, value: number) => { + setQuantities((prev) => ({ + ...prev, + [variantId]: Math.max(0, value), + })); + }, []); + + const incrementQuantity = useCallback( + (variantId: number, amount: number) => { + setQuantities((prev) => ({ + ...prev, + [variantId]: Math.max(0, (prev[variantId] ?? 0) + amount), + })); + }, + [], + ); + + const cartItems: CartLine[] = (() => { + const lines: CartLine[] = []; + + for (const [variantId, quantity] of Object.entries(quantities)) { + if (quantity <= 0) { + continue; + } + + const id = Number(variantId); + const variant = variantById.get(id); + + if (variant) { + const unitPrice = getUnitPrice(id); + lines.push({ + key: `variant-${id}`, + photoUrl: variant.photo_url, + title: variant.name, + subtitle: `${formatCurrency(unitPrice)} / pcs`, + price: unitPrice, + quantity, + onAdjust: (delta) => incrementQuantity(id, delta), + onSet: (value) => updateQuantity(id, value), + onRemove: () => updateQuantity(id, 0), + }); + } + } + + return lines; + })(); + + function formatQuantity(value: number): string { + return formatNumber(value, { maximumFractionDigits: 4 }); + } + + function getPayload() { + return { + stock_type: stockType, + channel, + price_type: stockType === 'reject' ? 'reject' : priceType, + payment_type: paymentType, + customer_id: customerId, + marketing_id: marketingId, + discount, + nego_price: negoPrice, + is_completed: isCompleted, + is_affiliate: isAffiliate, + tiktok_order_id: channel === 'tiktok' ? tiktokOrderId || null : null, + shopee_order_id: channel === 'shopee' ? shopeeOrderId || null : null, + items: Object.entries(quantitiesRef.current) + .map(([variantId, quantity]) => ({ + product_variant_id: Number(variantId), + quantity: Number(quantity), + })) + .filter((item) => item.quantity > 0), + notes: notes || null, + photo_key: showPhoto ? photo : null, + }; + } + + return ( + <> + + +
+
+

+ Tambah Transaksi +

+ +
+ +
({ + ...formData, + ...getPayload(), + })} + > + {({ errors, processing }) => ( +
+
+ + + Pilih Produk + + +
+ + + p.name + } + value={selectedProduct} + onValueChange={(value) => + setSelectedProductId( + value + ? String(value.id) + : '', + ) + } + > + + + + Tidak ada produk + ditemukan. + + + {(p) => ( + + {p.name} + + )} + + + + +
+ + {selectedProduct && ( +
+ {selectedProduct.product_variants.map( + (variant) => { + const currentStock = + stockType === 'good' + ? variant.stock + : variant.reject_stock; + + return ( +
0 + ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' + : 'flex items-center justify-between gap-3 rounded-lg border p-3' + } + > +
+ {variant.photo_url ? ( + { + ) : ( +
+ N/A +
+ )} +
+

+ { + variant.name + } +

+

+ Stok:{' '} + {formatQuantity( + Number( + currentStock, + ), + )}{' '} + pcs + ยท{' '} + {formatCurrency( + stockType === 'reject' + ? (variant.prices?.reject ?? 0) + : (variant.prices?.[priceType] ?? 0), + )} +

+
+
+
+ + + updateQuantity( + variant.id, + val, + ) + } + /> + +
+
+ ); + }, + )} +
+ )} +
+
+
+ +
+ + + Ringkasan + + +
+ + + setStockType( + value as + 'good' | 'reject', + ) + } + className="flex flex-wrap gap-4" + > +
+ + +
+
+ + +
+
+ +
+ +
+ + + +
+ + {channel === 'tiktok' && ( +
+ + setTiktokOrderId(e.target.value)} + placeholder="Masukkan ID pesanan TikTok" + className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50" + /> + +
+ )} + + {channel === 'shopee' && ( +
+ + setShopeeOrderId(e.target.value)} + placeholder="Masukkan ID pesanan Shopee" + className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50" + /> + +
+ )} + +
+ + + +
+ +
+ + + +
+ + {showPhoto && ( +
+ + { + setPhoto(key); + setPhotoUrl( + key + ? getTemporaryUrl( + key, + ) + : null, + ); + }} + folder="transaction" + existingUrl={photoUrl} + onUploadingChange={setUploading} + /> + +
+ )} + +
+ + c.name} + value={customers.find((c) => c.id === customerId) ?? null} + onValueChange={(value) => + setCustomerId(value ? value.id : null) + } + > + + + + Tidak ada pelanggan. + + + {(c) => ( + + {c.name} + + )} + + + + +
+ +
+ + + e.user_profile?.full_name ?? '-' + } + value={employees.find((e) => e.id === marketingId) ?? null} + onValueChange={(value) => + setMarketingId(value ? value.id : null) + } + > + + + + Tidak ada karyawan. + + + {(e) => ( + + {e.user_profile?.full_name ?? '-'} + + )} + + + + +
+ +
+
+ + Subtotal + + + {formatCurrency(subtotal)} + +
+ +
+ + + +
+ +
+ + + setNegoPrice(val || null) + } + placeholder="0" + /> + +
+ +
+
+ Total + + {formatCurrency( + total, + )} + +
+
+
+ +
+ + +
+ +
+ + +
+ +
+ +