diff --git a/app/Enums/OrderChannel.php b/app/Enums/OrderChannel.php new file mode 100644 index 0000000..671175b --- /dev/null +++ b/app/Enums/OrderChannel.php @@ -0,0 +1,29 @@ + 'Tiktok', + self::SHOPEE => 'Shopee', + self::TOKOPEDIA => 'Tokopedia', + self::LAZADA => 'Lazada', + self::FACEBOOK => 'Facebook', + self::WEBSITE => 'Website', + self::STORE => 'Toko', + self::OTHER => 'Lainnya', + }; + } +} diff --git a/app/Enums/OrderStatus.php b/app/Enums/OrderStatus.php new file mode 100644 index 0000000..682f353 --- /dev/null +++ b/app/Enums/OrderStatus.php @@ -0,0 +1,23 @@ + 'Menunggu', + self::PROCESSING => 'Proses', + self::SHIPPED => 'Dikirim', + self::DELIVERED => 'Selesai', + self::CANCELLED => 'Gagal', + }; + } +} diff --git a/app/Enums/PaymentMethod.php b/app/Enums/PaymentMethod.php new file mode 100644 index 0000000..72c2df9 --- /dev/null +++ b/app/Enums/PaymentMethod.php @@ -0,0 +1,21 @@ + 'Tunai', + self::TRANSFER => 'Transfer', + self::E_WALLET => 'E-Wallet', + self::QRIS => 'QRIS', + }; + } +} diff --git a/app/Http/Controllers/Admin/Manage/OrderCartController.php b/app/Http/Controllers/Admin/Manage/OrderCartController.php new file mode 100644 index 0000000..64fde36 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/OrderCartController.php @@ -0,0 +1,74 @@ +validated(); + + $item = OrderItem::where('user_id', auth()->id()) + ->whereNull('order_id') + ->where('product_id', $validated['product_id']) + ->where('price_type', $validated['price_type']) + ->first(); + + if ($item) { + $newQty = $item->qty + $validated['qty']; + + if ($newQty <= 0) { + $item->delete(); + } else { + $item->update([ + 'qty' => $newQty, + 'total' => $newQty * $item->price, + ]); + } + } else { + if ($validated['qty'] > 0) { + OrderItem::create([ + 'user_id' => auth()->id(), + 'order_id' => null, + 'product_id' => $validated['product_id'], + 'qty' => $validated['qty'], + 'price' => $validated['price'], + 'total' => $validated['qty'] * $validated['price'], + 'price_type' => $validated['price_type'], + ]); + } + } + + return redirect()->back(); + } + + public function removeFromCart(OrderItem $orderItem): RedirectResponse + { + if ($orderItem->user_id === auth()->id() && $orderItem->order_id === null) { + $orderItem->delete(); + } + + return redirect()->back(); + } + + public function updateCartItem(AddToCartRequest $request, OrderItem $orderItem): RedirectResponse + { + if ($orderItem->user_id !== auth()->id() || $orderItem->order_id !== null) { + return redirect()->back(); + } + + $validated = $request->validated(); + + $orderItem->update([ + 'qty' => $validated['qty'], + 'total' => $validated['qty'] * $orderItem->price, + ]); + + return redirect()->back(); + } +} diff --git a/app/Http/Controllers/Admin/Manage/OrderController.php b/app/Http/Controllers/Admin/Manage/OrderController.php new file mode 100644 index 0000000..d21fe72 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/OrderController.php @@ -0,0 +1,183 @@ + Order::with(['items.product'])->latest()->get(), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/manage/order/create', [ + 'products' => Product::with(['prices', 'categories'])->active()->get(), + 'cartItems' => OrderItem::with(['product.prices', 'product.categories']) + ->where('user_id', auth()->id()) + ->whereNull('order_id') + ->latest() + ->get(), + 'orderStatus' => collect(OrderStatus::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]), + 'orderChannels' => collect(OrderChannel::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]), + 'paymentMethods' => collect(PaymentMethod::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]), + 'priceTypes' => collect(PriceType::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]), + ]); + } + + public function store(OrderRequest $request): RedirectResponse + { + $validated = $request->validated(); + + DB::transaction(function () use ($validated) { + $totalItemsPrice = collect($validated['items'])->sum('total'); + $hpp = collect($validated['items'])->sum(function ($item) { + $product = Product::find($item['product_id']); + $purchasePrice = $product->prices()->where('price_type', PriceType::PURCHASE)->first()?->price ?? 0; + + return $purchasePrice * $item['qty']; + }); + + $order = Order::create([ + 'user_id' => auth()->id(), + 'invoice_number' => $validated['invoice_number'] ?? 'INV-'.now()->format('YmdHis').'-'.strtoupper(fake()->bothify('??##')), + 'customer_name' => $validated['customer_name'], + 'hpp' => $hpp, + 'discount' => $validated['discount'], + 'payment' => $validated['payment'], + 'total' => $totalItemsPrice - $validated['discount'], + 'payment_method' => $validated['payment_method'], + 'order_status' => $validated['order_status'], + 'order_channel' => $validated['order_channel'], + ]); + + foreach ($validated['items'] as $item) { + OrderItem::create([ + 'user_id' => auth()->id(), + 'order_id' => $order->id, + 'product_id' => $item['product_id'], + 'price' => $item['price'], + 'qty' => $item['qty'], + 'total' => $item['total'], + 'price_type' => $item['price_type'], + ]); + + Product::find($item['product_id'])->decrement('stock', $item['qty']); + } + + OrderItem::where('user_id', auth()->id()) + ->whereNull('order_id') + ->delete(); + }); + + return redirect()->route('order.index')->with('success', 'Pesanan berhasil disimpan'); + } + + public function edit(Order $order): Response + { + $order->load(['items.product']); + + return Inertia::render('admin/manage/order/edit', [ + 'order' => $order, + 'products' => Product::with(['prices', 'categories'])->active()->get(), + 'orderStatus' => collect(OrderStatus::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]), + 'orderChannels' => collect(OrderChannel::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]), + 'paymentMethods' => collect(PaymentMethod::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]), + 'priceTypes' => collect(PriceType::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]), + ]); + } + + public function update(OrderRequest $request, Order $order): RedirectResponse + { + $validated = $request->validated(); + + DB::transaction(function () use ($validated, $order) { + $totalItemsPrice = collect($validated['items'])->sum(fn ($item) => $item['qty'] * $item['price']); + $hpp = collect($validated['items'])->sum(function ($item) { + $product = Product::find($item['product_id']); + $purchasePrice = $product->prices()->where('price_type', PriceType::PURCHASE)->first()?->price ?? 0; + + return $purchasePrice * $item['qty']; + }); + + // Restore stock for old items + foreach ($order->items as $item) { + $item->product->increment('stock', $item->qty); + } + + $order->items()->delete(); + + $order->update([ + 'customer_name' => $validated['customer_name'], + 'hpp' => $hpp, + 'discount' => $validated['discount'], + 'payment' => $validated['payment'], + 'total' => $totalItemsPrice - $validated['discount'], + 'payment_method' => $validated['payment_method'], + 'order_status' => $validated['order_status'], + 'order_channel' => $validated['order_channel'], + ]); + + foreach ($validated['items'] as $item) { + $order->items()->create([ + 'user_id' => auth()->id(), + 'product_id' => $item['product_id'], + 'price' => $item['price'], + 'qty' => $item['qty'], + 'total' => $item['total'], + 'price_type' => $item['price_type'], + ]); + + Product::find($item['product_id'])->decrement('stock', $item['qty']); + } + }); + + return redirect()->route('order.index')->with('success', 'Pesanan berhasil diperbarui'); + } + + public function destroy(Order $order): RedirectResponse + { + DB::transaction(function () use ($order) { + foreach ($order->items as $item) { + $item->product->increment('stock', $item->qty); + } + $order->delete(); + }); + + return redirect()->back()->with('success', 'Pesanan berhasil dihapus'); + } + + public function bulkDestroy(Request $request): RedirectResponse + { + $ids = $request->input('ids'); + + DB::transaction(function () use ($ids) { + $orders = Order::with('items')->whereIn('id', $ids)->get(); + foreach ($orders as $order) { + foreach ($order->items as $item) { + $item->product->increment('stock', $item->qty); + } + $order->delete(); + } + }); + + return redirect()->back()->with('success', 'Pesanan terpilih berhasil dihapus'); + } +} diff --git a/app/Http/Requests/Admin/Manage/Order/AddToCartRequest.php b/app/Http/Requests/Admin/Manage/Order/AddToCartRequest.php new file mode 100644 index 0000000..b189e7d --- /dev/null +++ b/app/Http/Requests/Admin/Manage/Order/AddToCartRequest.php @@ -0,0 +1,44 @@ +|string> + */ + public function rules(): array + { + return [ + 'product_id' => [ + Rule::requiredIf($this->isMethod('post')), + Rule::exists('products', 'id'), + ], + 'qty' => ['required', 'integer'], + 'price' => [ + Rule::requiredIf($this->isMethod('post')), + 'integer', + 'min:0', + ], + 'price_type' => [ + Rule::requiredIf($this->isMethod('post')), + Rule::enum(PriceType::class), + ], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/Order/OrderRequest.php b/app/Http/Requests/Admin/Manage/Order/OrderRequest.php new file mode 100644 index 0000000..42f36d8 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/Order/OrderRequest.php @@ -0,0 +1,46 @@ +|string> + */ + public function rules(): array + { + return [ + 'invoice_number' => ['nullable', 'string', 'max:30'], + 'customer_name' => ['required', 'string', 'max:100'], + 'discount' => ['required', 'integer', 'min:0'], + 'payment' => ['required', 'integer', 'min:0'], + 'payment_method' => ['required', Rule::enum(PaymentMethod::class)], + 'order_status' => ['required', Rule::enum(OrderStatus::class)], + 'order_channel' => ['required', Rule::enum(OrderChannel::class)], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_id' => ['required', Rule::exists('products', 'id')->whereNull('deleted_at')], + 'items.*.qty' => ['required', 'integer', 'min:1'], + 'items.*.price' => ['required', 'integer', 'min:0'], + 'items.*.total' => ['required', 'integer', 'min:0'], + 'items.*.price_type' => ['required', Rule::enum(PriceType::class)], + ]; + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000..217fdf1 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,128 @@ + 'integer', + 'discount' => 'integer', + 'payment' => 'integer', + 'total' => 'integer', + 'payment_method' => PaymentMethod::class, + 'order_status' => OrderStatus::class, + 'order_channel' => OrderChannel::class, + ]; + } + + protected function hppFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->hpp, 0, ',', '.'), + ); + } + + protected function discountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->discount, 0, ',', '.'), + ); + } + + protected function paymentFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->payment, 0, ',', '.'), + ); + } + + protected function totalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->total, 0, ',', '.'), + ); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function items(): HasMany + { + return $this->hasMany(OrderItem::class); + } + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['invoice_number', 'customer_name', 'hpp', 'discount', 'payment', 'total', 'payment_method', 'order_status', 'order_channel']) + ->logOnlyDirty() + ->useLogName('Pesanan'); + } + + public function tapActivity(Activity $activity, string $eventName) + { + $activity->description = match ($eventName) { + 'created' => 'TAMBAH', + 'updated' => 'UBAH', + 'deleted' => 'HAPUS', + default => $activity->description, + }; + + if (isset($activity->properties['attributes'])) { + $attributeMap = [ + 'invoice_number' => 'Nomor Invoice', + 'customer_name' => 'Nama Pelanggan', + 'hpp' => 'Modal', + 'discount' => 'Diskon', + 'payment' => 'Bayar', + 'total' => 'Total', + 'payment_method' => 'Metode Pembayaran', + 'order_status' => 'Status Pesanan', + 'order_channel' => 'Saluran Pesanan', + ]; + + $properties = $activity->properties->toArray(); + + $localizeValues = function ($attrs) use ($attributeMap) { + $newAttrs = []; + foreach ($attrs as $key => $value) { + $label = $attributeMap[$key] ?? $key; + $newAttrs[$label] = $value; + } + + return $newAttrs; + }; + + $properties['attributes'] = $localizeValues($properties['attributes']); + + if (isset($properties['old'])) { + $properties['old'] = $localizeValues($properties['old']); + } + + $activity->properties = collect($properties); + } + } +} diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php new file mode 100644 index 0000000..cb669dc --- /dev/null +++ b/app/Models/OrderItem.php @@ -0,0 +1,108 @@ + 'integer', + 'qty' => 'integer', + 'total' => 'integer', + 'price_type' => PriceType::class, + ]; + } + + protected function priceFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->price, 0, ',', '.'), + ); + } + + protected function totalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->total, 0, ',', '.'), + ); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['price', 'qty', 'total', 'price_type']) + ->logOnlyDirty() + ->useLogName('Item Pesanan'); + } + + public function tapActivity(Activity $activity, string $eventName) + { + $activity->description = match ($eventName) { + 'created' => 'TAMBAH', + 'updated' => 'UBAH', + 'deleted' => 'HAPUS', + default => $activity->description, + }; + + if (isset($activity->properties['attributes'])) { + $attributeMap = [ + 'price' => 'Harga', + 'qty' => 'Jumlah', + 'total' => 'Total', + 'price_type' => 'Jenis Harga', + ]; + + $properties = $activity->properties->toArray(); + + $localizeValues = function ($attrs) use ($attributeMap) { + $newAttrs = []; + foreach ($attrs as $key => $value) { + $label = $attributeMap[$key] ?? $key; + $newAttrs[$label] = $value; + } + + return $newAttrs; + }; + + $properties['attributes'] = $localizeValues($properties['attributes']); + + if (isset($properties['old'])) { + $properties['old'] = $localizeValues($properties['old']); + } + + $activity->properties = collect($properties); + } + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index c16699e..e1db688 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -88,6 +88,16 @@ public function prices(): HasMany return $this->hasMany(ProductPrice::class); } + public function orderItems(): HasMany + { + return $this->hasMany(OrderItem::class); + } + + public function purchaseItems(): HasMany + { + return $this->hasMany(PurchaseItem::class); + } + public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() diff --git a/app/Models/User.php b/app/Models/User.php index 1a01619..2c39794 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -45,6 +45,21 @@ public function expenses(): HasMany return $this->hasMany(Expense::class); } + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function orderItems(): HasMany + { + return $this->hasMany(OrderItem::class); + } + + public function purchaseItems(): HasMany + { + return $this->hasMany(PurchaseItem::class); + } + public function payrolls(): HasMany { return $this->hasMany(Payroll::class); diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 0000000..8b97776 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,39 @@ + + */ +class OrderFactory extends Factory +{ + protected $model = Order::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::inRandomOrder()->first()?->id ?? User::factory(), + 'invoice_number' => 'INV-'.now()->format('YmdHis').'-'.strtoupper($this->faker->bothify('??##')), + 'customer_name' => $this->faker->name(), + 'hpp' => 0, + 'discount' => 0, + 'payment' => 0, + 'total' => 0, + 'payment_method' => $this->faker->randomElement(PaymentMethod::cases()), + 'order_status' => $this->faker->randomElement(OrderStatus::cases()), + 'order_channel' => $this->faker->randomElement(OrderChannel::cases()), + ]; + } +} diff --git a/database/factories/OrderItemFactory.php b/database/factories/OrderItemFactory.php new file mode 100644 index 0000000..864a084 --- /dev/null +++ b/database/factories/OrderItemFactory.php @@ -0,0 +1,39 @@ + + */ +class OrderItemFactory extends Factory +{ + protected $model = OrderItem::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $qty = $this->faker->numberBetween(1, 10); + $price = $this->faker->numberBetween(50000, 200000); + + return [ + 'user_id' => User::inRandomOrder()->first()?->id ?? User::factory(), + 'order_id' => Order::factory(), + 'product_id' => Product::inRandomOrder()->first()?->id ?? Product::factory(), + 'price' => $price, + 'qty' => $qty, + 'total' => $price * $qty, + 'price_type' => $this->faker->randomElement(PriceType::cases()), + ]; + } +} diff --git a/database/migrations/2026_04_21_193515_create_orders_table.php b/database/migrations/2026_04_21_193515_create_orders_table.php new file mode 100644 index 0000000..580a582 --- /dev/null +++ b/database/migrations/2026_04_21_193515_create_orders_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('invoice_number', 30); + $table->string('customer_name', 100); + $table->unsignedInteger('hpp'); + $table->unsignedInteger('discount'); + $table->unsignedInteger('payment'); + $table->unsignedInteger('total'); + $table->enum('payment_method', PaymentMethod::cases()); + $table->enum('order_status', OrderStatus::cases()); + $table->enum('order_channel', OrderChannel::cases()); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrentOnUpdate()->nullable(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_04_21_193518_create_order_items_table.php b/database/migrations/2026_04_21_193518_create_order_items_table.php new file mode 100644 index 0000000..4392a38 --- /dev/null +++ b/database/migrations/2026_04_21_193518_create_order_items_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('price'); + $table->unsignedInteger('qty'); + $table->unsignedInteger('total'); + $table->enum('price_type', PriceType::cases()); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrentOnUpdate()->nullable(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('order_items'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c5afec5..55997e8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -19,6 +19,7 @@ public function run(): void ProductSeeder::class, ExpenseSeeder::class, PurchaseSeeder::class, + OrderSeeder::class, ]); } } diff --git a/database/seeders/OrderSeeder.php b/database/seeders/OrderSeeder.php new file mode 100644 index 0000000..e23563f --- /dev/null +++ b/database/seeders/OrderSeeder.php @@ -0,0 +1,64 @@ +isEmpty() || $products->isEmpty()) { + return; + } + + Order::factory(20)->create()->each(function ($order) use ($users, $products) { + $itemsCount = rand(1, 4); + $totalOrderPrice = 0; + $totalOrderHpp = 0; + + for ($i = 0; $i < $itemsCount; $i++) { + $product = $products->random(); + $qty = rand(1, 5); + $price = rand(100000, 300000); + $itemTotal = $qty * $price; + + // Assuming hpp is 70% of price for simulation + $hpp = intval($price * 0.7); + $totalOrderHpp += ($hpp * $qty); + + OrderItem::factory()->create([ + 'order_id' => $order->id, + 'user_id' => $users->random()->id, + 'product_id' => $product->id, + 'qty' => $qty, + 'price' => $price, + 'total' => $itemTotal, + ]); + + $product->decrement('stock', $qty); + $totalOrderPrice += $itemTotal; + } + + $discount = rand(0, 1) ? rand(5000, 20000) : 0; + $finalTotal = max(0, $totalOrderPrice - $discount); + + $order->update([ + 'hpp' => $totalOrderHpp, + 'discount' => $discount, + 'total' => $finalTotal, + 'payment' => $finalTotal, // Assume fully paid + ]); + }); + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 4fe55b6..5b432ae 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,5 +1,5 @@ import { Link } from '@inertiajs/react'; -import { Boxes, DollarSign, History, LayoutGrid, List, ScrollText, Settings, ShoppingCart, User, Wallet } from 'lucide-react'; +import { Boxes, DollarSign, History, LayoutGrid, List, ScrollText, Settings, ShoppingBag, ShoppingCart, User, Wallet } from 'lucide-react'; import AppLogo from '@/components/app-logo'; import { NavMain } from '@/components/nav-main'; import { @@ -20,6 +20,7 @@ import payroll from '@/routes/payroll'; import user from '@/routes/user'; import system from '@/routes/system'; import purchase from '@/routes/purchase'; +import order from '@/routes/order'; const mainNavItems: NavItem[] = [ { @@ -48,6 +49,11 @@ const masterNavItems: NavItem[] = [ ]; const manageNavItems: NavItem[] = [ + { + title: 'Pesanan', + href: order.index().url, + icon: ShoppingBag, + }, { title: 'Belanja', href: purchase.index().url, diff --git a/resources/js/pages/admin/manage/order/create.tsx b/resources/js/pages/admin/manage/order/create.tsx new file mode 100644 index 0000000..4e07da1 --- /dev/null +++ b/resources/js/pages/admin/manage/order/create.tsx @@ -0,0 +1,632 @@ +import { Head, Link, useForm, router } from '@inertiajs/react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Field } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import * as orderRoutes from '@/routes/order'; +import React, { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { Product, ProductPrice } from '@/types'; +import { OrderItem } from '@/types/order'; +import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Tag } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { format } from 'date-fns'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'; +import { + Sheet, + SheetContent, + SheetTrigger, + SheetClose, +} from "@/components/ui/sheet"; +import { NumericFormat } from 'react-number-format'; + +type CartItem = OrderItem & { id: number }; +type EnumOption = { value: string, label: string }; + +export default function OrderCreate({ products, cartItems, orderStatus, orderChannels, paymentMethods, priceTypes }: { + products: Product[], + cartItems: CartItem[], + orderStatus: EnumOption[], + orderChannels: EnumOption[], + paymentMethods: EnumOption[], + priceTypes: EnumOption[] +}) { + const [search, setSearch] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [qtyDialogIndex, setQtyDialogIndex] = useState(null); + const [qtyInputValue, setQtyInputValue] = useState(''); + + // Default price type for the catalog + const [globalPriceType, setGlobalPriceType] = useState('retail'); + + const categories = useMemo(() => { + const map = new Map(); + products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name))); + return Array.from(map.entries()).map(([id, name]) => ({ id, name })); + }, [products]); + + const { data, setData, post, processing, errors, transform } = useForm({ + invoice_number: '', + customer_name: '', + discount: '', + payment: '', + payment_method: 'cash', + order_status: 'delivered', + order_channel: 'store', + items: [] as any[], + }); + + const filteredProducts = products.filter(p => { + const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()); + const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory); + return matchesSearch && matchesCategory; + }); + + const getCartItem = (productId: number, priceType: string) => + cartItems.find(item => item.product_id === productId && item.price_type === priceType); + + const getProductPrice = (product: Product, priceType: string) => { + return product.prices?.find(p => p.price_type === priceType)?.price || 0; + }; + + const addToCart = (product: Product, priceType: string) => { + const price = getProductPrice(product, priceType); + router.post(orderRoutes.addToCart().url, { + product_id: product.id, + qty: 1, + price: price, + price_type: priceType + }, { + preserveScroll: true, + }); + }; + + const decreaseQuantity = (item: CartItem, e: React.MouseEvent) => { + e.stopPropagation(); + router.post(orderRoutes.addToCart().url, { + product_id: item.product_id, + qty: -1, + price: item.price, + price_type: item.price_type + }, { + preserveScroll: true, + }); + }; + + const increaseQuantity = (item: CartItem, e: React.MouseEvent) => { + e.stopPropagation(); + router.post(orderRoutes.addToCart().url, { + product_id: item.product_id, + qty: 1, + price: item.price, + price_type: item.price_type + }, { + preserveScroll: true, + }); + }; + + const removeFromCart = (itemId: number) => { + router.delete(orderRoutes.removeFromCart(itemId).url, { + preserveScroll: true, + }); + }; + + const updateCartQuantity = (itemId: number, qty: number) => { + if (qty < 1) return; + router.patch(orderRoutes.updateCartItem(itemId).url, { + qty + }, { + preserveScroll: true, + }); + }; + + const openQtyDialog = (item: CartItem, e: React.MouseEvent) => { + e.stopPropagation(); + setQtyInputValue(String(item.qty)); + setQtyDialogIndex(item.id); + }; + + const confirmQty = () => { + const val = parseInt(qtyInputValue); + if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) { + updateCartQuantity(qtyDialogIndex, val); + } + setQtyDialogIndex(null); + }; + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (cartItems.length === 0) { + toast.error('Pilih minimal satu produk'); + return; + } + + transform((data) => ({ + ...data, + items: cartItems.map(item => ({ + product_id: item.product_id, + qty: item.qty, + price: item.price, + total: item.total, + price_type: item.price_type + })) + })); + + post(orderRoutes.store().url, { + onSuccess: (response: any) => { + toast.success(response.props.flash.success); + }, + }); + }; + + const subtotal = useMemo(() => { + return cartItems.reduce((acc, item) => acc + item.total, 0); + }, [cartItems]); + + const total = subtotal - data.discount; + const change = data.payment - total; + const cartTotalItems = cartItems.reduce((a, i) => a + i.qty, 0); + + const PriceTypeLabel = ({ type }: { type: string }) => { + const option = priceTypes.find(opt => opt.value === type); + return option ? option.label : type; + }; + + const CartFormContent = () => ( +
+ + + + Keranjang + + + {cartTotalItems} pcs + + + + + + {cartItems.length === 0 ? ( +
+ +

Keranjang masih kosong

+

Klik produk untuk menambahkan

+
+ ) : ( +
+ {cartItems.map((item) => ( +
+
+
+ {item.product?.thumbnail_url ? ( + {item.product.name} + ) : ( +
+ +
+ )} +
+
+

{item.product?.name}

+
+ + + +

+ Rp {item.total.toLocaleString('id-ID')} +

+
+
+ +
+
+
+ + {item.qty} + +
+ × + + Rp {item.price.toLocaleString('id-ID')} + +
+
+ ))} +
+ )} +
+
+ +
+
+ + + setData('customer_name', e.target.value)} placeholder="Nama Pelanggan" /> + {errors.customer_name &&

{errors.customer_name}

} +
+ + + + {errors.order_channel &&

{errors.order_channel}

} +
+
+ +
+ + + + {errors.payment_method &&

{errors.payment_method}

} +
+ + + + {errors.order_status &&

{errors.order_status}

} +
+
+ +
+
+ Subtotal + Rp {subtotal.toLocaleString('id-ID')} +
+
+ Potongan / Diskon +
+ { + setData('discount', values.floatValue || 0) + }} + placeholder="Rp 0" + autoComplete='off' + /> + {errors.discount &&

{errors.discount}

} +
+
+ +
+ Total + Rp {total.toLocaleString('id-ID')} +
+
+ +
+ + + { + setData('payment', values.floatValue || 0) + }} + placeholder="Rp 0" + autoComplete='off' + /> + + {change >= 0 && data.payment > 0 && ( +
+ Kembalian + Rp {change.toLocaleString('id-ID')} +
+ )} +
+ + +
+
+ ); + + return ( +
+ + +
+

Tambah Pesanan

+ + + +
+ +
+
+
+
+ + setSearch(e.target.value)} + /> +
+
+ + +
+
+ + +
+ {filteredProducts.map((product) => { + const cartItem = getCartItem(product.id, globalPriceType); + const currentPrice = getProductPrice(product, globalPriceType); + + return ( + addToCart(product, globalPriceType)} + > + +
+ {product.thumbnail_url ? ( + {product.name} + ) : ( +
+ +
+ )} + {cartItem && ( +
+ {cartItem.qty} +
+ )} +
+ +
+
+ {product.categories?.map(cat => ( + + {cat.name} + + ))} +
+

+ {product.name} +

+
+ + {/* Price + Stepper */} +
e.stopPropagation()} + > +
+ Harga + + Rp {currentPrice.toLocaleString('id-ID')} + +
+
+ + + +
+
+
+
+ ); + })} +
+ {filteredProducts.length === 0 && ( +
+ + + Produk tidak ditemukan + Coba kata kunci lain atau kategori berbeda. + + +
+ )} +
+
+ +
+ + {CartFormContent()} + +
+ +
+
+ + + + + + {CartFormContent()} + + +
+
+
+ + {/* Quantity Input Dialog */} + { if (!open) setQtyDialogIndex(null); }}> + + + Ubah Jumlah + +
+ + setQtyInputValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }} + className="text-lg font-bold" + autoFocus + /> +
+ + + + +
+
+
+ ); +} + +OrderCreate.layout = { + breadcrumbs: [ + { title: 'Kelola' }, + ], +}; diff --git a/resources/js/pages/admin/manage/order/edit.tsx b/resources/js/pages/admin/manage/order/edit.tsx new file mode 100644 index 0000000..5cae9ce --- /dev/null +++ b/resources/js/pages/admin/manage/order/edit.tsx @@ -0,0 +1,619 @@ +import { Head, Link, useForm, router } from '@inertiajs/react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Field } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import * as orderRoutes from '@/routes/order'; +import React, { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { Product, ProductPrice } from '@/types'; +import { Order, OrderItem } from '@/types/order'; +import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Tag } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { format } from 'date-fns'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'; +import { + Sheet, + SheetContent, + SheetTrigger, + SheetClose, +} from "@/components/ui/sheet"; +import { NumericFormat } from 'react-number-format'; + +type EnumOption = { value: string, label: string }; + +export default function OrderEdit({ order, products, orderStatus, orderChannels, paymentMethods, priceTypes }: { + order: Order, + products: Product[], + orderStatus: EnumOption[], + orderChannels: EnumOption[], + paymentMethods: EnumOption[], + priceTypes: EnumOption[] +}) { + const [search, setSearch] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [qtyDialogIndex, setQtyDialogIndex] = useState<{ product_id: number, price_type: string } | null>(null); + const [qtyInputValue, setQtyInputValue] = useState(''); + const [globalPriceType, setGlobalPriceType] = useState('retail'); + + const categories = useMemo(() => { + const map = new Map(); + products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name))); + return Array.from(map.entries()).map(([id, name]) => ({ id, name })); + }, [products]); + + const { data, setData, patch, processing, errors } = useForm({ + invoice_number: order.invoice_number, + customer_name: order.customer_name, + discount: order.discount, + payment: order.payment, + payment_method: order.payment_method, + order_status: order.order_status, + order_channel: order.order_channel, + items: (order.items?.map(item => ({ + product_id: item.product_id, + qty: item.qty, + price: item.price, + total: item.total, + price_type: item.price_type, + product: products.find(p => p.id === item.product_id) || item.product + })) ?? []) as any[], + }); + + const filteredProducts = products.filter(p => { + const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()); + const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory); + return matchesSearch && matchesCategory; + }); + + const findItemIndex = (productId: number, priceType: string) => + data.items.findIndex(item => item.product_id === productId && item.price_type === priceType); + + const getProductPrice = (product: Product, priceType: string) => { + return product.prices?.find(p => p.price_type === priceType)?.price || 0; + }; + + const addToCart = (product: Product, priceType: string) => { + const index = findItemIndex(product.id, priceType); + const price = getProductPrice(product, priceType); + + if (index > -1) { + const newItems = [...data.items]; + newItems[index].qty += 1; + newItems[index].total = newItems[index].qty * newItems[index].price; + setData('items', newItems); + } else { + setData('items', [ + ...data.items, + { + product_id: product.id, + qty: 1, + price: price, + total: price, + price_type: priceType, + product: product + } + ]); + } + }; + + const decreaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => { + e.stopPropagation(); + const index = findItemIndex(productId, priceType); + if (index === -1) return; + + const newItems = [...data.items]; + if (newItems[index].qty <= 1) { + newItems.splice(index, 1); + } else { + newItems[index].qty -= 1; + newItems[index].total = newItems[index].qty * newItems[index].price; + } + setData('items', newItems); + }; + + const increaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => { + e.stopPropagation(); + const product = products.find(p => p.id === productId); + if (product) addToCart(product, priceType); + }; + + const removeFromCart = (productId: number, priceType: string) => { + const newItems = data.items.filter(item => !(item.product_id === productId && item.price_type === priceType)); + setData('items', newItems); + }; + + const updateCartQuantity = (productId: number, priceType: string, qty: number) => { + if (qty < 1) return; + const index = findItemIndex(productId, priceType); + if (index > -1) { + const newItems = [...data.items]; + newItems[index].qty = qty; + newItems[index].total = qty * newItems[index].price; + setData('items', newItems); + } + }; + + const openQtyDialog = (item: any, e: React.MouseEvent) => { + e.stopPropagation(); + setQtyInputValue(String(item.qty)); + setQtyDialogIndex({ product_id: item.product_id, price_type: item.price_type }); + }; + + const confirmQty = () => { + const val = parseInt(qtyInputValue); + if (!isNaN(val) && val >= 1 && qtyDialogIndex) { + updateCartQuantity(qtyDialogIndex.product_id, qtyDialogIndex.price_type, val); + } + setQtyDialogIndex(null); + }; + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (data.items.length === 0) { + toast.error('Pilih minimal satu produk'); + return; + } + + patch(orderRoutes.update(order.id).url, { + onSuccess: (response: any) => { + toast.success(response.props.flash.success); + }, + }); + }; + + const subtotal = useMemo(() => { + return data.items.reduce((acc, item) => acc + item.total, 0); + }, [data.items]); + + const total = subtotal - data.discount; + const change = data.payment - total; + const cartTotalItems = data.items.reduce((a, i) => a + i.qty, 0); + + const PriceTypeLabel = ({ type }: { type: string }) => { + const option = priceTypes.find(opt => opt.value === type); + return option ? option.label : type; + }; + + const CartFormContent = () => ( +
+ + + + Keranjang + + + {cartTotalItems} pcs + + + + + + {data.items.length === 0 ? ( +
+ +

Keranjang masih kosong

+
+ ) : ( +
+ {data.items.map((item, idx) => ( +
+
+
+ {item.product?.thumbnail_url ? ( + {item.product.name} + ) : ( +
+ +
+ )} +
+
+

{item.product?.name}

+
+ + + +

+ Rp {item.total.toLocaleString('id-ID')} +

+
+
+ +
+
+
+ + {item.qty} + +
+ × + + Rp {item.price.toLocaleString('id-ID')} + +
+
+ ))} +
+ )} +
+
+ +
+
+ + + setData('customer_name', e.target.value)} /> + {errors.customer_name &&

{errors.customer_name}

} +
+ + + + {errors.order_channel &&

{errors.order_channel}

} +
+
+ +
+ + + + {errors.payment_method &&

{errors.payment_method}

} +
+ + + + {errors.order_status &&

{errors.order_status}

} +
+
+ +
+
+ Subtotal + Rp {subtotal.toLocaleString('id-ID')} +
+
+ Potongan / Diskon +
+ { + setData('discount', values.floatValue || 0) + }} + placeholder="Rp 0" + autoComplete='off' + /> +
+ {errors.discount &&

{errors.discount}

} +
+
+ +
+ Total + Rp {total.toLocaleString('id-ID')} +
+ +
+ + + { + setData('payment', values.floatValue || 0) + }} + placeholder="Rp 0" + autoComplete='off' + /> + + {change >= 0 && data.payment > 0 && ( +
+ Kembalian + Rp {change.toLocaleString('id-ID')} +
+ )} +
+ + +
+
+ ); + + return ( +
+ + +
+

Ubah Pesanan

+ + + +
+ +
+
+
+
+ + setSearch(e.target.value)} + /> +
+
+ + +
+
+ + +
+ {filteredProducts.map((product) => { + const index = findItemIndex(product.id, globalPriceType); + const cartItem = index > -1 ? data.items[index] : null; + const currentPrice = getProductPrice(product, globalPriceType); + + return ( + addToCart(product, globalPriceType)} + > + +
+ {product.thumbnail_url ? ( + {product.name} + ) : ( +
+ +
+ )} + {cartItem && ( +
+ {cartItem.qty} +
+ )} +
+ +
+
+ {product.categories?.map(cat => ( + + {cat.name} + + ))} +
+

+ {product.name} +

+
+ + {/* Price + Stepper */} +
e.stopPropagation()} + > +
+ Harga + + Rp {currentPrice.toLocaleString('id-ID')} + +
+
+ + + +
+
+
+
+ ); + })} +
+
+
+ +
+ + {CartFormContent()} + +
+ +
+
+ + + + + + {CartFormContent()} + + +
+
+
+ + {/* Quantity Input Dialog */} + { if (!open) setQtyDialogIndex(null); }}> + + + Ubah Jumlah + +
+ + setQtyInputValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }} + className="text-lg font-bold" + autoFocus + /> +
+ + + + +
+
+
+ ); +} + +OrderEdit.layout = { + breadcrumbs: [ + { title: 'Kelola' }, + ], +}; diff --git a/resources/js/pages/admin/manage/order/hooks/use-order-index.ts b/resources/js/pages/admin/manage/order/hooks/use-order-index.ts new file mode 100644 index 0000000..4b88598 --- /dev/null +++ b/resources/js/pages/admin/manage/order/hooks/use-order-index.ts @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { Order } from '@/types/order'; +import { router } from '@inertiajs/react'; +import * as orderRoutes from '@/routes/order'; +import { toast } from 'sonner'; + +export function useOrderIndex() { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + const [orderToDelete, setOrderToDelete] = useState(null); + const [rowsToDelete, setRowsToDelete] = useState([]); + const [rowSelection, setRowSelection] = useState({}); + + const onDelete = (order: Order) => { + setOrderToDelete(order); + setIsDeleteDialogOpen(true); + }; + + const confirmDelete = () => { + if (orderToDelete) { + router.delete(orderRoutes.destroy(orderToDelete.id).url, { + onSuccess: (response: any) => { + toast.success(response.props.flash.success); + setIsDeleteDialogOpen(false); + setOrderToDelete(null); + setRowSelection({}); + }, + }); + } + }; + + const confirmBulkDelete = () => { + router.post(orderRoutes.bulkDestroy().url, { + ids: rowsToDelete.map((row: any) => row.id), + _method: 'DELETE' + }, { + onSuccess: (response: any) => { + toast.success(response.props.flash.success); + setIsBulkDeleteDialogOpen(false); + setRowsToDelete([]); + setRowSelection({}); + }, + }); + }; + + return { + isDeleteDialogOpen, + isBulkDeleteDialogOpen, + orderToDelete, + rowsToDelete, + rowSelection, + setRowSelection, + setRowsToDelete, + setIsDeleteDialogOpen, + setIsBulkDeleteDialogOpen, + onDelete, + confirmDelete, + confirmBulkDelete, + }; +} diff --git a/resources/js/pages/admin/manage/order/index.tsx b/resources/js/pages/admin/manage/order/index.tsx new file mode 100644 index 0000000..14ec0e7 --- /dev/null +++ b/resources/js/pages/admin/manage/order/index.tsx @@ -0,0 +1,130 @@ +import { Head, Link } from '@inertiajs/react'; +import type { Order } from '@/types/order'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Trash2, ShoppingBag } from 'lucide-react'; +import { DataTable } from '@/components/data-table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +import * as orderRoutes from '@/routes/order'; +import { useOrderIndex } from './hooks/use-order-index'; +import { getColumns } from './partials/columns'; + +export default function OrderIndex({ orders }: { orders: Order[] }) { + const { + isDeleteDialogOpen, + isBulkDeleteDialogOpen, + orderToDelete, + rowsToDelete, + rowSelection, + setRowSelection, + setRowsToDelete, + setIsDeleteDialogOpen, + setIsBulkDeleteDialogOpen, + onDelete, + confirmDelete, + confirmBulkDelete, + } = useOrderIndex(); + + const columns = getColumns({ onDelete }); + + return ( +
+ + +
+
+

Pesanan

+
+ + + +
+ + + + { + setRowsToDelete(rows); + setIsBulkDeleteDialogOpen(true); + }, + icon: Trash2, + variant: 'destructive' + }, + ]} + /> + + + + {/* Single Delete Confirmation */} + + + + + + + Hapus data pesanan? + + Tindakan ini tidak dapat dibatalkan. Data pesanan dengan nomor invoice {orderToDelete?.invoice_number} akan dihapus secara permanen dan stok akan dikembalikan. + + + + Batal + Hapus + + + + + {/* Bulk Delete Confirmation */} + + + + + + + Hapus {rowsToDelete.length} data pesanan? + + Tindakan ini tidak dapat dibatalkan. {rowsToDelete.length} pesanan yang terpilih akan dihapus secara permanen. + + + + Batal + + Hapus + + + + +
+ ); +} + +OrderIndex.layout = { + breadcrumbs: [ + { + title: 'Kelola', + } + ], +}; diff --git a/resources/js/pages/admin/manage/order/partials/columns.tsx b/resources/js/pages/admin/manage/order/partials/columns.tsx new file mode 100644 index 0000000..c1a6bbe --- /dev/null +++ b/resources/js/pages/admin/manage/order/partials/columns.tsx @@ -0,0 +1,131 @@ +import { ColumnDef } from '@tanstack/react-table'; +import { Order } from '@/types/order'; +import { DataTableColumnHeader } from '@/components/data-table-column-header'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Button } from '@/components/ui/button'; +import { Pencil, Trash2, ShoppingBag } from 'lucide-react'; +import { Link } from '@inertiajs/react'; +import * as orderRoutes from '@/routes/order'; +import { format } from 'date-fns'; +import { id } from 'date-fns/locale'; +import { Badge } from '@/components/ui/badge'; + +interface ColumnProps { + onDelete: (order: Order) => void; +} + +export const getColumns = ({ onDelete }: ColumnProps): ColumnDef[] => [ + { + accessorKey: "invoice_number", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.invoice_number} + + {format(new Date(row.original.created_at), 'dd MMM yyyy HH:mm', { locale: id })} + +
+ ), + meta: { title: "No. Invoice" }, + }, + { + accessorKey: "customer_name", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.customer_name} + + {row.original.order_channel} + +
+ ), + meta: { title: "Pelanggan" }, + }, + { + accessorKey: "items", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const items = row.original.items; + + if (!items || !Array.isArray(items) || items.length === 0) { + return -; + } + + return ( +
+ {items.slice(0, 2).map((item, i) => ( +
+ {item.product?.name}:{' '} + {item.qty} x {item.price_formatted} +
+ ))} + {items.length > 2 && ( + +{items.length - 2} item lainnya... + )} +
+ ); + }, + meta: { title: "Item" }, + }, + { + accessorKey: "total", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ + {row.original.total_formatted} + + + {row.original.order_status} + +
+ ), + meta: { title: "Total" }, + }, + { + id: "actions", + header: "Aksi", + cell: ({ row }) => { + const order = row.original; + return ( +
+ + + + + + + +

Ubah

+
+
+ + + + + +

Hapus

+
+
+
+ ); + }, + meta: { title: "Aksi" }, + }, +]; diff --git a/resources/js/types/order.ts b/resources/js/types/order.ts new file mode 100644 index 0000000..3757e8f --- /dev/null +++ b/resources/js/types/order.ts @@ -0,0 +1,34 @@ +import { Product } from "./product"; + +export interface Order { + id: number; + invoice_number: string; + customer_name: string; + hpp: number; + discount: number; + payment: number; + total: number; + total_formatted: string; + payment_method: 'cash' | 'transfer' | 'e_wallet' | 'qris'; + order_status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'; + order_channel: 'tiktok' | 'shopee' | 'tokopedia' | 'lazada' | 'facebook' | 'website' | 'store' | 'other'; + items?: OrderItem[]; + created_at: string; + updated_at: string; +} + +export interface OrderItem { + id: number; + order_id: number | null; + user_id: number; + product_id: number; + price: number; + price_formatted: string; + qty: number; + total: number; + total_formatted: string; + price_type: string; + product?: Product; + created_at: string; + updated_at: string; +} diff --git a/routes/manage.php b/routes/manage.php index bac7b07..461b319 100644 --- a/routes/manage.php +++ b/routes/manage.php @@ -1,5 +1,7 @@ name('purchase.addToCart'); Route::delete('purchase/remove-from-cart/{purchaseItem}', [PurchaseCartController::class, 'removeFromCart'])->name('purchase.removeFromCart'); Route::patch('purchase/update-cart-item/{purchaseItem}', [PurchaseCartController::class, 'updateCartItem'])->name('purchase.updateCartItem'); + + Route::get('orders', [OrderController::class, 'index'])->name('order.index'); + Route::get('order/create', [OrderController::class, 'create'])->name('order.create'); + Route::post('order/store', [OrderController::class, 'store'])->name('order.store'); + Route::get('order/edit/{order}', [OrderController::class, 'edit'])->name('order.edit'); + Route::patch('order/update/{order}', [OrderController::class, 'update'])->name('order.update'); + Route::delete('order/destroy/{order}', [OrderController::class, 'destroy'])->name('order.destroy'); + Route::delete('order/bulk-destroy', [OrderController::class, 'bulkDestroy'])->name('order.bulkDestroy'); + + // Order Cart routes + Route::post('order/add-to-cart', [OrderCartController::class, 'addToCart'])->name('order.addToCart'); + Route::delete('order/remove-from-cart/{orderItem}', [OrderCartController::class, 'removeFromCart'])->name('order.removeFromCart'); + Route::patch('order/update-cart-item/{orderItem}', [OrderCartController::class, 'updateCartItem'])->name('order.updateCartItem'); }); });