From 0998c81803ac7ad14e79aadf296a56258d07d0c7 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sun, 16 Aug 2026 14:27:19 +0700 Subject: [PATCH] feat: split 'reject' price type into 'reject_capital' and 'reject_selling', update related components and database migration --- .ai/REFERENCE.md | 2 +- app/DataFixes/FixOrderItemsUnitPrice.php | 2 +- app/Enums/PriceType.php | 6 +- .../Admin/Manage/TransactionRequest.php | 2 +- app/Services/Admin/Manage/RestockService.php | 2 +- .../Admin/Manage/TransactionService.php | 11 +++- .../Master/Product/ProductVariantService.php | 14 ++--- ...6_08_15_200000_split_reject_price_type.php | 62 +++++++++++++++++++ .../notification-permission-prompt.tsx | 20 +++--- .../pages/admin/manage/transaction/create.tsx | 14 ++--- .../pages/admin/manage/transaction/edit.tsx | 14 ++--- .../js/pages/admin/master/product/create.tsx | 3 +- .../js/pages/admin/master/product/edit.tsx | 12 +++- .../admin/master/product/variant/edit.tsx | 29 +++++---- tests/Feature/Admin/Master/ProductTest.php | 17 +++-- 15 files changed, 148 insertions(+), 62 deletions(-) create mode 100644 database/migrations/2026_08_15_200000_split_reject_price_type.php diff --git a/.ai/REFERENCE.md b/.ai/REFERENCE.md index 764882b..105491b 100644 --- a/.ai/REFERENCE.md +++ b/.ai/REFERENCE.md @@ -300,7 +300,7 @@ ## Enums | `PayrollPeriodStatus` | open, closed | payroll_periods.status | | `PayrollStatus` | unpaid, paid, cancelled | payrolls.status | | `Permission` | — | Permission action names | -| `PriceType` | retail, wholesale, capital | product_prices.type, orders.price_type | +| `PriceType` | retail, wholesale, capital, reject_capital, reject_selling | product_prices.type, orders.price_type | | `ProductStatus` | active, draft, inactive, pending, rejected | products.status | | `ProductStockQuality` | good, reject | order_items.stock_quality, restocks.stock_type, stok_opname_items.stock_quality | | `RawMaterialUnit` | kg, meter, yard | raw_materials.unit | diff --git a/app/DataFixes/FixOrderItemsUnitPrice.php b/app/DataFixes/FixOrderItemsUnitPrice.php index 0edc299..eb61ae8 100644 --- a/app/DataFixes/FixOrderItemsUnitPrice.php +++ b/app/DataFixes/FixOrderItemsUnitPrice.php @@ -108,7 +108,7 @@ public function fix(): array private function resolvePriceType(string $orderPriceType, string $stockQuality): string { if ($stockQuality === ProductStockQuality::REJECT->value) { - return PriceType::REJECT->value; + return PriceType::REJECT_SELLING->value; } $map = [ diff --git a/app/Enums/PriceType.php b/app/Enums/PriceType.php index c1eb662..744b2dc 100644 --- a/app/Enums/PriceType.php +++ b/app/Enums/PriceType.php @@ -16,7 +16,8 @@ enum PriceType: string case TIKTOK = 'tiktok'; case SHOPEE = 'shopee'; case CAPITAL = 'capital'; - case REJECT = 'reject'; + case REJECT_CAPITAL = 'reject_capital'; + case REJECT_SELLING = 'reject_selling'; public function label(): string { @@ -29,7 +30,8 @@ public function label(): string self::TIKTOK => 'TikTok', self::SHOPEE => 'Shopee', self::CAPITAL => 'Modal', - self::REJECT => 'Reject', + self::REJECT_CAPITAL => 'Reject Modal', + self::REJECT_SELLING => 'Reject Jual', }; } } diff --git a/app/Http/Requests/Admin/Manage/TransactionRequest.php b/app/Http/Requests/Admin/Manage/TransactionRequest.php index 77c9c37..86b33b1 100644 --- a/app/Http/Requests/Admin/Manage/TransactionRequest.php +++ b/app/Http/Requests/Admin/Manage/TransactionRequest.php @@ -30,7 +30,7 @@ public function rules(): array return [ 'stock_type' => ['sometimes', 'required', Rule::in(ProductStockQuality::values())], 'channel' => ['sometimes', 'required', Rule::in(OrderChannel::values())], - 'price_type' => ['sometimes', 'required', Rule::in(array_diff(PriceType::values(), [PriceType::CAPITAL->value]))], + 'price_type' => ['sometimes', 'required', Rule::in(array_diff(PriceType::values(), [PriceType::CAPITAL->value, PriceType::REJECT_CAPITAL->value]))], 'payment_type' => ['sometimes', 'required', Rule::in(PaymentType::values())], 'customer_id' => ['nullable', 'integer', Rule::exists('customers', 'id')], 'marketing_id' => ['nullable', 'integer', Rule::exists('users', 'id')], diff --git a/app/Services/Admin/Manage/RestockService.php b/app/Services/Admin/Manage/RestockService.php index ac65c36..4c5bfc6 100644 --- a/app/Services/Admin/Manage/RestockService.php +++ b/app/Services/Admin/Manage/RestockService.php @@ -188,7 +188,7 @@ public function destroy(Restock $restock): bool private function buildItemRows(array $items, string $stockType, $now, int &$total): array { $priceType = $stockType === ProductStockQuality::REJECT->value - ? PriceType::REJECT + ? PriceType::REJECT_CAPITAL : PriceType::CAPITAL; $variantIds = collect($items)->pluck('product_variant_id')->unique()->all(); diff --git a/app/Services/Admin/Manage/TransactionService.php b/app/Services/Admin/Manage/TransactionService.php index d3ed68e..49466ab 100644 --- a/app/Services/Admin/Manage/TransactionService.php +++ b/app/Services/Admin/Manage/TransactionService.php @@ -33,6 +33,7 @@ class TransactionService PriceType::RETAIL->value => PriceType::RETAIL, PriceType::TIKTOK->value => PriceType::TIKTOK, PriceType::SHOPEE->value => PriceType::SHOPEE, + PriceType::REJECT_SELLING->value => PriceType::REJECT_SELLING, ]; public function __construct( @@ -355,7 +356,7 @@ public function updateStatus(Order $order, string $status): Order private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array { $resolvedPriceType = $stockType === ProductStockQuality::REJECT->value - ? PriceType::REJECT + ? PriceType::REJECT_SELLING : (self::SELLING_PRICE_MAP[$priceType] ?? PriceType::RETAIL); $variantIds = collect($items)->pluck('product_variant_id')->unique()->all(); @@ -375,9 +376,13 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp return [$variant->id => $price?->price ?? 0]; }); - $capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) { + $capitalPriceType = $stockType === ProductStockQuality::REJECT->value + ? PriceType::REJECT_CAPITAL + : PriceType::CAPITAL; + + $capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) use ($capitalPriceType) { $price = $variant->productPrices - ->first(fn ($p) => $p->type === PriceType::CAPITAL); + ->first(fn ($p) => $p->type === $capitalPriceType); return [$variant->id => $price?->price ?? 0]; }); diff --git a/app/Services/Admin/Master/Product/ProductVariantService.php b/app/Services/Admin/Master/Product/ProductVariantService.php index 8850688..ecddb33 100644 --- a/app/Services/Admin/Master/Product/ProductVariantService.php +++ b/app/Services/Admin/Master/Product/ProductVariantService.php @@ -41,11 +41,11 @@ public function getForRestock(): array if ($allVariantIds !== []) { $mediaByVariant = Media::query() - ->whereIn('mediable_id', $allVariantIds) - ->where('mediable_type', ProductVariant::class) + ->whereIn('model_id', $allVariantIds) + ->where('model_type', ProductVariant::class) ->where('collection_name', 'images') ->get() - ->groupBy('mediable_id'); + ->groupBy('model_id'); } else { $mediaByVariant = collect(); } @@ -62,7 +62,7 @@ public function getForRestock(): array $variant->capital_price = $capitalPrice?->price ?? 0; $rejectPrice = $variant->productPrices - ->first(fn ($price) => $price->type === PriceType::REJECT); + ->first(fn ($price) => $price->type === PriceType::REJECT_CAPITAL); $variant->reject_price = $rejectPrice?->price ?? 0; }); })->toArray(); @@ -84,11 +84,11 @@ public function getForTransaction(): array if ($allVariantIds !== []) { $mediaByVariant = Media::query() - ->whereIn('mediable_id', $allVariantIds) - ->where('mediable_type', ProductVariant::class) + ->whereIn('model_id', $allVariantIds) + ->where('model_type', ProductVariant::class) ->where('collection_name', 'images') ->get() - ->groupBy('mediable_id'); + ->groupBy('model_id'); } else { $mediaByVariant = collect(); } diff --git a/database/migrations/2026_08_15_200000_split_reject_price_type.php b/database/migrations/2026_08_15_200000_split_reject_price_type.php new file mode 100644 index 0000000..9934284 --- /dev/null +++ b/database/migrations/2026_08_15_200000_split_reject_price_type.php @@ -0,0 +1,62 @@ +where('type', 'reject') + ->update(['type' => 'reject_selling']); + + // Insert reject_capital rows (price = 0) for each variant that now has reject_selling + $rejectSellingPrices = DB::table('product_prices') + ->where('type', 'reject_selling') + ->get(); + + foreach ($rejectSellingPrices as $row) { + DB::table('product_prices')->insert([ + 'variant_id' => $row->variant_id, + 'type' => 'reject_capital', + 'price' => 0, + 'created_at' => $row->created_at, + 'updated_at' => $row->updated_at, + ]); + } + + // 2. orders: rename 'reject' → 'reject_selling' + DB::table('orders') + ->where('price_type', 'reject') + ->update(['price_type' => 'reject_selling']); + + // 3. Modify enum columns to reflect new values (MySQL only) + if (DB::getDriverName() === 'mysql') { + DB::statement("ALTER TABLE product_prices MODIFY COLUMN type ENUM('distributor','agent','sub_agent','wholesale','retail','tiktok','shopee','capital','reject_capital','reject_selling') NOT NULL"); + DB::statement("ALTER TABLE orders MODIFY COLUMN price_type ENUM('distributor','agent','sub_agent','wholesale','retail','tiktok','shopee','capital','reject_capital','reject_selling') NOT NULL"); + } + } + + public function down(): void + { + // Reverse: rename back and clean up + DB::table('product_prices') + ->where('type', 'reject_capital') + ->delete(); + + DB::table('product_prices') + ->where('type', 'reject_selling') + ->update(['type' => 'reject']); + + DB::table('orders') + ->where('price_type', 'reject_selling') + ->update(['price_type' => 'reject']); + + if (DB::getDriverName() === 'mysql') { + DB::statement("ALTER TABLE product_prices MODIFY COLUMN type ENUM('distributor','agent','sub_agent','wholesale','retail','tiktok','shopee','capital','reject') NOT NULL"); + DB::statement("ALTER TABLE orders MODIFY COLUMN price_type ENUM('distributor','agent','sub_agent','wholesale','retail','tiktok','shopee','capital','reject') NOT NULL"); + } + } +}; diff --git a/resources/js/components/notifications/notification-permission-prompt.tsx b/resources/js/components/notifications/notification-permission-prompt.tsx index 55350c5..6656e3e 100644 --- a/resources/js/components/notifications/notification-permission-prompt.tsx +++ b/resources/js/components/notifications/notification-permission-prompt.tsx @@ -10,21 +10,21 @@ export function NotificationPermissionPrompt() { usePushNotification(); useEffect(() => { - if (!isSupported || hasRequested.current) { - return; - } - - if (Notification.permission !== 'default') { + if (!isSupported || hasRequested.current || !vapidPublicKey) { return; } hasRequested.current = true; - void requestPermission().then(async (result) => { - if (result === 'granted' && vapidPublicKey) { - await subscribe(vapidPublicKey); - } - }); + if (Notification.permission === 'default') { + void requestPermission().then(async (result) => { + if (result === 'granted') { + await subscribe(vapidPublicKey); + } + }); + } else if (Notification.permission === 'granted') { + void subscribe(vapidPublicKey); + } }, [isSupported, requestPermission, subscribe, vapidPublicKey]); return null; diff --git a/resources/js/pages/admin/manage/transaction/create.tsx b/resources/js/pages/admin/manage/transaction/create.tsx index a925aae..32735ad 100644 --- a/resources/js/pages/admin/manage/transaction/create.tsx +++ b/resources/js/pages/admin/manage/transaction/create.tsx @@ -68,7 +68,7 @@ type Props = { priceTypeOptions: TransactionCreateData['priceTypeOptions']; }; -const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee']; +const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee', 'reject_selling']; export default function TransactionCreate({ products, @@ -181,8 +181,8 @@ export default function TransactionCreate({ useEffect(() => { if (stockType === 'reject') { - setPriceType('reject'); - } else if (priceType === 'reject') { + setPriceType('reject_selling'); + } else if (priceType === 'reject_selling') { setPriceType('retail'); } }, [stockType]); @@ -191,7 +191,7 @@ export default function TransactionCreate({ const availablePriceTypes = useMemo(() => { if (stockType === 'reject') { - return priceTypeOptions.filter((o) => o.value === 'reject'); + return priceTypeOptions.filter((o) => o.value === 'reject_selling'); } return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value)); @@ -206,7 +206,7 @@ export default function TransactionCreate({ } if (stockType === 'reject') { - return variant.prices?.reject ?? 0; + return variant.prices?.reject_selling ?? 0; } return variant.prices?.[priceType] ?? 0; @@ -284,7 +284,7 @@ export default function TransactionCreate({ return { stock_type: stockType, channel, - price_type: stockType === 'reject' ? 'reject' : priceType, + price_type: stockType === 'reject' ? 'reject_selling' : priceType, payment_type: paymentType, customer_id: customerId, marketing_id: marketingId, @@ -443,7 +443,7 @@ export default function TransactionCreate({ ) : ( formatCurrency( stockType === 'reject' - ? (variant.prices?.reject ?? 0) + ? (variant.prices?.reject_selling ?? 0) : (variant.prices?.[priceType] ?? 0), ) )} diff --git a/resources/js/pages/admin/manage/transaction/edit.tsx b/resources/js/pages/admin/manage/transaction/edit.tsx index 034e617..e86c1f3 100644 --- a/resources/js/pages/admin/manage/transaction/edit.tsx +++ b/resources/js/pages/admin/manage/transaction/edit.tsx @@ -67,7 +67,7 @@ type Props = { priceTypeOptions: TransactionCreateData['priceTypeOptions']; }; -const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee']; +const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee', 'reject_selling']; export default function TransactionEdit({ transaction, @@ -134,8 +134,8 @@ export default function TransactionEdit({ useEffect(() => { if (stockType === 'reject') { - setPriceType('reject'); - } else if (priceType === 'reject') { + setPriceType('reject_selling'); + } else if (priceType === 'reject_selling') { setPriceType('retail'); } }, [stockType]); @@ -162,7 +162,7 @@ export default function TransactionEdit({ const availablePriceTypes = useMemo(() => { if (stockType === 'reject') { - return priceTypeOptions.filter((o) => o.value === 'reject'); + return priceTypeOptions.filter((o) => o.value === 'reject_selling'); } return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value)); @@ -177,7 +177,7 @@ export default function TransactionEdit({ } if (stockType === 'reject') { - return variant.prices?.reject ?? 0; + return variant.prices?.reject_selling ?? 0; } return variant.prices?.[priceType] ?? 0; @@ -255,7 +255,7 @@ export default function TransactionEdit({ return { stock_type: stockType, channel, - price_type: stockType === 'reject' ? 'reject' : priceType, + price_type: stockType === 'reject' ? 'reject_selling' : priceType, payment_type: paymentType, customer_id: customerId, marketing_id: marketingId, @@ -423,7 +423,7 @@ export default function TransactionEdit({ ) : ( formatCurrency( stockType === 'reject' - ? (variant.prices?.reject ?? 0) + ? (variant.prices?.reject_selling ?? 0) : (variant.prices?.[priceType] ?? 0), ) )} diff --git a/resources/js/pages/admin/master/product/create.tsx b/resources/js/pages/admin/master/product/create.tsx index f23ef33..7d49ed0 100644 --- a/resources/js/pages/admin/master/product/create.tsx +++ b/resources/js/pages/admin/master/product/create.tsx @@ -43,7 +43,8 @@ const PRICE_TYPES = [ { key: 'tiktok', label: 'TikTok' }, { key: 'shopee', label: 'Shopee' }, { key: 'capital', label: 'Modal' }, - { key: 'reject', label: 'Reject' }, + { key: 'reject_capital', label: 'Reject Modal' }, + { key: 'reject_selling', label: 'Reject Jual' }, ]; function createEmptyPrices(): Array<{ type: string; price: number }> { diff --git a/resources/js/pages/admin/master/product/edit.tsx b/resources/js/pages/admin/master/product/edit.tsx index ff7b91f..7081f56 100644 --- a/resources/js/pages/admin/master/product/edit.tsx +++ b/resources/js/pages/admin/master/product/edit.tsx @@ -61,7 +61,8 @@ const PRICE_TYPES = [ { key: 'tiktok', label: 'TikTok' }, { key: 'shopee', label: 'Shopee' }, { key: 'capital', label: 'Modal' }, - { key: 'reject', label: 'Reject' }, + { key: 'reject_capital', label: 'Reject Modal' }, + { key: 'reject_selling', label: 'Reject Jual' }, ]; function createEmptyPrices(): Array<{ type: string; price: number }> { @@ -104,6 +105,13 @@ export default function ProductEdit({ product, categories }: Props) { product.description ?? '', ); + function normalizePrices(serverPrices: Array<{ type: string; price: number }>): Array<{ type: string; price: number }> { + return PRICE_TYPES.map((pt) => { + const existing = serverPrices.find((p) => p.type === pt.key); + return { type: pt.key, price: existing?.price ?? 0 }; + }); + } + const serverVariants: VariantState[] = product.product_variants.map( (v) => ({ id: v.id, @@ -116,7 +124,7 @@ export default function ProductEdit({ product, categories }: Props) { url: v.photo_urls[i] ?? null, })), uploading: false, - prices: v.prices.length > 0 ? v.prices : createEmptyPrices(), + prices: normalizePrices(v.prices), }), ); diff --git a/resources/js/pages/admin/master/product/variant/edit.tsx b/resources/js/pages/admin/master/product/variant/edit.tsx index 8ccbfec..f62f7dc 100644 --- a/resources/js/pages/admin/master/product/variant/edit.tsx +++ b/resources/js/pages/admin/master/product/variant/edit.tsx @@ -35,7 +35,8 @@ const PRICE_TYPES = [ { key: 'tiktok', label: 'TikTok' }, { key: 'shopee', label: 'Shopee' }, { key: 'capital', label: 'Modal' }, - { key: 'reject', label: 'Reject' }, + { key: 'reject_capital', label: 'Reject Modal' }, + { key: 'reject_selling', label: 'Reject Jual' }, ]; export default function ProductVariantEdit({ variant }: Props) { @@ -53,14 +54,15 @@ export default function ProductVariantEdit({ variant }: Props) { const [prices, setPrices] = useState< Array<{ type: string; price: number }> >( - variant.prices.length > 0 - ? variant.prices - : PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 })), + PRICE_TYPES.map((pt) => { + const existing = variant.prices.find((p) => p.type === pt.key); + return { type: pt.key, price: existing?.price ?? 0 }; + }), ); - function updatePrice(priceIndex: number, value: number) { + function updatePrice(priceType: string, value: number) { setPrices((prev) => - prev.map((p, i) => (i === priceIndex ? { ...p, price: value } : p)), + prev.map((p) => (p.type === priceType ? { ...p, price: value } : p)), ); } @@ -200,7 +202,9 @@ export default function ProductVariantEdit({ variant }: Props) {
{PRICE_TYPES.map( - (priceType, priceIndex) => ( + (priceType) => { + const priceEntry = prices.find((p) => p.type === priceType.key); + return (
updatePrice( - priceIndex, + priceType.key, val, ) } @@ -230,12 +232,13 @@ export default function ProductVariantEdit({ variant }: Props) {
- ), + ); + }, )}
diff --git a/tests/Feature/Admin/Master/ProductTest.php b/tests/Feature/Admin/Master/ProductTest.php index 60455a9..db3bd23 100644 --- a/tests/Feature/Admin/Master/ProductTest.php +++ b/tests/Feature/Admin/Master/ProductTest.php @@ -45,7 +45,8 @@ function makeValidProductPayload(array $overrides = []): array ['type' => 'tiktok', 'price' => 16000], ['type' => 'shopee', 'price' => 16500], ['type' => 'capital', 'price' => 8000], - ['type' => 'reject', 'price' => 5000], + ['type' => 'reject_capital', 'price' => 5000], + ['type' => 'reject_selling', 'price' => 6000], ], ], ], @@ -71,7 +72,8 @@ function makeValidSharedPricePayload(array $overrides = []): array ['type' => 'tiktok', 'price' => 16000], ['type' => 'shopee', 'price' => 16500], ['type' => 'capital', 'price' => 8000], - ['type' => 'reject', 'price' => 5000], + ['type' => 'reject_capital', 'price' => 5000], + ['type' => 'reject_selling', 'price' => 6000], ], 'variants' => [ [ @@ -97,7 +99,8 @@ function allPriceTypes(): array ['type' => 'tiktok', 'price' => 16000], ['type' => 'shopee', 'price' => 16500], ['type' => 'capital', 'price' => 8000], - ['type' => 'reject', 'price' => 5000], + ['type' => 'reject_capital', 'price' => 5000], + ['type' => 'reject_selling', 'price' => 6000], ]; } @@ -1946,7 +1949,8 @@ function allPriceTypes(): array ['type' => 'tiktok', 'price' => 16000], ['type' => 'shopee', 'price' => 16500], ['type' => 'capital', 'price' => 8000], - ['type' => 'reject', 'price' => 5000], + ['type' => 'reject_capital', 'price' => 5000], + ['type' => 'reject_selling', 'price' => 6000], ]; $response = $this->post(route('admin.master.products.store'), makeValidProductPayload([ @@ -1965,9 +1969,10 @@ function allPriceTypes(): array $variant = ProductVariant::where('name', 'Semua Harga')->first(); $dbPrices = ProductPrice::where('variant_id', $variant->id)->get(); - expect($dbPrices)->toHaveCount(9); + expect($dbPrices)->toHaveCount(10); expect($dbPrices->firstWhere('type', 'distributor')->price)->toBe(10000); - expect($dbPrices->firstWhere('type', 'reject')->price)->toBe(5000); + expect($dbPrices->firstWhere('type', 'reject_capital')->price)->toBe(5000); + expect($dbPrices->firstWhere('type', 'reject_selling')->price)->toBe(6000); }); test('shared price applies to all variants', function () {