feat: overhaul POS management workflows including enhanced purchase, order, cutting, and stock-opname forms with updated master data handling.

This commit is contained in:
Yoga Pangestu 2026-06-28 17:33:13 +07:00
parent b3790f2b2f
commit 816c2a0e2a
113 changed files with 4791 additions and 3636 deletions

View File

@ -416,6 +416,39 @@ ### Modifikasi Tabel (Alter Schema)
## JavaScript / Vue
### Komponen Reusable vs Page-Specific
- `resources/js/components/` → komponen **reusable** lintas page (UI primitives, catalog POS, printer, display formatter, dll.).
- `resources/js/pages/{module}/form/` dan `table/` → komponen **khusus page** (modal form, columns, actions).
- Jika komponen dipakai **lebih dari satu modul/page**, pindahkan ke `resources/js/components/{domain}/`.
- Contoh: `PosCatalogCard`, `PosCatalogVariantThumb``components/catalog/`
- Contoh: `OrderPrintButton`, `ThermalPrinterConnectButton``components/order/`
### Format Data dan Attribute Model
- Untuk **tampilan read-only**, gunakan field `*_formatted` dari API/model accessor.
- **Jangan** format manual di view jika accessor model sudah tersedia (misal: `stock_formatted`, `price_formatted`).
- Format **live/calc** di form (cart total, input rupiah) boleh pakai `formatRupiah()` / `RupiahInput`.
- Komponen display: `RupiahText` (`amount` + optional `formatted` prop).
### Types
- Type domain di `resources/js/types/{domain}.ts`.
- Type shared di `resources/js/types/common.ts`: `Paginated<T>`, `SelectOption`, `EnumOption`.
- Hindari duplikasi type di page; import dari `@/types/*`.
### Form Request dan View Label
- Label validasi harus **sama persis** dengan `<FieldLabel>` di view.
- Gunakan `attributes()` pada Form Request (locale `id``:Attribute wajib diisi.`).
- Contoh: view `Nama Produk``'name' => 'Nama Produk'`.
- Pesan custom untuk rule nested/business logic gunakan `messages()`.
### Pemisahan File
- Pecah file besar berdasarkan **fungsi**: catalog panel, cart panel, metadata form, composable logic.
- Target ideal: **< 300 baris** per komponen.
### Struktur Folder Page-Specific Components
Komponen yang hanya digunakan oleh satu page tertentu (misalnya modal form, kolom tabel, action button) **harus ditempatkan di folder page-nya langsung** di dalam subfolder sesuai fungsinya, bukan di `resources/js/components/`.

View File

@ -9,6 +9,7 @@
use App\Http\Requests\Admin\ToggleStatusRequest;
use App\Models\Product;
use App\Services\Master\CategoryService;
use App\Services\Master\MasterInventoryService;
use App\Services\Master\ProductService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -22,6 +23,7 @@ class ProductController extends Controller
public function __construct(
private readonly ProductService $productService,
private readonly CategoryService $categoryService,
private readonly MasterInventoryService $masterInventoryService,
) {}
public function index(Request $request): Response
@ -34,6 +36,7 @@ public function index(Request $request): Response
return Inertia::render('admin/master/products/Index', [
'products' => $this->productService->paginateForIndex($tableQuery, $isActive, $categoryId, $stockStatus),
'categories' => $this->categoryService->getSelectOptions(),
'outOfStockGroups' => $this->masterInventoryService->outOfStockProductGroups(),
'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive,
'category_id' => $categoryId,

View File

@ -9,6 +9,7 @@
use App\Http\Requests\Admin\Master\RawMaterialRequest;
use App\Http\Requests\Admin\ToggleStatusRequest;
use App\Models\RawMaterial;
use App\Services\Master\MasterInventoryService;
use App\Services\Master\RawMaterialService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -21,6 +22,7 @@ class RawMaterialController extends Controller
public function __construct(
private readonly RawMaterialService $rawMaterialService,
private readonly MasterInventoryService $masterInventoryService,
) {}
public function index(Request $request): Response
@ -31,6 +33,8 @@ public function index(Request $request): Response
return Inertia::render('admin/master/raw-materials/Index', [
'rawMaterials' => $this->rawMaterialService->paginateForIndex($tableQuery, $isActive, $stockStatus),
'outOfStockGroups' => $this->masterInventoryService->outOfStockRawMaterialGroups(),
'lowStockGroups' => $this->masterInventoryService->lowStockRawMaterialGroups(),
'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive,
'stock_status' => $stockStatus,

View File

@ -39,14 +39,14 @@ public function rules(): array
public function attributes(): array
{
return [
'email' => 'email',
'username' => 'username',
'full_name' => 'nama lengkap',
'phone_number' => 'nomor telepon',
'gender' => 'jenis kelamin',
'birth_date' => 'tanggal lahir',
'address' => 'alamat',
'profile_photo' => 'foto profil',
'email' => 'Email',
'username' => 'Username',
'full_name' => 'Nama Lengkap',
'phone_number' => 'Nomor Telepon',
'gender' => 'Jenis Kelamin',
'birth_date' => 'Tanggal Lahir',
'address' => 'Alamat',
'profile_photo' => 'Foto Profil',
];
}
}

View File

@ -57,18 +57,18 @@ public function rules(): array
public function attributes(): array
{
return [
'email' => 'email',
'username' => 'username',
'full_name' => 'nama lengkap',
'phone_number' => 'nomor telepon',
'gender' => 'jenis kelamin',
'birth_date' => 'tanggal lahir',
'address' => 'alamat',
'profile_photo' => 'foto profil',
'role' => 'role',
'join_date' => 'tanggal bergabung',
'employment_status' => 'status kepegawaian',
'base_salary' => 'gaji pokok',
'email' => 'Email',
'username' => 'Username',
'full_name' => 'Nama Lengkap',
'phone_number' => 'Nomor Telepon',
'gender' => 'Jenis Kelamin',
'birth_date' => 'Tanggal Lahir',
'address' => 'Alamat',
'profile_photo' => 'Foto Profil',
'role' => 'Role',
'join_date' => 'Tanggal Bergabung',
'employment_status' => 'Status Kepegawaian',
'base_salary' => 'Gaji Pokok',
];
}
}

View File

@ -62,17 +62,17 @@ public function rules(): array
public function attributes(): array
{
return [
'description' => 'keterangan',
'materials' => 'bahan baku',
'materials.*.raw_material_price_id' => 'bahan baku',
'materials.*.material_usage' => 'pemakaian',
'results' => 'hasil produk',
'results.*.product_variant_id' => 'varian produk',
'results.*.cutting_result' => 'hasil',
'results.*.sampel' => 'sampel',
'results.*.hasil_cutting_diluar_sampel' => 'hasil cutting diluar sampel',
'sewing_cost' => 'jasa jahit',
'other_cost' => 'biaya lainnya',
'description' => 'Keterangan',
'materials' => 'Bahan Baku',
'materials.*.raw_material_price_id' => 'Bahan Baku',
'materials.*.material_usage' => 'Pemakaian',
'results' => 'Hasil Produk',
'results.*.product_variant_id' => 'Varian Produk',
'results.*.cutting_result' => 'Hasil',
'results.*.sampel' => 'Sampel',
'results.*.hasil_cutting_diluar_sampel' => 'Diluar Sampel',
'sewing_cost' => 'Jasa Jahit',
'other_cost' => 'Biaya Lainnya',
];
}
}

View File

@ -68,23 +68,23 @@ public function rules(): array
public function attributes(): array
{
return [
'customer_id' => 'pelanggan',
'marketing_id' => 'marketing',
'channel' => 'channel',
'price_type' => 'tipe harga',
'payment_type' => 'tipe pembayaran',
'is_affiliate' => 'afiliasi',
'tiktok_order_id' => 'ID pesanan TikTok Shop',
'shopee_order_id' => 'ID pesanan Shopee',
'discount' => 'diskon',
'nego_price' => 'harga nego',
'notes' => 'keterangan',
'status' => 'status pesanan',
'items' => 'produk',
'items.*.product_variant_id' => 'varian produk',
'items.*.quantity' => 'jumlah',
'items.*.stock_quality' => 'kualitas stok',
...$this->photoUploadAttributes('bukti transaksi'),
'customer_id' => 'Pelanggan',
'marketing_id' => 'Marketing',
'channel' => 'Channel',
'price_type' => 'Tipe Harga',
'payment_type' => 'Tipe Pembayaran',
'is_affiliate' => 'Pesanan Afiliasi',
'tiktok_order_id' => 'ID Pesanan TikTok Shop',
'shopee_order_id' => 'ID Pesanan Shopee',
'discount' => 'Diskon',
'nego_price' => 'Harga Nego',
'notes' => 'Keterangan',
'status' => 'Status Pesanan',
'items' => 'Produk',
'items.*.product_variant_id' => 'Varian Produk',
'items.*.quantity' => 'Jumlah',
'items.*.stock_quality' => 'Kualitas Stok',
...$this->photoUploadAttributes('Bukti Transaksi'),
];
}

View File

@ -52,14 +52,14 @@ public function rules(): array
public function attributes(): array
{
return [
'supplier_id' => 'supplier',
'discount' => 'diskon',
'shipping_cost' => 'ongkir',
'notes' => 'keterangan',
'items' => 'bahan baku',
'items.*.raw_material_price_id' => 'bahan baku',
'items.*.quantity' => 'jumlah',
...$this->photoUploadAttributes('bukti transaksi'),
'supplier_id' => 'Supplier',
'discount' => 'Diskon',
'shipping_cost' => 'Ongkir',
'notes' => 'Keterangan',
'items' => 'Bahan Baku',
'items.*.raw_material_price_id' => 'Bahan Baku',
'items.*.quantity' => 'Jumlah',
...$this->photoUploadAttributes('Bukti Transaksi'),
];
}
}

View File

@ -40,4 +40,16 @@ public function messages(): array
'items.*.physical_stock.min' => 'Stok fisik tidak boleh negatif.',
];
}
public function attributes(): array
{
return [
'opname_date' => 'Tanggal Opname',
'notes' => 'Catatan',
'items' => 'Item Stok Opname',
'items.*.product_variant_id' => 'Varian Produk',
'items.*.physical_stock' => 'Stok Fisik',
'items.*.notes' => 'Catatan',
];
}
}

View File

@ -32,7 +32,7 @@ public function rules(): array
public function attributes(): array
{
return [
'name' => 'nama',
'name' => 'Nama',
];
}
}

View File

@ -35,9 +35,9 @@ public function rules(): array
public function attributes(): array
{
return [
'name' => 'nama',
'phone_number' => 'nomor telepon',
'address' => 'alamat',
'name' => 'Nama',
'phone_number' => 'Nomor Telepon',
'address' => 'Alamat',
];
}
}

View File

@ -53,15 +53,15 @@ public function rules(): array
public function attributes(): array
{
return [
'name' => 'nama produk',
'description' => 'deskripsi',
'category_ids' => 'kategori',
'category_ids.*' => 'kategori',
'variants' => 'varian',
'variants.*.name' => 'nama varian',
'variants.*.stock' => 'stok',
'variants.*.stock_retail' => 'stok ecer',
...$this->variantImageAttributes('variants', 'foto varian'),
'name' => 'Nama Produk',
'description' => 'Deskripsi',
'category_ids' => 'Kategori',
'category_ids.*' => 'Kategori',
'variants' => 'Varian',
'variants.*.name' => 'Nama Varian',
'variants.*.stock' => 'Stok',
'variants.*.stock_retail' => 'Stok Ecer',
...$this->variantImageAttributes('variants', 'Foto Varian'),
];
}
}

View File

@ -51,13 +51,13 @@ public function rules(): array
public function attributes(): array
{
return [
'name' => 'nama bahan baku',
'unit' => 'satuan',
'prices' => 'varian',
'prices.*.variant' => 'nama varian',
'prices.*.price' => 'harga',
'prices.*.stock' => 'stok',
...$this->variantImageAttributes('prices', 'foto varian'),
'name' => 'Nama Bahan Baku',
'unit' => 'Satuan',
'prices' => 'Varian',
'prices.*.variant' => 'Nama Varian',
'prices.*.price' => 'Harga',
'prices.*.stock' => 'Stok',
...$this->variantImageAttributes('prices', 'Foto Varian'),
];
}
}

View File

@ -35,9 +35,9 @@ public function rules(): array
public function attributes(): array
{
return [
'name' => 'nama',
'phone_number' => 'nomor telepon',
'address' => 'alamat',
'name' => 'Nama',
'phone_number' => 'Nomor Telepon',
'address' => 'Alamat',
];
}
}

View File

@ -5,9 +5,11 @@
use App\Enums\OwnerVerificationStatus;
use App\Models\Concerns\HasPendingOwnerVerification;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -23,6 +25,7 @@
* @method static \Illuminate\Database\Eloquent\Builder inactive()
*/
#[Guarded(['id'])]
#[Appends(['total_stock_formatted', 'total_reject_stock_formatted', 'total_retail_stock_formatted'])]
#[Sluggable(from: 'name', to: 'slug')]
class Product extends Model
{
@ -51,7 +54,29 @@ protected function inactive(Builder $query): void
$query->where('is_active', false);
}
// 4. Other Methods
// 4. Attribute
public function totalRejectStockFormatted(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->variants->sum('reject_stock'), 0, ',', '.'),
);
}
public function totalRetailStockFormatted(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->variants->sum('stock_retail'), 0, ',', '.'),
);
}
public function totalStockFormatted(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->variants->sum('stock'), 0, ',', '.'),
);
}
// 5. Other Methods
public static function getActiveWithVariantsAndCategories(): Collection
{
return self::query()
@ -65,7 +90,7 @@ public static function getActiveWithVariantsAndCategories(): Collection
->get();
}
// 5. Relation
// 6. Relation
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class, 'product_categories');

View File

@ -4,7 +4,9 @@
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -13,6 +15,7 @@
use Spatie\MediaLibrary\HasMedia;
#[Guarded(['id'])]
#[Appends(['stock_formatted', 'reject_stock_formatted', 'stock_retail_formatted'])]
class ProductVariant extends Model implements HasMedia
{
// 1. Use Trait
@ -30,7 +33,29 @@ protected function casts(): array
];
}
// 3. Other Methods
// 4. Attribute
public function rejectStockFormatted(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->reject_stock, 0, ',', '.'),
);
}
public function stockFormatted(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock, 0, ',', '.'),
);
}
public function stockRetailFormatted(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock_retail, 0, ',', '.'),
);
}
// 5. Other Methods
public static function mediaModuleName(): string
{
return 'product';
@ -46,7 +71,7 @@ public function registerMediaCollections(): void
$this->addMediaCollection('images');
}
// 4. Relation
// 6. Relation
public function cuttingResultPrices(): HasMany
{
return $this->hasMany(CuttingResultPrice::class);

View File

@ -19,7 +19,7 @@
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends(['unit_abbreviation', 'unit_label'])]
#[Appends(['unit_abbreviation', 'unit_label', 'total_stock_formatted', 'total_inventory_value_formatted'])]
class RawMaterial extends Model
{
// 1. Use Trait
@ -63,6 +63,30 @@ public function unitLabel(): Attribute
);
}
public function totalInventoryValueFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format(
$this->prices->sum(fn (RawMaterialPrice $price) => (float) $price->stock * (int) $price->price),
0,
',',
'.',
),
);
}
public function totalStockFormatted(): Attribute
{
return Attribute::make(
get: function () {
$total = $this->prices->sum(fn (RawMaterialPrice $price) => (float) $price->stock);
$formatted = rtrim(rtrim(number_format($total, 4, ',', '.'), '0'), ',');
return "{$formatted} {$this->unit->abbreviation()}";
},
);
}
// 5. Relation
public function ownerVerificationRequests(): MorphMany
{

View File

@ -890,10 +890,30 @@ private function formatQuantityInput(float $value): string
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
private function formatMaterialUsageSummary(Cutting $cutting): string
{
$groups = [];
foreach ($cutting->materials as $material) {
$unit = $material->rawMaterialPrice?->rawMaterial?->unit?->abbreviation() ?? '';
$groups[$unit] = ($groups[$unit] ?? 0) + (float) $material->material_usage;
}
return collect($groups)
->filter(fn (float $total) => $total > 0)
->map(function (float $total, string $unit) {
$formatted = rtrim(rtrim(number_format($total, 2, ',', '.'), '0'), ',');
return "{$formatted} {$unit}";
})
->join(', ');
}
private function appendCostPreview(Cutting $cutting): void
{
$cutting->setAttribute('total_result_pieces', (int) $cutting->results->sum('cutting_result'));
$cutting->setAttribute('total_material_usage', (float) $cutting->materials->sum(fn (CuttingMaterial $material) => (float) $material->material_usage));
$cutting->setAttribute('total_material_usage_summary_formatted', $this->formatMaterialUsageSummary($cutting));
$totalMaterialCost = $cutting->total_material_cost ?? $this->calculateTotalMaterialCost($cutting);
$sewingCost = (int) ($cutting->sewing_cost ?? 0);

View File

@ -96,6 +96,10 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
$order->setAttribute('available_actions', $actions);
$order->setAttribute('is_editable', $order->status->isEditable());
$order->setAttribute(
'marketplace_settings_snapshot',
$this->enrichMarketplaceSnapshot($order->marketplace_settings_snapshot),
);
return $order;
});
@ -271,6 +275,10 @@ public function findForShow(Order $order): Order
$order->setAttribute('available_actions', $availableActions);
$order->setAttribute('is_editable', $order->status->isEditable());
$order->setAttribute(
'marketplace_settings_snapshot',
$this->enrichMarketplaceSnapshot($order->marketplace_settings_snapshot),
);
return $order;
}
@ -895,4 +903,17 @@ private function presentVariantPricesFromCollection(int $variantId, Collection $
->values()
->all();
}
private function enrichMarketplaceSnapshot(?array $snapshot): ?array
{
if ($snapshot === null) {
return null;
}
$snapshot['base_amount_formatted'] ??= 'Rp '.number_format($snapshot['base_amount'] ?? 0, 0, ',', '.');
$snapshot['total_fee_amount_formatted'] ??= 'Rp '.number_format($snapshot['total_fee_amount'] ?? 0, 0, ',', '.');
$snapshot['net_amount_formatted'] ??= 'Rp '.number_format($snapshot['net_amount'] ?? 0, 0, ',', '.');
return $snapshot;
}
}

View File

@ -63,6 +63,7 @@ public function catalogItems(): array
'id' => $variant->id,
'name' => $variant->name,
'stock' => $variant->stock,
'stock_formatted' => $variant->stock_formatted,
]),
])
->toArray();

View File

@ -0,0 +1,108 @@
<?php
namespace App\Services\Master;
use App\Enums\RawMaterialUnit;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Support\Media\MediaPresenter;
use Illuminate\Database\Eloquent\Builder;
class MasterInventoryService
{
public function outOfStockProductGroups(int $limit = 50): array
{
return Product::query()
->where('is_active', true)
->whereHas('variants', fn (Builder $query) => $query->where('stock', '<=', 0))
->with([
'variants' => fn ($query) => $query
->where('stock', '<=', 0)
->with('media')
->orderBy('name'),
])
->orderBy('name')
->limit($limit)
->get()
->map(fn (Product $product) => [
'id' => $product->id,
'name' => $product->name,
'edit_url' => route('admin.master.products.edit', $product),
'variants' => $product->variants->map(fn (ProductVariant $variant) => [
'id' => $variant->id,
'name' => $variant->name,
'stock_formatted' => $variant->stock_formatted,
'images' => MediaPresenter::collection($variant, 'images'),
])->values()->all(),
])
->filter(fn (array $group) => $group['variants'] !== [])
->values()
->all();
}
public function outOfStockRawMaterialGroups(int $limit = 50): array
{
return $this->rawMaterialGroupsWithStockFilter(
fn (Builder $query) => $query->where('stock', '<=', 0),
$limit,
);
}
public function lowStockRawMaterialGroups(int $limit = 50): array
{
$minStock = [
RawMaterialUnit::YARD->value => 20,
RawMaterialUnit::METER->value => 10,
RawMaterialUnit::KILOGRAM->value => 5,
];
return $this->rawMaterialGroupsWithStockFilter(
function (Builder $query) use ($minStock): void {
$query->where('stock', '>', 0)->where(function (Builder $query) use ($minStock): void {
foreach (RawMaterialUnit::cases() as $unit) {
$query->orWhere(function (Builder $query) use ($unit, $minStock): void {
$query->whereHas(
'rawMaterial',
fn (Builder $rawMaterialQuery) => $rawMaterialQuery->where('unit', $unit),
)->where('stock', '<', $minStock[$unit->value]);
});
}
});
},
$limit,
);
}
private function rawMaterialGroupsWithStockFilter(callable $priceConstraint, int $limit): array
{
return RawMaterial::query()
->where('is_active', true)
->whereHas('prices', $priceConstraint)
->with([
'prices' => function ($query) use ($priceConstraint): void {
$priceConstraint($query);
$query->with('media')->orderBy('variant');
},
])
->orderBy('name')
->limit($limit)
->get()
->map(fn (RawMaterial $rawMaterial) => [
'id' => $rawMaterial->id,
'name' => $rawMaterial->name,
'edit_url' => route('admin.master.raw_materials.edit', $rawMaterial),
'unit_label' => $rawMaterial->unit_label,
'variants' => $rawMaterial->prices->map(fn (RawMaterialPrice $price) => [
'id' => $price->id,
'name' => $price->variant,
'stock_formatted' => $price->stock_formatted,
'images' => MediaPresenter::collection($price, 'images'),
])->values()->all(),
])
->filter(fn (array $group) => $group['variants'] !== [])
->values()
->all();
}
}

View File

@ -177,9 +177,12 @@ public function buildOrderSnapshot(OrderChannel $channel, int $totalAmount, arra
'is_affiliate' => $isAffiliate,
'fees' => $feeSnapshot['fees'],
'base_amount' => $totalAmount,
'base_amount_formatted' => 'Rp '.number_format($totalAmount, 0, ',', '.'),
'results' => $calculation['results'],
'total_fee_amount' => $calculation['total_fee_amount'],
'total_fee_amount_formatted' => 'Rp '.number_format($calculation['total_fee_amount'], 0, ',', '.'),
'net_amount' => $calculation['net_amount'],
'net_amount_formatted' => 'Rp '.number_format($calculation['net_amount'], 0, ',', '.'),
];
}

View File

@ -4,6 +4,7 @@ import type { ColumnDef } from '@tanstack/vue-table';
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table';
import { computed, provide } from 'vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import { buildPaginationSummary } from '@/lib/grouped-table';
import { Button } from '@/components/ui/button';
import {
Table,
@ -109,20 +110,13 @@ provide('data-table-sort', {
const showingCount = computed(() => props.paginationDisplayedCount ?? props.data.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
return null;
}
const { total } = props.pagination;
const label = props.paginationItemLabel;
if (total === 0) {
return `Menampilkan 0 ${label}`;
}
return `Menampilkan ${showingCount.value} ${label} dari ${total}`;
});
const paginationSummary = computed(() =>
buildPaginationSummary(
props.pagination,
showingCount.value,
props.paginationItemLabel,
),
);
function resolveRowSpan(row: TData, columnId: string): number | undefined {
const column = resolvedColumns.value.find((item) => {

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { Button } from '@/components/ui/button';
import type { DataTablePagination, DataTablePaginationLink } from '@/types/data-table';
defineProps<{
summary: string | null;
pagination?: DataTablePagination;
paginationLinks?: DataTablePaginationLink[];
}>();
</script>
<template>
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p v-if="summary" class="text-muted-foreground text-sm">
{{ summary }}
</p>
<div
v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end"
>
<Button
v-for="link in paginationLinks"
:key="`${link.label}-${link.url}`"
variant="outline"
size="sm"
:disabled="!link.url || link.active"
as-child
>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>
<span v-else v-html="link.label" />
</Button>
</div>
</div>
</template>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed } from 'vue';
import { formatRupiah } from '@/lib/rupiah';
const props = defineProps<{
amount: number | string;
formatted?: string | null;
}>();
const display = computed(() => {
if (props.formatted) {
return props.formatted;
}
const numericAmount = typeof props.amount === 'string'
? Number.parseInt(props.amount, 10)
: props.amount;
return `Rp ${formatRupiah(Number.isFinite(numericAmount) ? numericAmount : 0)}`;
});
</script>
<template>
<span>{{ display }}</span>
</template>

View File

@ -2,8 +2,8 @@
import { router } from '@inertiajs/vue3';
import { ChevronDown, PackageX, TriangleAlert } from '@lucide/vue';
import { computed, ref } from 'vue';
import PosCatalogCard from '@/pages/admin/manage/shared/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/pages/admin/manage/shared/PosCatalogVariantThumb.vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import {

View File

@ -0,0 +1,13 @@
import { computed, type MaybeRefOrGetter, toValue } from 'vue';
import { buildPaginationSummary } from '@/lib/grouped-table';
import type { DataTablePagination } from '@/types/data-table';
export function usePaginationSummary(
pagination: MaybeRefOrGetter<DataTablePagination | undefined>,
showingCount: MaybeRefOrGetter<number>,
itemLabel: string,
) {
return computed(() =>
buildPaginationSummary(toValue(pagination), toValue(showingCount), itemLabel),
);
}

View File

@ -1,3 +1,7 @@
import type { Component } from 'vue';
import { Check, RotateCcw, Scissors, X } from '@lucide/vue';
import type { BadgeVariant } from '@/lib/badge-variant';
export const CuttingStatus = {
IN_PROGRESS: 'in_progress',
COMPLETED: 'completed',
@ -5,3 +9,53 @@ export const CuttingStatus = {
VERIFIED: 'verified',
REJECTED: 'rejected',
} as const;
export type CuttingStatusValue = (typeof CuttingStatus)[keyof typeof CuttingStatus];
export function cuttingStatusBadgeVariant(status: string): BadgeVariant {
if (status === CuttingStatus.VERIFIED) {
return 'default';
}
if (status === CuttingStatus.REJECTED) {
return 'destructive';
}
if (status === CuttingStatus.COMPLETED) {
return 'secondary';
}
return 'outline';
}
export function cuttingStatusTransitionConfirmDescription(targetStatus: string): string {
if (targetStatus === CuttingStatus.COMPLETED) {
return 'Cutting akan ditandai selesai. Stok bahan baku akan dipotong sesuai pemakaian. Menunggu verifikasi admin toko.';
}
if (targetStatus === CuttingStatus.VERIFIED) {
return 'Hasil cutting akan diverifikasi. Stok sampel dan hasil cutting diluar sampel akan ditambahkan ke produk.';
}
if (targetStatus === CuttingStatus.IN_PROGRESS) {
return 'Cutting dikembalikan ke proses untuk diperbaiki.';
}
return '';
}
export function cuttingStatusActionIcon(status: string): Component {
if (status === CuttingStatus.COMPLETED) {
return Scissors;
}
if (status === CuttingStatus.VERIFIED) {
return Check;
}
if (status === CuttingStatus.IN_PROGRESS) {
return RotateCcw;
}
return X;
}

View File

@ -1,3 +1,5 @@
import type { BadgeVariant } from '@/lib/badge-variant';
export const EmployeeAdvanceStatus = {
PENDING: 'pending',
APPROVED: 'approved',
@ -5,3 +7,18 @@ export const EmployeeAdvanceStatus = {
PARTIALLY_PAID: 'partially_paid',
PAID: 'paid',
} as const;
export function employeeAdvanceStatusBadgeVariant(status: string): BadgeVariant {
switch (status) {
case EmployeeAdvanceStatus.APPROVED:
return 'default';
case EmployeeAdvanceStatus.PARTIALLY_PAID:
return 'outline';
case EmployeeAdvanceStatus.PAID:
return 'secondary';
case EmployeeAdvanceStatus.REJECTED:
return 'destructive';
default:
return 'outline';
}
}

View File

@ -16,4 +16,4 @@ export * from './marketplace-fee-value-type';
export * from './setting-section';
export * from './appearance-mode';
export * from './catalog-sort-option';
export * from './paper-size';
export * from './stok-opname-status';

View File

@ -1,5 +1,18 @@
import type { BadgeVariant } from '@/lib/badge-variant';
export const LeaveRequestStatus = {
PENDING: 'pending',
APPROVED: 'approved',
REJECTED: 'rejected',
} as const;
export function leaveRequestStatusBadgeVariant(status: string): BadgeVariant {
switch (status) {
case LeaveRequestStatus.APPROVED:
return 'default';
case LeaveRequestStatus.REJECTED:
return 'destructive';
default:
return 'outline';
}
}

View File

@ -1,4 +1,8 @@
export const OrderPaymentType = {
CASH: 'cash',
TRANSFER: 'transfer',
QRIS: 'qris',
MARKETPLACE: 'marketplace',
} as const;
export type OrderPaymentTypeValue = (typeof OrderPaymentType)[keyof typeof OrderPaymentType];

View File

@ -1,6 +1,55 @@
import type { Component } from 'vue';
import { Check, Send, X } from '@lucide/vue';
import type { BadgeVariant } from '@/lib/badge-variant';
export const OrderStatus = {
PENDING: 'pending',
PROCESSING: 'processing',
COMPLETED: 'completed',
CANCELLED: 'cancelled',
} as const;
export type OrderStatusValue = (typeof OrderStatus)[keyof typeof OrderStatus];
export function orderStatusBadgeVariant(status: string): BadgeVariant {
if (status === OrderStatus.COMPLETED) {
return 'default';
}
if (status === OrderStatus.CANCELLED) {
return 'destructive';
}
if (status === OrderStatus.PROCESSING) {
return 'secondary';
}
return 'outline';
}
export function orderStatusTransitionConfirmDescription(
targetStatus: string,
orderNumber: string,
): string {
if (targetStatus === OrderStatus.PROCESSING) {
return `Pesanan ${orderNumber} akan dikirim dan diproses.`;
}
if (targetStatus === OrderStatus.COMPLETED) {
return `Pesanan ${orderNumber} akan ditandai selesai.`;
}
return `Pesanan ${orderNumber} akan dibatalkan. Stok produk akan dikembalikan.`;
}
export function orderStatusActionIcon(status: string): Component {
if (status === OrderStatus.PROCESSING) {
return Send;
}
if (status === OrderStatus.COMPLETED) {
return Check;
}
return X;
}

View File

@ -1,4 +1,10 @@
import type { BadgeVariant } from '@/lib/badge-variant';
export const PayrollStatus = {
UNPAID: 'unpaid',
PAID: 'paid',
} as const;
export function payrollStatusBadgeVariant(status: string): BadgeVariant {
return status === PayrollStatus.PAID ? 'secondary' : 'outline';
}

View File

@ -0,0 +1,21 @@
import type { BadgeVariant } from '@/lib/badge-variant';
export const StokOpnameStatus = {
DRAFT: 'draft',
PENDING: 'pending',
VERIFIED: 'verified',
REJECTED: 'rejected',
} as const;
export type StokOpnameStatusValue = (typeof StokOpnameStatus)[keyof typeof StokOpnameStatus];
const badgeVariants: Record<StokOpnameStatusValue, BadgeVariant> = {
draft: 'secondary',
pending: 'outline',
verified: 'default',
rejected: 'destructive',
};
export function stokOpnameStatusBadgeVariant(status: StokOpnameStatusValue): BadgeVariant {
return badgeVariants[status];
}

View File

@ -0,0 +1 @@
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';

View File

@ -0,0 +1,23 @@
import type { DataTablePagination } from '@/types/data-table';
export function groupedTableRowNumber(firstItem: number | undefined, index: number): number {
return (firstItem ?? 1) + index;
}
export function buildPaginationSummary(
pagination: DataTablePagination | undefined,
showingCount: number,
itemLabel: string,
): string | null {
if (!pagination) {
return null;
}
const { total } = pagination;
if (total === 0) {
return `Menampilkan 0 ${itemLabel}`;
}
return `Menampilkan ${showingCount} ${itemLabel} dari ${total}`;
}

View File

@ -0,0 +1,19 @@
export function stokOpnameDifference(physicalStock: number, systemStock: number): number {
return physicalStock - systemStock;
}
export function stokOpnameDifferenceClass(difference: number): string {
if (difference > 0) {
return 'text-green-600 font-semibold';
}
if (difference < 0) {
return 'text-red-600 font-semibold';
}
return 'text-muted-foreground';
}
export function stokOpnameDifferenceText(difference: number): string {
return `${difference > 0 ? '+' : ''}${difference}`;
}

View File

@ -2,25 +2,10 @@ import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import { DataTableColumnHeader } from '@/components/data-table';
import { Badge } from '@/components/ui/badge';
import { EmployeeAdvanceStatus } from '@/constants/employee-advance-status';
import { employeeAdvanceStatusBadgeVariant } from '@/constants/employee-advance-status';
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
import DataTableActions from './data-table-actions.vue';
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case EmployeeAdvanceStatus.APPROVED:
return 'default';
case EmployeeAdvanceStatus.PARTIALLY_PAID:
return 'outline';
case EmployeeAdvanceStatus.PAID:
return 'secondary';
case EmployeeAdvanceStatus.REJECTED:
return 'destructive';
default:
return 'outline';
}
}
export function createColumns(
authEmployeeId: number | null,
onEdit: (employeeAdvance: EmployeeAdvanceListItem) => void,
@ -78,7 +63,7 @@ export function createColumns(
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
cell: ({ row }) => h(
Badge,
{ variant: statusVariant(row.original.status) },
{ variant: employeeAdvanceStatusBadgeVariant(row.original.status) },
() => row.original.status_label,
),
},

View File

@ -2,14 +2,10 @@ import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import { DataTableColumnHeader } from '@/components/data-table';
import { Badge } from '@/components/ui/badge';
import { PayrollStatus } from '@/constants/payroll-status';
import { payrollStatusBadgeVariant } from '@/constants/payroll-status';
import type { PayrollListItem } from '@/types/payroll';
import DataTableActions from './data-table-actions.vue';
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
return status === PayrollStatus.PAID ? 'secondary' : 'outline';
}
export function createColumns(
onAdjust: (payroll: PayrollListItem) => void,
): ColumnDef<PayrollListItem>[] {
@ -45,7 +41,7 @@ export function createColumns(
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
cell: ({ row }) => h(
Badge,
{ variant: statusVariant(row.original.status) },
{ variant: payrollStatusBadgeVariant(row.original.status) },
() => row.original.status_label,
),
},

View File

@ -2,21 +2,10 @@ import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import { DataTableColumnHeader } from '@/components/data-table';
import { Badge } from '@/components/ui/badge';
import { LeaveRequestStatus } from '@/constants/leave-request-status';
import { leaveRequestStatusBadgeVariant } from '@/constants/leave-request-status';
import type { LeaveRequestListItem } from '@/types/leave-request';
import DataTableActions from './data-table-actions.vue';
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case LeaveRequestStatus.APPROVED:
return 'default';
case LeaveRequestStatus.REJECTED:
return 'destructive';
default:
return 'outline';
}
}
export function createColumns(
authEmployeeId: number | null,
onEdit: (leaveRequest: LeaveRequestListItem) => void,
@ -55,7 +44,7 @@ export function createColumns(
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
cell: ({ row }) => h(
Badge,
{ variant: statusVariant(row.original.status) },
{ variant: leaveRequestStatusBadgeVariant(row.original.status) },
() => row.original.status_label,
),
},

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { formatRupiah } from '@/lib/rupiah';
import type { CuttingMaterialCartItem, CuttingResultCartItem } from '@/types/cutting';
defineProps<{
materialCart: CuttingMaterialCartItem[];
resultCart: CuttingResultCartItem[];
materialLineCost: (item: CuttingMaterialCartItem) => number;
totalMaterialCost: number;
}>();
const open = defineModel<boolean>('open', { required: true });
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Detail Keranjang</DialogTitle>
</DialogHeader>
<div class="scrollbar-thin max-h-96 space-y-4 overflow-y-auto overscroll-y-contain">
<div v-if="materialCart.length > 0">
<p class="mb-2 text-xs font-medium text-muted-foreground">Bahan Baku</p>
<div class="space-y-2">
<div
v-for="item in materialCart"
:key="`detail-mat-${item.raw_material_price_id}`"
class="rounded-lg border p-3"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ item.raw_material_name }}</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }}
</p>
</div>
<span class="shrink-0 text-xs font-medium tabular-nums">
{{ item.material_usage }} {{ item.unit_abbreviation }}
</span>
</div>
<div class="mt-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span class="font-medium text-foreground">{{ formatRupiah(materialLineCost(item)) }}</span>
</div>
</div>
</div>
</div>
<div v-if="resultCart.length > 0">
<p class="mb-2 text-xs font-medium text-muted-foreground">Hasil Produk</p>
<div class="space-y-2">
<div
v-for="item in resultCart"
:key="`detail-res-${item.product_variant_id}`"
class="rounded-lg border p-3"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ item.product_name }}</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }} · Stok {{ item.stock }} pcs
</p>
</div>
</div>
<div class="mt-1.5 flex items-center gap-3 text-xs tabular-nums">
<span>Hasil: <strong>{{ item.cutting_result }}</strong></span>
<span class="text-muted-foreground">·</span>
<span>Sampel: <strong>{{ item.sampel }}</strong></span>
<span class="text-muted-foreground">·</span>
<span>Diluar Sampel: <strong>{{ item.hasil_cutting_diluar_sampel }}</strong></span>
</div>
</div>
</div>
</div>
</div>
<div class="border-t pt-3">
<div class="flex items-center justify-between text-sm font-semibold">
<span>Total Biaya Bahan</span>
<span class="text-primary">{{ formatRupiah(totalMaterialCost) }}</span>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,129 @@
<script setup lang="ts">
import { Minus, Plus, Search } from '@lucide/vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import { Input } from '@/components/ui/input';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
import type { CuttingCatalogPrice } from './useCuttingPosCart';
defineProps<{
filteredRawMaterials: CuttingRawMaterialCatalogItem[];
getMaterialCartItem: (priceId: number) => CuttingMaterialCartItem | undefined;
}>();
const materialSearch = defineModel<string>('materialSearch', { required: true });
const emit = defineEmits<{
'add-material': [rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice];
'decrease-material-qty': [priceId: number];
}>();
</script>
<template>
<Card class="min-w-0">
<CardHeader class="pb-3">
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="materialSearch" placeholder="Cari bahan baku..." class="pl-9" />
</div>
<div v-if="filteredRawMaterials.length === 0" class="py-8">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada bahan baku ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan bahan baku.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="columns-1 gap-4 sm:columns-2">
<PosCatalogCard
v-for="rawMaterial in filteredRawMaterials"
:key="rawMaterial.id"
:title="rawMaterial.name"
:cover-image="getFirstCoverImage(rawMaterial.prices)"
>
<template #header-extra>
<Badge variant="secondary" class="mt-1.5">
{{ rawMaterial.unit_label }}
</Badge>
</template>
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div
v-for="price in rawMaterial.prices"
:key="price.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[
'cursor-pointer hover:bg-muted/30',
getMaterialCartItem(price.id)
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
: '',
]"
@click="!getMaterialCartItem(price.id) && emit('add-material', rawMaterial, price)"
>
<PosCatalogVariantThumb :items="price.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ price.variant }}
</p>
<p class="text-xs tabular-nums text-muted-foreground">
Stok: {{ price.stock_formatted }}
</p>
</div>
<div v-if="getMaterialCartItem(price.id)" class="flex shrink-0 items-center gap-1.5">
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('decrease-material-qty', price.id)"
>
<Minus class="size-3.5" />
</Button>
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
{{ getMaterialCartItem(price.id)!.material_usage }}
</span>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('add-material', rawMaterial, price)"
>
<Plus class="size-3.5" />
</Button>
</div>
<Button
v-else
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
@click.stop="emit('add-material', rawMaterial, price)"
>
<Plus class="size-3.5" />
</Button>
</div>
</PosCatalogCard>
</div>
</div>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,71 @@
<script setup lang="ts">
import { Trash2 } from '@lucide/vue';
import { DecimalInput } from '@/components/form/decimal-input';
import { Button } from '@/components/ui/button';
import {
Field,
FieldLabel,
} from '@/components/ui/field';
import type { CuttingMaterialCartItem } from '@/types/cutting';
defineProps<{
materialCart: CuttingMaterialCartItem[];
}>();
const emit = defineEmits<{
remove: [index: number];
'sync-field': [index: number];
}>();
</script>
<template>
<div class="space-y-2">
<p class="text-sm font-medium">Bahan Baku</p>
<div
v-if="materialCart.length === 0"
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"
>
Belum ada bahan baku dipilih.
</div>
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<div
v-for="(item, index) in materialCart"
:key="item.raw_material_price_id"
class="rounded-lg border p-3"
>
<div class="mb-2 flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ item.raw_material_name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive"
@click="emit('remove', index)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
<Field>
<FieldLabel class="text-xs">
Pemakaian ({{ item.unit_abbreviation }})
</FieldLabel>
<DecimalInput
v-model="item.material_usage"
class="h-8"
@change="emit('sync-field', index)"
/>
</Field>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
import { Minus, Plus, Search } from '@lucide/vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import { Input } from '@/components/ui/input';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import type { CuttingProductCatalogItem, CuttingResultCartItem } from '@/types/cutting';
import type { CuttingCatalogVariant } from './useCuttingPosCart';
defineProps<{
filteredProducts: CuttingProductCatalogItem[];
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
}>();
const productSearch = defineModel<string>('productSearch', { required: true });
const emit = defineEmits<{
'add-result': [product: CuttingProductCatalogItem, variant: CuttingCatalogVariant];
'decrease-result-qty': [variantId: number];
}>();
</script>
<template>
<Card class="min-w-0">
<CardHeader class="pb-3">
<CardTitle class="text-base">Pilih Produk Hasil</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="productSearch" placeholder="Cari produk..." class="pl-9" />
</div>
<div v-if="filteredProducts.length === 0" class="py-8">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada produk ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan produk.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="columns-1 gap-4 sm:columns-2">
<PosCatalogCard
v-for="product in filteredProducts"
:key="product.id"
:title="product.name"
:cover-image="getFirstCoverImage(product.variants)"
>
<p v-if="!product.variants.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div
v-for="variant in product.variants"
:key="variant.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[
'cursor-pointer hover:bg-muted/30',
getResultCartItem(variant.id)
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
: '',
]"
@click="!getResultCartItem(variant.id) && emit('add-result', product, variant)"
>
<PosCatalogVariantThumb :items="variant.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ variant.name }}
</p>
<p class="text-xs text-muted-foreground">
<span class="tabular-nums">Stok: {{ variant.stock }} pcs</span>
</p>
</div>
<div v-if="getResultCartItem(variant.id)" class="flex shrink-0 items-center gap-1.5">
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('decrease-result-qty', variant.id)"
>
<Minus class="size-3.5" />
</Button>
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
{{ getResultCartItem(variant.id)!.cutting_result }}
</span>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('add-result', product, variant)"
>
<Plus class="size-3.5" />
</Button>
</div>
<Button
v-else
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
@click.stop="emit('add-result', product, variant)"
>
<Plus class="size-3.5" />
</Button>
</div>
</PosCatalogCard>
</div>
</div>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import { Trash2 } from '@lucide/vue';
import { NumberInput } from '@/components/form/number-input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Field,
FieldLabel,
} from '@/components/ui/field';
import type { CuttingResultCartItem } from '@/types/cutting';
defineProps<{
resultCart: CuttingResultCartItem[];
totalResultPieces: number;
}>();
const emit = defineEmits<{
remove: [index: number];
'sync-totals': [item: CuttingResultCartItem];
'sync-field': [index: number];
}>();
</script>
<template>
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium">Hasil Produk</p>
<Badge v-if="totalResultPieces > 0" variant="outline" class="tabular-nums text-xs">
{{ totalResultPieces }} pcs
</Badge>
</div>
<div
v-if="resultCart.length === 0"
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"
>
Belum ada produk hasil dipilih.
</div>
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<div
v-for="(item, index) in resultCart"
:key="item.product_variant_id"
class="rounded-lg border p-3"
>
<div class="mb-2 flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ item.product_name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }} · Stok {{ item.stock }} pcs
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive"
@click="emit('remove', index)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
<div class="grid grid-cols-3 gap-2">
<Field>
<FieldLabel class="text-xs">Hasil</FieldLabel>
<NumberInput
v-model="item.cutting_result"
class="h-8"
@change="emit('sync-totals', item); emit('sync-field', index)"
/>
</Field>
<Field>
<FieldLabel class="text-xs">Sampel</FieldLabel>
<NumberInput
v-model="item.sampel"
class="h-8"
@change="emit('sync-totals', item); emit('sync-field', index)"
/>
</Field>
<Field>
<FieldLabel class="text-xs">Diluar Sampel</FieldLabel>
<NumberInput v-model="item.hasil_cutting_diluar_sampel" class="h-8" />
</Field>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import { Save, Scissors } from '@lucide/vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors, type FormWithErrors } from '@/lib/form';
import type { CuttingMaterialCartItem, CuttingResultCartItem } from '@/types/cutting';
import CuttingPosMaterialSummaryItems from './CuttingPosMaterialSummaryItems.vue';
import CuttingPosResultSummaryItems from './CuttingPosResultSummaryItems.vue';
defineProps<{
form: FormWithErrors & { description: string; processing?: boolean };
materialCart: CuttingMaterialCartItem[];
resultCart: CuttingResultCartItem[];
totalResultPieces: number;
submitLabel: string;
}>();
const emit = defineEmits<{
submit: [];
'open-detail': [];
'remove-material': [index: number];
'sync-material-field': [index: number];
'remove-result': [index: number];
'sync-result-totals': [item: CuttingResultCartItem];
'sync-result-field': [index: number];
}>();
</script>
<template>
<Card class="h-fit xl:sticky xl:top-4">
<CardHeader class="pb-3">
<CardTitle class="flex items-center justify-between gap-2 text-base">
<span class="flex items-center gap-2">
<Scissors class="size-4" />
Ringkasan Cutting
</span>
<span class="flex items-center gap-2">
<button
v-if="materialCart.length > 0 || resultCart.length > 0"
type="button"
class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80"
@click="emit('open-detail')"
>
Lihat Detail
</button>
<Badge v-if="totalResultPieces > 0" variant="secondary" class="tabular-nums font-semibold">
Total: {{ totalResultPieces }} pcs
</Badge>
</span>
</CardTitle>
</CardHeader>
<CardContent>
<form @submit.prevent="emit('submit')">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="description">Keterangan</FieldLabel>
<Textarea
id="description"
v-model="form.description"
placeholder="Contoh: Cutting batch pagi"
rows="2"
:maxlength="FIELD_LIMITS.description"
/>
<FieldError :errors="formErrors(form, 'description')" />
</Field>
<CuttingPosMaterialSummaryItems
:material-cart="materialCart"
@remove="emit('remove-material', $event)"
@sync-field="emit('sync-material-field', $event)"
/>
<Separator />
<CuttingPosResultSummaryItems
:result-cart="resultCart"
:total-result-pieces="totalResultPieces"
@remove="emit('remove-result', $event)"
@sync-totals="emit('sync-result-totals', $event)"
@sync-field="emit('sync-result-field', $event)"
/>
<Button
type="submit"
class="w-full"
:disabled="form.processing || materialCart.length === 0 || resultCart.length === 0"
>
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</FieldSet>
</FieldGroup>
</form>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,480 @@
import { computed, ref, type MaybeRefOrGetter, toValue } from 'vue';
import { toast } from 'vue-sonner';
import { apiFetch } from '@/lib/api';
import draft_materials from '@/routes/admin/manage/cuttings/draft_materials';
import draft_results from '@/routes/admin/manage/cuttings/draft_results';
import type {
CuttingMaterialCartItem,
CuttingProductCatalogItem,
CuttingRawMaterialCatalogItem,
CuttingResultCartItem,
} from '@/types/cutting';
export type CuttingCatalogPrice = CuttingRawMaterialCatalogItem['prices'][number];
export type CuttingCatalogVariant = CuttingProductCatalogItem['variants'][number];
export function useCuttingPosCart(options: {
rawMaterialCatalog: MaybeRefOrGetter<CuttingRawMaterialCatalogItem[]>;
productCatalog: MaybeRefOrGetter<CuttingProductCatalogItem[]>;
isCreateMode: MaybeRefOrGetter<boolean>;
}) {
const materialSearch = ref('');
const productSearch = ref('');
const materialCart = ref<CuttingMaterialCartItem[]>([]);
const resultCart = ref<CuttingResultCartItem[]>([]);
function findCatalogPrice(priceId: number) {
for (const rawMaterial of toValue(options.rawMaterialCatalog)) {
const price = rawMaterial.prices.find((item) => item.id === priceId);
if (price) {
return {
price: price.price,
unit: rawMaterial.unit,
};
}
}
return null;
}
function setCarts(materials: CuttingMaterialCartItem[], results: CuttingResultCartItem[]) {
materialCart.value = materials.map((item) => ({ ...item }));
resultCart.value = results.map((item) => ({ ...item }));
}
function loadDraftItems(materials: CuttingMaterialCartItem[], results: CuttingResultCartItem[]) {
if (!toValue(options.isCreateMode)) {
return;
}
if (materials.length > 0) {
materialCart.value = materials.map((item) => ({ ...item }));
}
if (results.length > 0) {
resultCart.value = results.map((item) => ({ ...item }));
}
}
const filteredRawMaterials = computed(() => {
const keyword = materialSearch.value.trim().toLowerCase();
const catalog = toValue(options.rawMaterialCatalog);
if (!keyword) {
return catalog;
}
return catalog.filter(
(rawMaterial) =>
rawMaterial.name.toLowerCase().includes(keyword)
|| rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)),
);
});
const filteredProducts = computed(() => {
const keyword = productSearch.value.trim().toLowerCase();
const catalog = toValue(options.productCatalog);
if (!keyword) {
return catalog;
}
return catalog.filter(
(product) =>
product.name.toLowerCase().includes(keyword)
|| product.variants.some((variant) => variant.name.toLowerCase().includes(keyword)),
);
});
const totalMaterialCost = computed(() =>
materialCart.value.reduce((sum, item) => sum + materialLineCost(item), 0),
);
const totalResultPieces = computed(() =>
resultCart.value.reduce(
(sum, item) => sum + (Number(item.cutting_result) || 0),
0,
),
);
const estimatedCostPerUnit = computed(() => {
if (totalResultPieces.value <= 0) {
return 0;
}
return Math.round(totalMaterialCost.value / totalResultPieces.value);
});
function materialLineCost(item: CuttingMaterialCartItem): number {
const catalogPrice = findCatalogPrice(item.raw_material_price_id);
if (!catalogPrice) {
return 0;
}
const usage = Number(item.material_usage) || 0;
return Math.round(usage * catalogPrice.price);
}
function upsertMaterialCartItem(item: CuttingMaterialCartItem) {
const index = materialCart.value.findIndex(
(cartItem) => cartItem.raw_material_price_id === item.raw_material_price_id,
);
if (index === -1) {
materialCart.value.push({ ...item });
return;
}
materialCart.value[index] = { ...item };
}
function upsertResultCartItem(item: CuttingResultCartItem) {
const index = resultCart.value.findIndex(
(cartItem) => cartItem.product_variant_id === item.product_variant_id,
);
if (index === -1) {
resultCart.value.push({ ...item });
return;
}
resultCart.value[index] = { ...item };
}
async function syncDraftMaterial(
rawMaterial: CuttingRawMaterialCatalogItem,
price: CuttingCatalogPrice,
materialUsage: string,
) {
const { item } = await apiFetch<{ item: CuttingMaterialCartItem }>(draft_materials.store.url(), {
method: 'POST',
body: JSON.stringify({
raw_material_price_id: price.id,
material_usage: materialUsage,
}),
});
upsertMaterialCartItem(item);
}
async function syncDraftMaterialById(priceId: number, materialUsage: string) {
const { item } = await apiFetch<{ item: CuttingMaterialCartItem }>(draft_materials.store.url(), {
method: 'POST',
body: JSON.stringify({
raw_material_price_id: priceId,
material_usage: materialUsage,
}),
});
upsertMaterialCartItem(item);
}
async function syncDraftResult(
product: CuttingProductCatalogItem,
variant: CuttingCatalogVariant,
cuttingResult: string,
sampel: string,
hasilCuttingDiluarSampel: string,
) {
const { item } = await apiFetch<{ item: CuttingResultCartItem }>(draft_results.store.url(), {
method: 'POST',
body: JSON.stringify({
product_variant_id: variant.id,
cutting_result: cuttingResult,
sampel,
hasil_cutting_diluar_sampel: hasilCuttingDiluarSampel,
}),
});
upsertResultCartItem(item);
}
async function syncDraftResultById(
variantId: number,
cuttingResult: string,
sampel: string,
hasilCuttingDiluarSampel: string,
) {
const { item } = await apiFetch<{ item: CuttingResultCartItem }>(draft_results.store.url(), {
method: 'POST',
body: JSON.stringify({
product_variant_id: variantId,
cutting_result: cuttingResult,
sampel,
hasil_cutting_diluar_sampel: hasilCuttingDiluarSampel,
}),
});
upsertResultCartItem(item);
}
function getMaterialCartItem(priceId: number): CuttingMaterialCartItem | undefined {
return materialCart.value.find((item) => item.raw_material_price_id === priceId);
}
function getResultCartItem(variantId: number): CuttingResultCartItem | undefined {
return resultCart.value.find((item) => item.product_variant_id === variantId);
}
function syncResultTotals(item: CuttingResultCartItem) {
const total = Number(item.cutting_result) || 0;
const sampel = Number(item.sampel) || 0;
item.hasil_cutting_diluar_sampel = String(Math.max(total - sampel, 0));
}
async function removeMaterial(index: number) {
const item = materialCart.value[index];
if (toValue(options.isCreateMode)) {
try {
await apiFetch(draft_materials.destroy.url(item.raw_material_price_id), {
method: 'DELETE',
});
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menghapus item dari keranjang.');
return;
}
}
materialCart.value.splice(index, 1);
}
async function decreaseMaterialQty(priceId: number) {
const item = materialCart.value.find((i) => i.raw_material_price_id === priceId);
if (!item) {
return;
}
const nextQty = (Number(item.material_usage) || 0) - 1;
if (nextQty <= 0) {
const index = materialCart.value.findIndex((i) => i.raw_material_price_id === priceId);
if (index !== -1) {
await removeMaterial(index);
}
return;
}
if (toValue(options.isCreateMode)) {
try {
await syncDraftMaterialById(priceId, String(nextQty));
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
return;
}
item.material_usage = String(nextQty);
}
async function addMaterial(rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice) {
const existing = materialCart.value.find(
(item) => item.raw_material_price_id === price.id,
);
const defaultUsage = '1';
const nextUsage = existing
? String((Number(existing.material_usage) || 0) + 1)
: defaultUsage;
if (toValue(options.isCreateMode)) {
try {
await syncDraftMaterial(rawMaterial, price, nextUsage);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.');
}
return;
}
if (existing) {
existing.material_usage = nextUsage;
return;
}
materialCart.value.push({
raw_material_price_id: price.id,
raw_material_name: rawMaterial.name,
variant: price.variant,
unit: rawMaterial.unit,
unit_abbreviation: rawMaterial.unit_abbreviation,
stock_input: price.stock_input,
material_usage: defaultUsage,
images: price.images ?? [],
});
}
async function removeResult(index: number) {
const item = resultCart.value[index];
if (toValue(options.isCreateMode)) {
try {
await apiFetch(draft_results.destroy.url(item.product_variant_id), {
method: 'DELETE',
});
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menghapus item dari keranjang.');
return;
}
}
resultCart.value.splice(index, 1);
}
async function decreaseResultQty(variantId: number) {
const item = resultCart.value.find((i) => i.product_variant_id === variantId);
if (!item) {
return;
}
const nextQty = (Number(item.cutting_result) || 0) - 1;
if (nextQty <= 0) {
const index = resultCart.value.findIndex((i) => i.product_variant_id === variantId);
if (index !== -1) {
await removeResult(index);
}
return;
}
const nextSampel = Math.max(0, (Number(item.sampel) || 0) - 1);
const nextHasilCuttingDiluarSampel = Math.max(nextQty - nextSampel, 0);
if (toValue(options.isCreateMode)) {
try {
await syncDraftResultById(
variantId,
String(nextQty),
String(nextSampel),
String(nextHasilCuttingDiluarSampel),
);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
return;
}
item.cutting_result = String(nextQty);
item.sampel = String(nextSampel);
syncResultTotals(item);
}
async function addResult(product: CuttingProductCatalogItem, variant: CuttingCatalogVariant) {
const existing = resultCart.value.find(
(item) => item.product_variant_id === variant.id,
);
const nextQty = existing ? (Number(existing.cutting_result) || 0) + 1 : 1;
const nextSampel = existing ? (Number(existing.sampel) || 0) + 1 : 1;
const nextHasilCuttingDiluarSampel = Math.max(nextQty - nextSampel, 0);
if (toValue(options.isCreateMode)) {
try {
await syncDraftResult(
product,
variant,
String(nextQty),
String(nextSampel),
String(nextHasilCuttingDiluarSampel),
);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.');
}
return;
}
if (existing) {
existing.cutting_result = String(nextQty);
existing.sampel = String(nextSampel);
syncResultTotals(existing);
return;
}
resultCart.value.push({
product_variant_id: variant.id,
product_name: product.name,
variant_name: variant.name,
stock: variant.stock,
cutting_result: '1',
sampel: '1',
hasil_cutting_diluar_sampel: '0',
images: variant.images ?? [],
});
}
async function syncMaterialField(index: number) {
if (!toValue(options.isCreateMode)) {
return;
}
const item = materialCart.value[index];
try {
await syncDraftMaterialById(item.raw_material_price_id, item.material_usage);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui item.');
}
}
async function syncResultField(index: number) {
if (!toValue(options.isCreateMode)) {
return;
}
const item = resultCart.value[index];
try {
await syncDraftResultById(
item.product_variant_id,
item.cutting_result,
item.sampel,
item.hasil_cutting_diluar_sampel,
);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui item.');
}
}
return {
materialSearch,
productSearch,
materialCart,
resultCart,
filteredRawMaterials,
filteredProducts,
totalMaterialCost,
totalResultPieces,
estimatedCostPerUnit,
materialLineCost,
setCarts,
loadDraftItems,
getMaterialCartItem,
getResultCartItem,
syncResultTotals,
addMaterial,
removeMaterial,
decreaseMaterialQty,
addResult,
removeResult,
decreaseResultQty,
syncMaterialField,
syncResultField,
};
}

View File

@ -1,12 +1,10 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import DataTableActions from './data-table-actions.vue';
import { CuttingStatus } from '@/constants/cutting-status';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
@ -15,6 +13,9 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { usePaginationSummary } from '@/composables/usePaginationSummary';
import { cuttingStatusBadgeVariant } from '@/constants/cutting-status';
import { groupedTableRowNumber } from '@/lib/grouped-table';
import type { CuttingListItem } from '@/types/cutting';
import type {
DataTablePagination,
@ -35,39 +36,10 @@ const emit = defineEmits<{
}>();
const showingCount = computed(() => props.cuttings.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
return null;
}
const { total } = props.pagination;
if (total === 0) {
return 'Menampilkan 0 cutting';
}
return `Menampilkan ${showingCount.value} cutting dari ${total}`;
});
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'cutting');
function rowNumber(index: number): number {
return (props.firstItem ?? 1) + index;
}
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (status === CuttingStatus.VERIFIED) {
return 'default';
}
if (status === CuttingStatus.REJECTED) {
return 'destructive';
}
if (status === CuttingStatus.COMPLETED) {
return 'secondary';
}
return 'outline';
return groupedTableRowNumber(props.firstItem, index);
}
interface GroupedCuttingMaterials {
@ -129,32 +101,6 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
return Object.values(groups);
}
function getMaterialUnit(materials: any[]): string | undefined {
return materials[0]?.raw_material_price?.raw_material?.unit_abbreviation;
}
interface MaterialUsageGroup {
total: string;
unit: string;
}
function getGroupedMaterialUsage(materials: any[]): MaterialUsageGroup[] {
const groups: Record<string, number> = {};
materials.forEach((mat) => {
const unit = mat.raw_material_price?.raw_material?.unit_abbreviation ?? '';
const usage = Number(mat.material_usage ?? 0);
groups[unit] = (groups[unit] ?? 0) + usage;
});
return Object.entries(groups)
.filter(([, total]) => total > 0)
.map(([unit, total]) => ({
total: String(parseFloat(total.toFixed(2))),
unit,
}));
}
</script>
<template>
@ -174,7 +120,7 @@ function getGroupedMaterialUsage(materials: any[]): MaterialUsageGroup[] {
<h3 class="font-medium leading-tight">
Cutting #{{ cutting.id }}
</h3>
<Badge :variant="statusVariant(cutting.status)">
<Badge :variant="cuttingStatusBadgeVariant(cutting.status)">
{{ cutting.status_label }}
</Badge>
</div>
@ -188,7 +134,7 @@ function getGroupedMaterialUsage(materials: any[]): MaterialUsageGroup[] {
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
<span>Total Hasil Cutting <strong class="text-primary">{{ cutting.total_result_pieces ??
0 }} pcs</strong></span>
<span v-if="cutting.materials.length">Total Pemakaian Bahan <strong class="text-primary">{{ getGroupedMaterialUsage(cutting.materials).map(g => `${g.total} ${g.unit}`).join(', ') }}</strong></span>
<span v-if="cutting.total_material_usage_summary_formatted">Total Pemakaian Bahan <strong class="text-primary">{{ cutting.total_material_usage_summary_formatted }}</strong></span>
<span>Biaya Bahan <strong class="text-primary">{{
cutting.total_material_cost_formatted ?? 'Rp 0' }}</strong></span>
</div>
@ -292,21 +238,10 @@ function getGroupedMaterialUsage(materials: any[]): MaterialUsageGroup[] {
<DataTableEmpty v-else description="Silakan lakukan pencarian untuk menemukan data yang Anda cari." />
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-sm">
{{ paginationSummary }}
</p>
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
<Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
:disabled="!link.url || link.active" as-child>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>
<span v-else v-html="link.label" />
</Button>
</div>
</div>
<GroupedTableFooter
:summary="paginationSummary"
:pagination="pagination"
:pagination-links="paginationLinks"
/>
</div>
</template>

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import { router, useForm } from '@inertiajs/vue3';
import { Check, RotateCcw, Scissors, X } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { RowDeleteAction, RowEditAction, RowStatusAction } from '@/components/button';
@ -28,7 +27,11 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { useCan } from '@/composables/useCan';
import { CuttingStatus } from '@/constants/cutting-status';
import {
cuttingStatusActionIcon,
CuttingStatus,
cuttingStatusTransitionConfirmDescription,
} from '@/constants/cutting-status';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { parseRupiah } from '@/lib/rupiah';
import { edit, destroy, transition_status } from '@/routes/admin/manage/cuttings';
@ -158,19 +161,7 @@ function canPerformAction(action: CuttingStatusAction): boolean {
}
function statusConfirmDescription(action: CuttingStatusAction): string {
if (action.status === CuttingStatus.COMPLETED) {
return 'Cutting akan ditandai selesai. Stok bahan baku akan dipotong sesuai pemakaian. Menunggu verifikasi admin toko.';
}
if (action.status === CuttingStatus.VERIFIED) {
return 'Hasil cutting akan diverifikasi. Stok sampel dan hasil cutting diluar sampel akan ditambahkan ke produk.';
}
if (action.status === CuttingStatus.IN_PROGRESS) {
return 'Cutting dikembalikan ke proses untuk diperbaiki.';
}
return '';
return cuttingStatusTransitionConfirmDescription(action.status);
}
function openStatusConfirm(action: CuttingStatusAction) {
@ -268,21 +259,6 @@ watch(rejectDialogOpen, (isOpen) => {
}
});
function actionIcon(status: string) {
if (status === CuttingStatus.COMPLETED) {
return Scissors;
}
if (status === CuttingStatus.VERIFIED) {
return Check;
}
if (status === CuttingStatus.IN_PROGRESS) {
return RotateCcw;
}
return X;
}
</script>
<template>
@ -292,7 +268,7 @@ function actionIcon(status: string) {
action-label="Verifikasi Stok" />
<template v-for="action in availableActions" :key="action.status">
<RowStatusAction v-if="canPerformAction(action)" :size="'sm'" :icon="actionIcon(action.status)"
<RowStatusAction v-if="canPerformAction(action)" :size="'sm'" :icon="cuttingStatusActionIcon(action.status)"
:label="action.label" :destructive="action.destructive" @click="openStatusConfirm(action)" />
</template>

View File

@ -17,7 +17,7 @@ import { Head, usePage } from '@inertiajs/vue3';
import { computed, onMounted, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { index, create } from '@/routes/admin/manage/orders';
import ThermalPrinterConnectButton from './form/ThermalPrinterConnectButton.vue';
import ThermalPrinterConnectButton from '@/components/order/ThermalPrinterConnectButton.vue';
import OrderGroupedTable from './table/OrderGroupedTable.vue';
const props = defineProps<{

View File

@ -1,21 +1,20 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import {
Check,
CreditCard,
MapPin,
Package,
Pencil,
Phone,
Receipt,
Send,
Trash2,
User,
X,
} from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { RowDeleteAction, RowDetailAction, RowEditAction, RowStatusAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import OrderPrintButton from '@/components/order/OrderPrintButton.vue';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
@ -31,12 +30,15 @@ import { useCan } from '@/composables/useCan';
import { useDestroy } from '@/composables/useDestroy';
import { MarketplaceFeeScope } from '@/constants/marketplace-fee-scope';
import { MarketplaceFeeValueType } from '@/constants/marketplace-fee-value-type';
import { OrderStatus } from '@/constants/order-status';
import {
orderStatusActionIcon,
orderStatusBadgeVariant,
orderStatusTransitionConfirmDescription,
} from '@/constants/order-status';
import { StockQuality } from '@/constants/stock-quality';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, edit, destroy, transition_status } from '@/routes/admin/manage/orders';
import type { OrderDetail, OrderStatusAction } from '@/types/order';
import OrderPrintButton from './form/OrderPrintButton.vue';
const props = defineProps<{
order: OrderDetail;
@ -57,48 +59,12 @@ const { open: deleteConfirmOpen, processing: deleteProcessing, destroy: destroyO
errorMessage: 'Gagal menghapus pesanan.',
});
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (status === OrderStatus.COMPLETED) {
return 'default';
}
if (status === OrderStatus.CANCELLED) {
return 'destructive';
}
if (status === OrderStatus.PROCESSING) {
return 'secondary';
}
return 'outline';
}
function actionIcon(status: string) {
if (status === OrderStatus.PROCESSING) {
return Send;
}
if (status === OrderStatus.COMPLETED) {
return Check;
}
return X;
}
function canPerformAction(action: OrderStatusAction): boolean {
return can(action.permission);
}
function statusConfirmDescription(action: OrderStatusAction): string {
if (action.status === OrderStatus.PROCESSING) {
return `Pesanan ${props.order.order_number} akan dikirim dan diproses.`;
}
if (action.status === OrderStatus.COMPLETED) {
return `Pesanan ${props.order.order_number} akan ditandai selesai.`;
}
return `Pesanan ${props.order.order_number} akan dibatalkan. Stok produk akan dikembalikan.`;
return orderStatusTransitionConfirmDescription(action.status, props.order.order_number);
}
function openStatusConfirm(action: OrderStatusAction) {
@ -143,7 +109,7 @@ const summaryRows = computed(() => {
}
if (props.order.nego_price != null && props.order.nego_price > 0) {
rows.push({ label: 'Harga Nego', value: props.order.nego_price_formatted });
rows.push({ label: 'Harga Nego', value: props.order.nego_price_formatted ?? '-' });
}
return rows;
@ -234,7 +200,7 @@ const feeEntries = computed(() => {
:variant="action.destructive ? 'outline' : 'default'"
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
@click="openStatusConfirm(action)">
<component :is="actionIcon(action.status)" class="size-3.5" />
<component :is="orderStatusActionIcon(action.status)" class="size-3.5" />
{{ action.label }}
</Button>
</template>
@ -276,7 +242,7 @@ const feeEntries = computed(() => {
</div>
<div>
<p class="text-muted-foreground text-sm">Status</p>
<Badge :variant="statusVariant(order.status)" class="mt-1">
<Badge :variant="orderStatusBadgeVariant(order.status)" class="mt-1">
{{ order.status_label }}
</Badge>
</div>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { formatRupiah } from '@/lib/rupiah';
import type { OrderCartItem } from '@/types/order';
defineProps<{
cart: OrderCartItem[];
stockQualityLabel: (stockQuality: string) => string;
lineSubtotal: (item: OrderCartItem) => number;
totalAmount: number;
}>();
const open = defineModel<boolean>('open', { required: true });
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Detail Keranjang</DialogTitle>
</DialogHeader>
<div class="scrollbar-thin max-h-96 space-y-3 overflow-y-auto overscroll-y-contain">
<div
v-for="item in cart"
:key="`detail-${item.product_variant_id}-${item.stock_quality}`"
class="rounded-lg border p-3"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ item.product_name }}</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }}
· {{ item.stock_quality_label ?? stockQualityLabel(item.stock_quality) }}
</p>
</div>
<span class="shrink-0 text-xs font-medium tabular-nums">{{ item.quantity }} pcs</span>
</div>
<div class="mt-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>@ Rp {{ formatRupiah(item.unit_price) }}</span>
<span class="font-medium text-foreground">Rp {{ formatRupiah(lineSubtotal(item)) }}</span>
</div>
</div>
</div>
<div class="border-t pt-3">
<div class="flex items-center justify-between text-sm font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(totalAmount) }}</span>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,113 @@
<script setup lang="ts">
import { Minus, Plus, Trash2 } from '@lucide/vue';
import { NumberInput } from '@/components/form/number-input';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Field,
FieldLabel,
} from '@/components/ui/field';
import { formatRupiah } from '@/lib/rupiah';
import type { OrderCartItem } from '@/types/order';
defineProps<{
cart: OrderCartItem[];
stockQualityLabel: (stockQuality: string) => string;
lineSubtotal: (item: OrderCartItem) => number;
}>();
const emit = defineEmits<{
remove: [index: number];
'adjust-quantity': [index: number, delta: number];
'sync-quantity': [index: number];
}>();
</script>
<template>
<div v-if="cart.length === 0" class="rounded-lg border border-dashed text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
<EmptyDescription>
Pilih varian produk di sebelah kiri untuk menambahkan ke keranjang.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<div
v-for="(item, index) in cart"
:key="`${item.product_variant_id}-${item.stock_quality}`"
class="rounded-lg border p-3"
>
<div class="flex gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ item.product_name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }}
·
{{ item.stock_quality_label ?? stockQualityLabel(item.stock_quality) }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-7 shrink-0"
@click="emit('remove', index)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
<Field>
<FieldLabel class="text-xs">Jumlah (pcs)</FieldLabel>
<div class="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
class="size-8 shrink-0"
@click="emit('adjust-quantity', index, -1)"
>
<Minus class="size-3.5" />
</Button>
<NumberInput
v-model="item.quantity"
class="h-8 text-center"
@change="emit('sync-quantity', index)"
/>
<Button
type="button"
variant="outline"
size="icon"
class="size-8 shrink-0"
@click="emit('adjust-quantity', index, 1)"
>
<Plus class="size-3.5" />
</Button>
</div>
</Field>
<p class="text-xs text-muted-foreground">
Harga satuan Rp {{ formatRupiah(item.unit_price) }}
</p>
<p class="text-right text-sm font-medium">
Rp {{ formatRupiah(lineSubtotal(item)) }}
</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,153 @@
<script setup lang="ts">
import { Minus, Plus, Search } from '@lucide/vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import { STOCK_QUALITY_OPTIONS } from '@/types/order';
import type { OrderCartItem, OrderCatalogItem } from '@/types/order';
import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
defineProps<{
isCashierUser: boolean;
filteredCatalog: OrderCatalogItem[];
getVariantPrice: (variant: ProductVariantItem) => ProductPriceItem | undefined;
getCartItem: (variantId: number) => OrderCartItem | undefined;
}>();
const search = defineModel<string>('search', { required: true });
const selectedStockQuality = defineModel<'good' | 'reject' | 'retail'>('selectedStockQuality', { required: true });
const emit = defineEmits<{
'add-to-cart': [product: OrderCatalogItem, variant: ProductVariantItem];
'decrease-qty': [variantId: number];
}>();
</script>
<template>
<Card class="min-w-0">
<CardHeader class="pb-3">
<CardTitle class="text-base">Pilih Produk</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex gap-4">
<div class="relative flex-1">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="search" placeholder="Cari produk..." class="pl-9" />
</div>
<Field v-if="!isCashierUser" class="flex-1">
<Select v-model="selectedStockQuality">
<SelectTrigger id="stock_quality" class="w-full">
<SelectValue placeholder="Pilih kualitas stok" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="option in STOCK_QUALITY_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
</div>
<div v-if="filteredCatalog.length === 0" class="py-12 text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada produk ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan produk yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3">
<PosCatalogCard
v-for="product in filteredCatalog"
:key="product.id"
:title="product.name"
:cover-image="getFirstCoverImage(product.variants)"
>
<p v-if="!product.variants.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div
v-for="variant in product.variants"
:key="variant.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[
getVariantPrice(variant) ? 'cursor-pointer hover:bg-muted/30' : 'opacity-60',
getCartItem(variant.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : '',
]"
@click="getVariantPrice(variant) && !getCartItem(variant.id) && emit('add-to-cart', product, variant)"
>
<PosCatalogVariantThumb :items="variant.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ variant.name }}
</p>
<p class="text-xs text-muted-foreground">
<span v-if="isCashierUser" class="tabular-nums">Ecer: {{ variant.stock_retail ?? 0 }}</span>
<template v-else>
<span class="tabular-nums">Bagus: {{ variant.stock }}</span>
<span class="mx-1">·</span>
<span class="tabular-nums">Reject: {{ variant.reject_stock ?? 0 }}</span>
</template>
<span class="mx-1">·</span>
<span v-if="getVariantPrice(variant)" class="tabular-nums">
{{ getVariantPrice(variant)!.price_formatted }}
</span>
<span v-else>-</span>
</p>
</div>
<div v-if="getCartItem(variant.id)" class="flex shrink-0 items-center gap-1.5">
<Button type="button" variant="outline" size="icon-sm" @click.stop="emit('decrease-qty', variant.id)">
<Minus class="size-3.5" />
</Button>
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
{{ getCartItem(variant.id)!.quantity }}
</span>
<Button type="button" variant="outline" size="icon-sm" @click.stop="emit('add-to-cart', product, variant)">
<Plus class="size-3.5" />
</Button>
</div>
<Button
v-else
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
:disabled="!getVariantPrice(variant)"
@click.stop="emit('add-to-cart', product, variant)"
>
<Plus class="size-3.5" />
</Button>
</div>
</PosCatalogCard>
</div>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,133 @@
<script setup lang="ts">
import { Save } from '@lucide/vue';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import {
Field,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { OrderStatus } from '@/constants/order-status';
import { PaperSize } from '@/constants/paper-size';
import { formErrors } from '@/lib/form';
import type { FormWithErrors } from '@/lib/form';
import { formatRupiah } from '@/lib/rupiah';
import type { MediaUploadState } from '@/types/media';
defineProps<{
form: FormWithErrors & {
discount: string;
nego_price: string;
notes: string;
status: string;
processing?: boolean;
};
subtotal: number;
totalAmount: number;
showPhotoInput: boolean;
isCreateMode: boolean;
canComplete: boolean;
canCreate: boolean;
cartEmpty: boolean;
submitLabel: string;
}>();
const printAfterSave = defineModel<boolean>('printAfterSave', { required: true });
const selectedPaperSize = defineModel<'58mm' | '80mm'>('selectedPaperSize', { required: true });
const photoState = defineModel<MediaUploadState>('photoState', { required: true });
</script>
<template>
<Separator />
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Subtotal</span>
<span class="font-medium">Rp {{ formatRupiah(subtotal) }}</span>
</div>
<Field>
<FieldLabel for="discount">Diskon</FieldLabel>
<RupiahInput id="discount" v-model="form.discount" placeholder="0" />
<FieldError :errors="formErrors(form, 'discount')" />
</Field>
<Field>
<FieldLabel for="nego_price">Harga Nego</FieldLabel>
<RupiahInput id="nego_price" v-model="form.nego_price" placeholder="Kosongkan jika tidak ada nego" />
<p class="text-xs text-muted-foreground">Jika diisi, harga nego menjadi total akhir.</p>
<FieldError :errors="formErrors(form, 'nego_price')" />
</Field>
<div class="flex justify-between text-base font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(totalAmount) }}</span>
</div>
</div>
<Field v-if="canComplete">
<div class="flex items-center gap-3">
<Switch
id="status-completed"
:model-value="form.status === OrderStatus.COMPLETED"
@update:model-value="form.status = $event ? OrderStatus.COMPLETED : OrderStatus.PENDING"
/>
<Label for="status-completed" class="cursor-pointer text-sm">
Pesanan selesai
</Label>
</div>
<p class="text-xs text-muted-foreground">
Aktifkan jika pesanan sudah selesai diproses.
</p>
<FieldError :errors="formErrors(form, 'status')" />
</Field>
<Field>
<FieldLabel for="notes">Keterangan</FieldLabel>
<Textarea id="notes" v-model="form.notes" placeholder="Contoh: Pesanan walk-in" rows="2" />
<FieldError :errors="formErrors(form, 'notes')" />
</Field>
<MediaDropzone
v-if="showPhotoInput"
id="order-photos"
v-model="photoState"
label="Bukti Transaksi"
:max-files="1"
required
:errors="formErrors(form, 'photos')"
/>
<Field v-if="isCreateMode && canCreate">
<div class="flex items-center gap-3">
<Switch id="print-after-save" v-model="printAfterSave" />
<Label for="print-after-save" class="cursor-pointer text-sm">
Cetak struk setelah simpan
</Label>
</div>
<div v-if="printAfterSave" class="mt-2 flex items-center gap-2 pl-1">
<span class="text-xs text-muted-foreground">Ukuran kertas:</span>
<div class="flex gap-1.5">
<button
v-for="size in ([PaperSize.MM_58, PaperSize.MM_80] as const)"
:key="size"
type="button"
class="inline-flex h-7 cursor-pointer items-center rounded-md border px-2.5 text-xs font-medium transition-colors"
:class="selectedPaperSize === size
? 'border-primary bg-primary/5 text-primary'
: 'border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground'"
@click="selectedPaperSize = size"
>
{{ size }}
</button>
</div>
</div>
</Field>
<Button type="submit" class="w-full" :disabled="form.processing || cartEmpty">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</template>

View File

@ -1,65 +1,30 @@
<script setup lang="ts">
import { useForm, usePage } from '@inertiajs/vue3';
import { Minus, Plus, Save, Search, ShoppingCart, Trash2 } from '@lucide/vue';
import { ShoppingCart } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { NumberInput } from '@/components/form/number-input';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { useCan } from '@/composables/useCan';
import { OrderChannel } from '@/constants/order-channel';
import { OrderPaymentType } from '@/constants/order-payment-type';
import { OrderStatus } from '@/constants/order-status';
import { PaperSize } from '@/constants/paper-size';
import { StockQuality } from '@/constants/stock-quality';
import { apiFetch } from '@/lib/api';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import { formErrors } from '@/lib/form';
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
import { store as syncDraftRoute, resync_prices as resyncPricesRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/orders/draft_items';
import { parseRupiah } from '@/lib/rupiah';
import type { Auth } from '@/types/auth';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { MediaItem, MediaUploadState } from '@/types/media';
import { STOCK_QUALITY_OPTIONS, STOCK_QUALITY_OPTIONS_CASHIER } from '@/types/order';
import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order';
import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
import CustomerQuickCreateModal from './CustomerQuickCreateModal.vue';
import OrderPosCartDetailDialog from './OrderPosCartDetailDialog.vue';
import OrderPosCartSummaryItems from './OrderPosCartSummaryItems.vue';
import OrderPosCatalogPanel from './OrderPosCatalogPanel.vue';
import OrderPosCheckoutSection from './OrderPosCheckoutSection.vue';
import OrderPosMetadataFields from './OrderPosMetadataFields.vue';
import { useOrderPosCart } from './useOrderPosCart';
const props = defineProps<{
customers: SelectOption[];
@ -95,34 +60,14 @@ const emit = defineEmits<{
}>();
const isCreateMode = computed(() => props.method === 'post');
const { can } = useCan();
const page = usePage();
const authUser = computed(() => (page.props.auth as Auth).user);
const isMarketingUser = computed(() =>
(authUser.value?.roles?.includes('marketing-offline') || authUser.value?.roles?.includes('marketing-online')) ?? false,
);
const isCashierUser = computed(() => authUser.value?.roles?.includes('cashier') ?? false);
const isCashierUser = computed(() =>
authUser.value?.roles?.includes('cashier') ?? false,
);
const search = ref('');
const selectedStockQuality = ref<'good' | 'reject' | 'retail'>(isCashierUser.value ? StockQuality.RETAIL : StockQuality.GOOD);
const cart = ref<OrderCartItem[]>([]);
const customerFormOpen = ref(false);
const cartDetailOpen = ref(false);
const printAfterSave = ref(false);
const selectedPaperSize = ref<'58mm' | '80mm'>(PaperSize.MM_80);
const photoState = ref<MediaUploadState>(createMediaUploadState());
function onCustomerCreated(customer: { id: number; name: string }) {
emit('customer-created', customer);
form.customer_id = String(customer.id);
}
// Auto-fill marketing_id with logged-in user's id if they are marketing role
const defaultMarketingId = computed(() =>
isMarketingUser.value && authUser.value ? String(authUser.value.id) : '',
);
@ -132,19 +77,66 @@ const form = useForm({
marketing_id: defaultMarketingId.value || 'none',
channel: 'store',
price_type: 'retail',
payment_type: OrderPaymentType.CASH,
payment_type: OrderPaymentType.CASH as string,
is_affiliate: false,
tiktok_order_id: '',
shopee_order_id: '',
discount: '',
nego_price: '',
notes: '',
status: OrderStatus.PENDING,
status: OrderStatus.PENDING as string,
});
const isStoreChannel = computed(() => form.channel === 'store');
const isMarketplaceChannel = computed(() => form.channel === OrderChannel.SHOPEE || form.channel === OrderChannel.TIKTOK);
const showPhotoInput = computed(() => form.payment_type === 'qris' || form.payment_type === 'transfer');
const showPhotoInput = computed(() =>
form.payment_type === OrderPaymentType.QRIS || form.payment_type === OrderPaymentType.TRANSFER,
);
const customerFormOpen = ref(false);
const cartDetailOpen = ref(false);
const printAfterSave = ref(false);
const selectedPaperSize = ref<'58mm' | '80mm'>(PaperSize.MM_80);
const photoState = ref<MediaUploadState>(createMediaUploadState());
const {
search,
selectedStockQuality,
cart,
filteredCatalog,
subtotal,
setCart,
loadDraftItems,
getVariantPrice,
getCartItem,
lineSubtotal,
stockQualityLabel,
addToCart,
removeFromCart,
adjustQuantity,
syncCartItemQuantity,
decreaseVariantQty,
} = useOrderPosCart({
catalog: () => props.catalog,
isCreateMode: () => isCreateMode.value,
priceType: () => form.price_type,
isCashierUser: () => isCashierUser.value,
});
const discountAmount = computed(() => Number(parseRupiah(form.discount)) || 0);
const negoPriceAmount = computed(() => Number(parseRupiah(form.nego_price)) || 0);
const totalAmount = computed(() => {
if (negoPriceAmount.value > 0) {
return negoPriceAmount.value;
}
return Math.max(subtotal.value - discountAmount.value, 0);
});
function onCustomerCreated(customer: { id: number; name: string }) {
emit('customer-created', customer);
form.customer_id = String(customer.id);
}
function populateForm() {
if (!props.initialData) {
@ -166,29 +158,11 @@ function populateForm() {
photoState.value = createMediaUploadState(
props.initialData.photos ? [props.initialData.photos] : [],
);
cart.value = props.initialData.items.map((item) => ({
...item,
stock_quality: item.stock_quality ?? StockQuality.GOOD,
}));
setCart(props.initialData.items);
}
watch(
() => props.initialData,
() => {
populateForm();
},
{ immediate: true },
);
function populateDraftItems() {
if (!isCreateMode.value || !props.draftItems?.length) {
return;
}
cart.value = props.draftItems.map((item) => ({ ...item }));
}
populateDraftItems();
watch(() => props.initialData, populateForm, { immediate: true });
loadDraftItems(props.draftItems ?? []);
watch(
() => form.channel,
@ -209,247 +183,6 @@ watch(
},
);
watch(
() => form.price_type,
async (priceType) => {
if (!isCreateMode.value || cart.value.length === 0) {
return;
}
try {
const { items } = await apiFetch<{ items: OrderCartItem[] }>(
resyncPricesRoute.url(),
{
method: 'PUT',
body: JSON.stringify({ price_type: priceType }),
},
);
cart.value = items.map((item) => ({ ...item }));
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui harga keranjang.');
}
},
);
function upsertCartItem(item: OrderCartItem) {
const index = cart.value.findIndex(
(cartItem) =>
cartItem.product_variant_id === item.product_variant_id
&& cartItem.stock_quality === item.stock_quality,
);
if (index === -1) {
cart.value.push({ ...item });
return;
}
cart.value[index] = { ...item };
}
function availableStockForVariant(variant: ProductVariantItem, stockQuality: string): number {
if (stockQuality === StockQuality.REJECT) return variant.reject_stock;
if (stockQuality === StockQuality.RETAIL) return variant.stock_retail ?? 0;
return variant.stock;
}
const filteredCatalog = computed(() => {
const keyword = search.value.trim().toLowerCase();
if (!keyword) {
return props.catalog;
}
return props.catalog.filter((product) =>
product.name.toLowerCase().includes(keyword)
|| product.variants.some((variant) => variant.name.toLowerCase().includes(keyword)),
);
});
const subtotal = computed(() =>
cart.value.reduce((sum, item) => sum + lineSubtotal(item), 0),
);
const discountAmount = computed(() => Number(parseRupiah(form.discount)) || 0);
const negoPriceAmount = computed(() => Number(parseRupiah(form.nego_price)) || 0);
const totalAmount = computed(() => {
if (negoPriceAmount.value > 0) {
return negoPriceAmount.value;
}
return Math.max(subtotal.value - discountAmount.value, 0);
});
function getVariantPrice(variant: ProductVariantItem): ProductPriceItem | undefined {
return variant.prices.find((price) => price.type === form.price_type);
}
function getCartItem(variantId: number, stockQuality = selectedStockQuality.value): OrderCartItem | undefined {
return cart.value.find(
(item) =>
item.product_variant_id === variantId
&& item.stock_quality === stockQuality,
);
}
async function decreaseVariantQty(variantId: number) {
const index = cart.value.findIndex(
(item) =>
item.product_variant_id === variantId
&& item.stock_quality === selectedStockQuality.value,
);
if (index !== -1) {
await adjustQuantity(index, -1);
}
}
function lineSubtotal(item: OrderCartItem): number {
const quantity = Number(item.quantity) || 0;
return quantity * item.unit_price;
}
async function syncDraftItem(variantId: number, quantity: number, stockQuality = selectedStockQuality.value) {
const { item } = await apiFetch<{ item: OrderCartItem }>(syncDraftRoute.url(), {
method: 'POST',
body: JSON.stringify({
product_variant_id: variantId,
quantity,
price_type: form.price_type,
stock_quality: stockQuality,
}),
});
upsertCartItem(item);
}
async function addToCart(product: OrderCatalogItem, variant: ProductVariantItem) {
const price = getVariantPrice(variant);
if (!price) {
toast.error('Harga untuk tipe harga ini belum diatur.');
return;
}
const stockQuality = selectedStockQuality.value;
const availableStock = availableStockForVariant(variant, stockQuality);
if (availableStock < 1) {
toast.error(`Stok ${stockQuality === StockQuality.REJECT ? 'Reject' : stockQuality === StockQuality.RETAIL ? 'Eceran' : 'Bagus'} tidak tersedia.`);
return;
}
const existing = cart.value.find(
(item) =>
item.product_variant_id === variant.id
&& item.stock_quality === stockQuality,
);
const nextQty = existing ? (Number(existing.quantity) || 0) + 1 : 1;
if (nextQty > availableStock) {
toast.error(`Stok ${stockQuality === StockQuality.REJECT ? 'Reject' : stockQuality === StockQuality.RETAIL ? 'Eceran' : 'Bagus'} tidak mencukupi.`);
return;
}
if (isCreateMode.value) {
try {
await syncDraftItem(variant.id, nextQty, stockQuality);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.');
}
return;
}
if (existing) {
existing.quantity = String(nextQty);
return;
}
cart.value.push({
product_variant_id: variant.id,
product_name: product.name,
variant_name: variant.name,
stock_quality: stockQuality,
stock_quality_label: STOCK_QUALITY_OPTIONS.find((option) => option.value === stockQuality)?.label,
quantity: '1',
unit_price: Number(price.price_input),
images: variant.images ?? [],
});
}
async function removeFromCart(index: number) {
const item = cart.value[index];
if (isCreateMode.value) {
try {
await apiFetch(
destroyDraftRoute.url(item.product_variant_id, {
query: { stock_quality: item.stock_quality }
}),
{
method: 'DELETE',
},
);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menghapus item dari keranjang.');
return;
}
}
cart.value.splice(index, 1);
}
async function adjustQuantity(index: number, delta: number) {
const item = cart.value[index];
const nextQty = (Number(item.quantity) || 0) + delta;
if (nextQty < 1) {
await removeFromCart(index);
return;
}
if (isCreateMode.value) {
try {
await syncDraftItem(item.product_variant_id, nextQty, item.stock_quality);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
return;
}
item.quantity = String(nextQty);
}
async function syncCartItemQuantity(index: number) {
const item = cart.value[index];
const nextQty = Number(item.quantity) || 0;
if (nextQty < 1) {
await removeFromCart(index);
return;
}
if (!isCreateMode.value) {
return;
}
try {
await syncDraftItem(item.product_variant_id, nextQty, item.stock_quality);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
}
function buildFormData(): FormData {
const formData = new FormData();
@ -467,7 +200,6 @@ function buildFormData(): FormData {
formData.append('marketing_id', String(authUser.value.id));
}
// For cashier, force store channel with retail price type and cash payment
if (isCashierUser.value) {
formData.append('channel', 'store');
formData.append('price_type', 'retail');
@ -510,7 +242,7 @@ function buildFormData(): FormData {
cart.value.forEach((item, index) => {
formData.append(`items[${index}][product_variant_id]`, String(item.product_variant_id));
formData.append(`items[${index}][stock_quality]`, item.stock_quality);
formData.append(`items[${index}][quantity]`, item.quantity);
formData.append(`items[${index}][quantity]`, String(item.quantity));
});
}
@ -528,7 +260,7 @@ function submit() {
form.transform(() => payload).post(props.submitUrl, {
forceFormData: true,
onError: (errors: any) => {
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
}
@ -539,98 +271,16 @@ function submit() {
<template>
<div class="grid gap-4 xl:grid-cols-[1fr_380px]">
<Card class="min-w-0">
<CardHeader class="pb-3">
<CardTitle class="text-base">Pilih Produk</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex gap-4">
<div class="relative flex-1">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="search" placeholder="Cari produk..." class="pl-9" />
</div>
<Field v-if="!isCashierUser" class="flex-1">
<Select v-model="selectedStockQuality">
<SelectTrigger id="stock_quality" class="w-full">
<SelectValue placeholder="Pilih kualitas stok" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="option in STOCK_QUALITY_OPTIONS" :key="option.value"
:value="option.value">
{{ option.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
</div>
<div v-if="filteredCatalog.length === 0" class="py-12 text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada produk ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan produk yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3">
<PosCatalogCard v-for="product in filteredCatalog" :key="product.id" :title="product.name"
:cover-image="getFirstCoverImage(product.variants)">
<p v-if="!product.variants.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div v-for="variant in product.variants" :key="variant.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
getVariantPrice(variant) ? 'cursor-pointer hover:bg-muted/30' : 'opacity-60',
getCartItem(variant.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : ''
]"
@click="getVariantPrice(variant) && !getCartItem(variant.id) && addToCart(product, variant)">
<PosCatalogVariantThumb :items="variant.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ variant.name }}
</p>
<p class="text-xs text-muted-foreground">
<span v-if="isCashierUser" class="tabular-nums">Ecer: {{ variant.stock_retail ?? 0 }}</span>
<template v-else>
<span class="tabular-nums">Bagus: {{ variant.stock }}</span>
<span class="mx-1">·</span>
<span class="tabular-nums">Reject: {{ variant.reject_stock ?? 0 }}</span>
</template>
<span class="mx-1">·</span>
<span v-if="getVariantPrice(variant)" class="tabular-nums">
{{ getVariantPrice(variant)!.price_formatted }}
</span>
<span v-else>-</span>
</p>
</div>
<div v-if="getCartItem(variant.id)" class="flex items-center gap-1.5 shrink-0">
<Button type="button" variant="outline" size="icon-sm"
@click.stop="decreaseVariantQty(variant.id)">
<Minus class="size-3.5" />
</Button>
<span class="text-xs font-semibold min-w-[1.25rem] text-center tabular-nums">
{{ getCartItem(variant.id)!.quantity }}
</span>
<Button type="button" variant="outline" size="icon-sm"
@click.stop="addToCart(product, variant)">
<Plus class="size-3.5" />
</Button>
</div>
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
:disabled="!getVariantPrice(variant)" @click.stop="addToCart(product, variant)">
<Plus class="size-3.5" />
</Button>
</div>
</PosCatalogCard>
</div>
</CardContent>
</Card>
<OrderPosCatalogPanel
v-model:search="search"
v-model:selected-stock-quality="selectedStockQuality"
:is-cashier-user="isCashierUser"
:filtered-catalog="filteredCatalog"
:get-variant-price="getVariantPrice"
:get-cart-item="getCartItem"
@add-to-cart="addToCart"
@decrease-qty="decreaseVariantQty"
/>
<Card class="h-fit xl:sticky xl:top-4">
<CardHeader class="pb-3">
@ -639,9 +289,12 @@ function submit() {
<ShoppingCart class="size-4" />
Ringkasan Pesanan
</span>
<button v-if="cart.length > 0" type="button"
<button
v-if="cart.length > 0"
type="button"
class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80"
@click="cartDetailOpen = true">
@click="cartDetailOpen = true"
>
Lihat Detail
</button>
</CardTitle>
@ -650,284 +303,44 @@ function submit() {
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field v-if="!isCashierUser">
<FieldLabel for="channel" required>Channel</FieldLabel>
<Select v-model="form.channel">
<SelectTrigger id="channel" class="w-full">
<SelectValue placeholder="Pilih channel" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="channel in channels" :key="channel.value"
:value="channel.value">
{{ channel.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="formErrors(form, 'channel')" />
</Field>
<OrderPosMetadataFields
:form="form"
:channels="channels"
:store-price-types="storePriceTypes"
:payment-types="paymentTypes"
:customers="customers"
:marketings="marketings"
:is-cashier-user="isCashierUser"
:is-store-channel="isStoreChannel"
:is-marketplace-channel="isMarketplaceChannel"
:is-marketing-user="isMarketingUser"
:can-create-customer="can('customers.create')"
@open-customer-form="customerFormOpen = true"
/>
<Field v-if="isStoreChannel && !isCashierUser">
<FieldLabel for="price_type" required>Tipe Harga</FieldLabel>
<Select v-model="form.price_type">
<SelectTrigger id="price_type" class="w-full">
<SelectValue placeholder="Pilih tipe harga" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="priceType in storePriceTypes" :key="priceType.value"
:value="priceType.value">
{{ priceType.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="formErrors(form, 'price_type')" />
</Field>
<OrderPosCartSummaryItems
:cart="cart"
:stock-quality-label="stockQualityLabel"
:line-subtotal="lineSubtotal"
@remove="removeFromCart"
@adjust-quantity="adjustQuantity"
@sync-quantity="syncCartItemQuantity"
/>
<Field v-if="!isStoreChannel && !isCashierUser">
<FieldLabel>Tipe Harga</FieldLabel>
<div class="flex h-9 items-center rounded-md border bg-muted/40 px-3 text-sm">
{{ form.channel === OrderChannel.SHOPEE ? 'Shopee' : 'TikTok' }}
</div>
</Field>
<Field v-if="form.channel === OrderChannel.TIKTOK && !isCashierUser">
<FieldLabel for="tiktok_order_id" :required="form.channel === OrderChannel.TIKTOK">ID
Pesanan
TikTok Shop</FieldLabel>
<Input id="tiktok_order_id" v-model="form.tiktok_order_id"
placeholder="Masukkan ID pesanan TikTok Shop" />
<FieldError :errors="formErrors(form, 'tiktok_order_id')" />
</Field>
<Field v-if="form.channel === OrderChannel.SHOPEE && !isCashierUser">
<FieldLabel for="shopee_order_id" :required="form.channel === OrderChannel.SHOPEE">ID
Pesanan
Shopee</FieldLabel>
<Input id="shopee_order_id" v-model="form.shopee_order_id"
placeholder="Masukkan ID pesanan Shopee" />
<FieldError :errors="formErrors(form, 'shopee_order_id')" />
</Field>
<Field v-if="!isCashierUser">
<FieldLabel for="payment_type" required>Tipe Pembayaran</FieldLabel>
<Select v-model="form.payment_type" :disabled="isMarketplaceChannel">
<SelectTrigger id="payment_type" class="w-full">
<SelectValue placeholder="Pilih tipe pembayaran" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="pt in paymentTypes" :key="pt.value" :value="pt.value">
{{ pt.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="formErrors(form, 'payment_type')" />
</Field>
<Field v-if="isMarketplaceChannel && !isCashierUser">
<div class="flex items-center gap-3">
<Switch id="is_affiliate" :model-value="form.is_affiliate"
@update:model-value="form.is_affiliate = $event" />
<FieldLabel for="is_affiliate" class="cursor-pointer">Pesanan Afiliasi</FieldLabel>
</div>
<p class="text-xs text-muted-foreground">
Aktifkan jika pesanan ini melalui afiliasi. Biaya afiliasi akan dikenakan sesuai
pengaturan marketplace.
</p>
</Field>
<Field v-if="!isCashierUser">
<FieldLabel for="customer">Pelanggan</FieldLabel>
<div class="flex gap-2">
<Select v-model="form.customer_id" class="flex-1">
<SelectTrigger id="customer" class="w-full">
<SelectValue placeholder="Pilih pelanggan" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="customer in customers" :key="customer.value"
:value="String(customer.value)">
{{ customer.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button v-if="can('customers.create')" type="button" variant="outline" size="icon"
class="shrink-0" @click="customerFormOpen = true">
<Plus class="size-4" />
</Button>
</div>
<FieldError :errors="formErrors(form, 'customer_id')" />
</Field>
<Field>
<FieldLabel for="marketing">Marketing</FieldLabel>
<Select v-model="form.marketing_id" :disabled="isMarketingUser">
<SelectTrigger id="marketing" class="w-full">
<SelectValue placeholder="Pilih marketing" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="marketing in marketings" :key="marketing.value"
:value="String(marketing.value)">
{{ marketing.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<p v-if="isMarketingUser" class="text-xs text-muted-foreground">
Otomatis tercatat atas nama Anda.
</p>
<FieldError :errors="formErrors(form, 'marketing_id')" />
</Field>
<div v-if="cart.length === 0"
class="rounded-lg border border-dashed text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
<EmptyDescription>
Pilih varian produk di sebelah kiri untuk menambahkan ke keranjang.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<div v-for="(item, index) in cart"
:key="`${item.product_variant_id}-${item.stock_quality}`"
class="rounded-lg border p-3">
<div class="flex gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ item.product_name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }}
·
{{ item.stock_quality_label ?? (item.stock_quality ===
StockQuality.REJECT
? 'Reject' : item.stock_quality === StockQuality.RETAIL
? 'Eceran' : 'Bagus') }}
</p>
</div>
<Button type="button" variant="ghost" size="icon"
class="text-destructive hover:text-destructive size-7 shrink-0"
@click="removeFromCart(index)">
<Trash2 class="size-3.5" />
</Button>
</div>
<Field>
<FieldLabel class="text-xs">Jumlah (pcs)</FieldLabel>
<div class="flex items-center gap-1">
<Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, -1)">
<Minus class="size-3.5" />
</Button>
<NumberInput v-model="item.quantity" class="h-8 text-center"
@change="syncCartItemQuantity(index)" />
<Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, 1)">
<Plus class="size-3.5" />
</Button>
</div>
</Field>
<p class="text-xs text-muted-foreground">
Harga satuan Rp {{ formatRupiah(item.unit_price) }}
</p>
<p class="text-right text-sm font-medium">
Rp {{ formatRupiah(lineSubtotal(item)) }}
</p>
</div>
</div>
</div>
</div>
<Separator />
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Subtotal</span>
<span class="font-medium">Rp {{ formatRupiah(subtotal) }}</span>
</div>
<Field>
<FieldLabel for="discount">Diskon</FieldLabel>
<RupiahInput id="discount" v-model="form.discount" placeholder="0" />
<FieldError :errors="formErrors(form, 'discount')" />
</Field>
<Field>
<FieldLabel for="nego_price">Harga Nego</FieldLabel>
<RupiahInput id="nego_price" v-model="form.nego_price" placeholder="Kosongkan jika tidak ada nego" />
<p class="text-xs text-muted-foreground">Jika diisi, harga nego menjadi total akhir.</p>
<FieldError :errors="formErrors(form, 'nego_price')" />
</Field>
<div class="flex justify-between text-base font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(totalAmount) }}</span>
</div>
</div>
<Field v-if="can('orders.complete')">
<div class="flex items-center gap-3">
<Switch id="status-completed" :model-value="form.status === OrderStatus.COMPLETED"
@update:model-value="form.status = $event ? OrderStatus.COMPLETED : OrderStatus.PENDING" />
<Label for="status-completed" class="cursor-pointer text-sm">
Pesanan selesai
</Label>
</div>
<p class="text-xs text-muted-foreground">
Aktifkan jika pesanan sudah selesai diproses.
</p>
<FieldError :errors="formErrors(form, 'status')" />
</Field>
<Field>
<FieldLabel for="notes">Keterangan</FieldLabel>
<Textarea id="notes" v-model="form.notes" placeholder="Contoh: Pesanan walk-in"
rows="2" />
<FieldError :errors="formErrors(form, 'notes')" />
</Field>
<MediaDropzone v-if="showPhotoInput" id="order-photos" v-model="photoState" label="Bukti Transaksi"
:max-files="1" required :errors="formErrors(form, 'photos')" />
<Field v-if="isCreateMode && can('orders.create')">
<div class="flex items-center gap-3">
<Switch id="print-after-save" :model-value="printAfterSave"
@update:model-value="printAfterSave = $event" />
<Label for="print-after-save" class="cursor-pointer text-sm">
Cetak struk setelah simpan
</Label>
</div>
<div v-if="printAfterSave" class="mt-2 flex items-center gap-2 pl-1">
<span class="text-xs text-muted-foreground">Ukuran kertas:</span>
<div class="flex gap-1.5">
<button v-for="size in ([PaperSize.MM_58, PaperSize.MM_80] as const)"
:key="size" type="button"
class="inline-flex h-7 items-center rounded-md border px-2.5 text-xs font-medium transition-colors cursor-pointer"
:class="selectedPaperSize === size
? 'border-primary bg-primary/5 text-primary'
: 'border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground'"
@click="selectedPaperSize = size">
{{ size }}
</button>
</div>
</div>
</Field>
<Button type="submit" class="w-full" :disabled="form.processing || cart.length === 0">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
<OrderPosCheckoutSection
v-model:print-after-save="printAfterSave"
v-model:selected-paper-size="selectedPaperSize"
v-model:photo-state="photoState"
:form="form"
:subtotal="subtotal"
:total-amount="totalAmount"
:show-photo-input="showPhotoInput"
:is-create-mode="isCreateMode"
:can-complete="can('orders.complete')"
:can-create="can('orders.create')"
:cart-empty="cart.length === 0"
:submit-label="submitLabel"
/>
</FieldSet>
</FieldGroup>
</form>
@ -937,38 +350,11 @@ function submit() {
<CustomerQuickCreateModal v-model:open="customerFormOpen" @created="onCustomerCreated" />
<Dialog v-model:open="cartDetailOpen">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Detail Keranjang</DialogTitle>
</DialogHeader>
<div class="max-h-96 space-y-3 overflow-y-auto overscroll-y-contain scrollbar-thin">
<div v-for="item in cart" :key="`detail-${item.product_variant_id}-${item.stock_quality}`"
class="rounded-lg border p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ item.product_name }}</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }}
· {{ item.stock_quality_label ?? (item.stock_quality === StockQuality.REJECT ? 'Reject'
: item.stock_quality === StockQuality.RETAIL ? 'Eceran' : 'Bagus')
}}
</p>
</div>
<span class="shrink-0 text-xs font-medium tabular-nums">{{ item.quantity }} pcs</span>
</div>
<div class="mt-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>@ Rp {{ formatRupiah(item.unit_price) }}</span>
<span class="font-medium text-foreground">Rp {{ formatRupiah(lineSubtotal(item)) }}</span>
</div>
</div>
</div>
<div class="border-t pt-3">
<div class="flex items-center justify-between text-sm font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(totalAmount) }}</span>
</div>
</div>
</DialogContent>
</Dialog>
<OrderPosCartDetailDialog
v-model:open="cartDetailOpen"
:cart="cart"
:stock-quality-label="stockQualityLabel"
:line-subtotal="lineSubtotal"
:total-amount="totalAmount"
/>
</template>

View File

@ -0,0 +1,185 @@
<script setup lang="ts">
import { Plus } from '@lucide/vue';
import { Button } from '@/components/ui/button';
import {
Field,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { OrderChannel } from '@/constants/order-channel';
import { formErrors } from '@/lib/form';
import type { FormWithErrors } from '@/lib/form';
import type { EnumOption, SelectOption } from '@/types/order';
defineProps<{
form: FormWithErrors & {
channel: string;
price_type: string;
payment_type: string;
is_affiliate: boolean;
tiktok_order_id: string;
shopee_order_id: string;
customer_id: string;
marketing_id: string;
};
channels: EnumOption[];
storePriceTypes: EnumOption[];
paymentTypes: EnumOption[];
customers: SelectOption[];
marketings: SelectOption[];
isCashierUser: boolean;
isStoreChannel: boolean;
isMarketplaceChannel: boolean;
isMarketingUser: boolean;
canCreateCustomer: boolean;
}>();
const emit = defineEmits<{
'open-customer-form': [];
}>();
</script>
<template>
<Field v-if="!isCashierUser">
<FieldLabel for="channel" required>Channel</FieldLabel>
<Select v-model="form.channel">
<SelectTrigger id="channel" class="w-full">
<SelectValue placeholder="Pilih channel" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="channel in channels" :key="channel.value" :value="channel.value">
{{ channel.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="formErrors(form, 'channel')" />
</Field>
<Field v-if="isStoreChannel && !isCashierUser">
<FieldLabel for="price_type" required>Tipe Harga</FieldLabel>
<Select v-model="form.price_type">
<SelectTrigger id="price_type" class="w-full">
<SelectValue placeholder="Pilih tipe harga" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="priceType in storePriceTypes" :key="priceType.value" :value="priceType.value">
{{ priceType.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="formErrors(form, 'price_type')" />
</Field>
<Field v-if="!isStoreChannel && !isCashierUser">
<FieldLabel>Tipe Harga</FieldLabel>
<div class="flex h-9 items-center rounded-md border bg-muted/40 px-3 text-sm">
{{ form.channel === OrderChannel.SHOPEE ? 'Shopee' : 'TikTok' }}
</div>
</Field>
<Field v-if="form.channel === OrderChannel.TIKTOK && !isCashierUser">
<FieldLabel for="tiktok_order_id" :required="form.channel === OrderChannel.TIKTOK">
ID Pesanan TikTok Shop
</FieldLabel>
<Input id="tiktok_order_id" v-model="form.tiktok_order_id" placeholder="Masukkan ID pesanan TikTok Shop" />
<FieldError :errors="formErrors(form, 'tiktok_order_id')" />
</Field>
<Field v-if="form.channel === OrderChannel.SHOPEE && !isCashierUser">
<FieldLabel for="shopee_order_id" :required="form.channel === OrderChannel.SHOPEE">
ID Pesanan Shopee
</FieldLabel>
<Input id="shopee_order_id" v-model="form.shopee_order_id" placeholder="Masukkan ID pesanan Shopee" />
<FieldError :errors="formErrors(form, 'shopee_order_id')" />
</Field>
<Field v-if="!isCashierUser">
<FieldLabel for="payment_type" required>Tipe Pembayaran</FieldLabel>
<Select v-model="form.payment_type" :disabled="isMarketplaceChannel">
<SelectTrigger id="payment_type" class="w-full">
<SelectValue placeholder="Pilih tipe pembayaran" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="pt in paymentTypes" :key="pt.value" :value="pt.value">
{{ pt.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="formErrors(form, 'payment_type')" />
</Field>
<Field v-if="isMarketplaceChannel && !isCashierUser">
<div class="flex items-center gap-3">
<Switch id="is_affiliate" :model-value="form.is_affiliate" @update:model-value="form.is_affiliate = $event" />
<FieldLabel for="is_affiliate" class="cursor-pointer">Pesanan Afiliasi</FieldLabel>
</div>
<p class="text-xs text-muted-foreground">
Aktifkan jika pesanan ini melalui afiliasi. Biaya afiliasi akan dikenakan sesuai pengaturan marketplace.
</p>
</Field>
<Field v-if="!isCashierUser">
<FieldLabel for="customer">Pelanggan</FieldLabel>
<div class="flex gap-2">
<Select v-model="form.customer_id" class="flex-1">
<SelectTrigger id="customer" class="w-full">
<SelectValue placeholder="Pilih pelanggan" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="customer in customers" :key="customer.value" :value="String(customer.value)">
{{ customer.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button
v-if="canCreateCustomer"
type="button"
variant="outline"
size="icon"
class="shrink-0"
@click="emit('open-customer-form')"
>
<Plus class="size-4" />
</Button>
</div>
<FieldError :errors="formErrors(form, 'customer_id')" />
</Field>
<Field>
<FieldLabel for="marketing">Marketing</FieldLabel>
<Select v-model="form.marketing_id" :disabled="isMarketingUser">
<SelectTrigger id="marketing" class="w-full">
<SelectValue placeholder="Pilih marketing" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="marketing in marketings" :key="marketing.value" :value="String(marketing.value)">
{{ marketing.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<p v-if="isMarketingUser" class="text-xs text-muted-foreground">
Otomatis tercatat atas nama Anda.
</p>
<FieldError :errors="formErrors(form, 'marketing_id')" />
</Field>
</template>

View File

@ -0,0 +1,307 @@
import { computed, ref, watch, type MaybeRefOrGetter, toValue } from 'vue';
import { toast } from 'vue-sonner';
import { OrderChannel } from '@/constants/order-channel';
import { StockQuality } from '@/constants/stock-quality';
import { apiFetch } from '@/lib/api';
import { store as syncDraftRoute, resync_prices as resyncPricesRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/orders/draft_items';
import { STOCK_QUALITY_OPTIONS } from '@/types/order';
import type { OrderCartItem, OrderCatalogItem } from '@/types/order';
import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
export function useOrderPosCart(options: {
catalog: MaybeRefOrGetter<OrderCatalogItem[]>;
isCreateMode: MaybeRefOrGetter<boolean>;
priceType: MaybeRefOrGetter<string>;
isCashierUser: MaybeRefOrGetter<boolean>;
}) {
const search = ref('');
const selectedStockQuality = ref<'good' | 'reject' | 'retail'>(
toValue(options.isCashierUser) ? StockQuality.RETAIL : StockQuality.GOOD,
);
const cart = ref<OrderCartItem[]>([]);
function setCart(items: OrderCartItem[]) {
cart.value = items.map((item) => ({
...item,
stock_quality: item.stock_quality ?? StockQuality.GOOD,
}));
}
function loadDraftItems(items: OrderCartItem[]) {
if (!toValue(options.isCreateMode) || items.length === 0) {
return;
}
cart.value = items.map((item) => ({ ...item }));
}
watch(
() => toValue(options.priceType),
async (priceType) => {
if (!toValue(options.isCreateMode) || cart.value.length === 0) {
return;
}
try {
const { items } = await apiFetch<{ items: OrderCartItem[] }>(
resyncPricesRoute.url(),
{
method: 'PUT',
body: JSON.stringify({ price_type: priceType }),
},
);
cart.value = items.map((item) => ({ ...item }));
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui harga keranjang.');
}
},
);
function upsertCartItem(item: OrderCartItem) {
const index = cart.value.findIndex(
(cartItem) =>
cartItem.product_variant_id === item.product_variant_id
&& cartItem.stock_quality === item.stock_quality,
);
if (index === -1) {
cart.value.push({ ...item });
return;
}
cart.value[index] = { ...item };
}
function availableStockForVariant(variant: ProductVariantItem, stockQuality: string): number {
if (stockQuality === StockQuality.REJECT) {
return variant.reject_stock;
}
if (stockQuality === StockQuality.RETAIL) {
return variant.stock_retail ?? 0;
}
return variant.stock;
}
const filteredCatalog = computed(() => {
const keyword = search.value.trim().toLowerCase();
const catalog = toValue(options.catalog);
if (!keyword) {
return catalog;
}
return catalog.filter((product) =>
product.name.toLowerCase().includes(keyword)
|| product.variants.some((variant) => variant.name.toLowerCase().includes(keyword)),
);
});
const subtotal = computed(() =>
cart.value.reduce((sum, item) => sum + lineSubtotal(item), 0),
);
function getVariantPrice(variant: ProductVariantItem): ProductPriceItem | undefined {
return variant.prices.find((price) => price.type === toValue(options.priceType));
}
function getCartItem(variantId: number, stockQuality = selectedStockQuality.value): OrderCartItem | undefined {
return cart.value.find(
(item) =>
item.product_variant_id === variantId
&& item.stock_quality === stockQuality,
);
}
function stockQualityLabel(stockQuality: string): string {
if (stockQuality === StockQuality.REJECT) {
return 'Reject';
}
if (stockQuality === StockQuality.RETAIL) {
return 'Eceran';
}
return 'Bagus';
}
function lineSubtotal(item: OrderCartItem): number {
const quantity = Number(item.quantity) || 0;
return quantity * item.unit_price;
}
async function syncDraftItem(variantId: number, quantity: number, stockQuality: string = selectedStockQuality.value) {
const { item } = await apiFetch<{ item: OrderCartItem }>(syncDraftRoute.url(), {
method: 'POST',
body: JSON.stringify({
product_variant_id: variantId,
quantity,
price_type: toValue(options.priceType),
stock_quality: stockQuality,
}),
});
upsertCartItem(item);
}
async function addToCart(product: OrderCatalogItem, variant: ProductVariantItem) {
const price = getVariantPrice(variant);
if (!price) {
toast.error('Harga untuk tipe harga ini belum diatur.');
return;
}
const stockQuality = selectedStockQuality.value;
const availableStock = availableStockForVariant(variant, stockQuality);
if (availableStock < 1) {
toast.error(`Stok ${stockQualityLabel(stockQuality)} tidak tersedia.`);
return;
}
const existing = cart.value.find(
(item) =>
item.product_variant_id === variant.id
&& item.stock_quality === stockQuality,
);
const nextQty = existing ? (Number(existing.quantity) || 0) + 1 : 1;
if (nextQty > availableStock) {
toast.error(`Stok ${stockQualityLabel(stockQuality)} tidak mencukupi.`);
return;
}
if (toValue(options.isCreateMode)) {
try {
await syncDraftItem(variant.id, nextQty, stockQuality);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.');
}
return;
}
if (existing) {
existing.quantity = String(nextQty);
return;
}
cart.value.push({
product_variant_id: variant.id,
product_name: product.name,
variant_name: variant.name,
stock_quality: stockQuality,
stock_quality_label: STOCK_QUALITY_OPTIONS.find((option) => option.value === stockQuality)?.label,
quantity: '1',
unit_price: Number(price.price_input ?? price.price),
images: variant.images ?? [],
});
}
async function removeFromCart(index: number) {
const item = cart.value[index];
if (toValue(options.isCreateMode)) {
try {
await apiFetch(
destroyDraftRoute.url(item.product_variant_id, {
query: { stock_quality: item.stock_quality },
}),
{
method: 'DELETE',
},
);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menghapus item dari keranjang.');
return;
}
}
cart.value.splice(index, 1);
}
async function adjustQuantity(index: number, delta: number) {
const item = cart.value[index];
const nextQty = (Number(item.quantity) || 0) + delta;
if (nextQty < 1) {
await removeFromCart(index);
return;
}
if (toValue(options.isCreateMode)) {
try {
await syncDraftItem(item.product_variant_id, nextQty, item.stock_quality);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
return;
}
item.quantity = String(nextQty);
}
async function syncCartItemQuantity(index: number) {
const item = cart.value[index];
const nextQty = Number(item.quantity) || 0;
if (nextQty < 1) {
await removeFromCart(index);
return;
}
if (!toValue(options.isCreateMode)) {
return;
}
try {
await syncDraftItem(item.product_variant_id, nextQty, item.stock_quality);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
}
async function decreaseVariantQty(variantId: number) {
const index = cart.value.findIndex(
(item) =>
item.product_variant_id === variantId
&& item.stock_quality === selectedStockQuality.value,
);
if (index !== -1) {
await adjustQuantity(index, -1);
}
}
return {
search,
selectedStockQuality,
cart,
filteredCatalog,
subtotal,
setCart,
loadDraftItems,
getVariantPrice,
getCartItem,
lineSubtotal,
stockQualityLabel,
addToCart,
removeFromCart,
adjustQuantity,
syncCartItemQuantity,
decreaseVariantQty,
};
}

View File

@ -1,12 +1,9 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import DataTableActions from './data-table-actions.vue';
import { formatRupiah } from '@/lib/rupiah';
import { OrderStatus } from '@/constants/order-status';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyDescription,
@ -21,6 +18,9 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { usePaginationSummary } from '@/composables/usePaginationSummary';
import { orderStatusBadgeVariant } from '@/constants/order-status';
import { groupedTableRowNumber } from '@/lib/grouped-table';
import type {
DataTablePagination,
DataTablePaginationLink,
@ -41,39 +41,10 @@ const emit = defineEmits<{
}>();
const showingCount = computed(() => props.orders.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
return null;
}
const { total } = props.pagination;
if (total === 0) {
return 'Menampilkan 0 pesanan';
}
return `Menampilkan ${showingCount.value} pesanan dari ${total}`;
});
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'pesanan');
function rowNumber(index: number): number {
return (props.firstItem ?? 1) + index;
}
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (status === OrderStatus.COMPLETED) {
return 'default';
}
if (status === OrderStatus.CANCELLED) {
return 'destructive';
}
if (status === OrderStatus.PROCESSING) {
return 'secondary';
}
return 'outline';
return groupedTableRowNumber(props.firstItem, index);
}
</script>
@ -94,7 +65,7 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
<h3 class="font-medium leading-tight">
{{ order.order_number }}
</h3>
<Badge :variant="statusVariant(order.status)">
<Badge :variant="orderStatusBadgeVariant(order.status)">
{{ order.status_label }}
</Badge>
<Badge variant="secondary">
@ -131,10 +102,10 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
<span>Total <strong class="text-primary">{{ order.total_amount_formatted
}}</strong></span>
<span v-if="order.marketplace_settings_snapshot?.total_fee_amount" class="text-destructive">
Potongan Marketplace <strong class="text-destructive">-{{ formatRupiah(order.marketplace_settings_snapshot.total_fee_amount) }}</strong>
Potongan Marketplace <strong class="text-destructive">-{{ order.marketplace_settings_snapshot.total_fee_amount_formatted }}</strong>
</span>
<span v-if="order.marketplace_settings_snapshot?.net_amount">
Total Bersih <strong class="text-green-600">{{ formatRupiah(order.marketplace_settings_snapshot.net_amount) }}</strong>
Total Bersih <strong class="text-green-600">{{ order.marketplace_settings_snapshot.net_amount_formatted }}</strong>
</span>
</div>
<p v-if="order.notes" class="text-muted-foreground text-sm">
@ -197,21 +168,10 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
</Empty>
</div>
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-sm">
{{ paginationSummary }}
</p>
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
<Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
:disabled="!link.url || link.active" as-child>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>
<span v-else v-html="link.label" />
</Button>
</div>
</div>
<GroupedTableFooter
:summary="paginationSummary"
:pagination="pagination"
:pagination-links="paginationLinks"
/>
</div>
</template>

View File

@ -1,16 +1,18 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { Check, Send, X } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { RowDeleteAction, RowDetailAction, RowEditAction, RowStatusAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import OrderPrintButton from '@/components/order/OrderPrintButton.vue';
import { Button } from '@/components/ui/button';
import { useCan } from '@/composables/useCan';
import { OrderStatus } from '@/constants/order-status';
import {
orderStatusActionIcon,
orderStatusTransitionConfirmDescription,
} from '@/constants/order-status';
import { show, edit, destroy, transition_status } from '@/routes/admin/manage/orders';
import type { OrderListItem, OrderStatusAction } from '@/types/order';
import OrderPrintButton from '../form/OrderPrintButton.vue';
const props = defineProps<{
order: OrderListItem;
@ -32,15 +34,7 @@ function canPerformAction(action: OrderStatusAction): boolean {
}
function statusConfirmDescription(action: OrderStatusAction): string {
if (action.status === OrderStatus.PROCESSING) {
return `Pesanan ${props.order.order_number} akan dikirim dan diproses.`;
}
if (action.status === OrderStatus.COMPLETED) {
return `Pesanan ${props.order.order_number} akan ditandai selesai.`;
}
return `Pesanan ${props.order.order_number} akan dibatalkan. Stok produk akan dikembalikan.`;
return orderStatusTransitionConfirmDescription(action.status, props.order.order_number);
}
function openStatusConfirm(action: OrderStatusAction) {
@ -73,18 +67,6 @@ function transitionStatus() {
},
});
}
function actionIcon(status: string) {
if (status === OrderStatus.PROCESSING) {
return Send;
}
if (status === OrderStatus.COMPLETED) {
return Check;
}
return X;
}
</script>
<template>
@ -92,13 +74,13 @@ function actionIcon(status: string) {
<OrderPrintButton :order="order" />
<template v-for="action in availableActions" :key="action.status">
<RowStatusAction v-if="action.icon_only && canPerformAction(action)" :icon="actionIcon(action.status)"
<RowStatusAction v-if="action.icon_only && canPerformAction(action)" :icon="orderStatusActionIcon(action.status)"
:label="action.label" :destructive="action.destructive" @click="openStatusConfirm(action)" />
<Button v-else-if="canPerformAction(action)" size="sm" :variant="action.destructive ? 'outline' : 'default'"
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
@click="openStatusConfirm(action)">
<component :is="actionIcon(action.status)" class="size-3.5" />
<component :is="orderStatusActionIcon(action.status)" class="size-3.5" />
{{ action.label }}
</Button>
</template>

View File

@ -0,0 +1,55 @@
<script setup lang="ts">
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { formatRupiah } from '@/lib/rupiah';
import type { PurchaseCartItem } from '@/types/purchase';
defineProps<{
cart: PurchaseCartItem[];
lineSubtotal: (item: PurchaseCartItem) => number;
total: number;
}>();
const open = defineModel<boolean>('open', { required: true });
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Detail Keranjang</DialogTitle>
</DialogHeader>
<div class="scrollbar-thin max-h-96 space-y-3 overflow-y-auto overscroll-y-contain">
<div
v-for="item in cart"
:key="`detail-${item.raw_material_price_id}`"
class="rounded-lg border p-3"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ item.raw_material_name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ item.variant }}</p>
</div>
<span class="shrink-0 text-xs font-medium tabular-nums">
{{ item.quantity }} {{ item.unit_abbreviation }}
</span>
</div>
<div class="mt-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>@ Rp {{ formatRupiah(item.unit_price) }}</span>
<span class="font-medium text-foreground">Rp {{ formatRupiah(lineSubtotal(item)) }}</span>
</div>
</div>
</div>
<div class="border-t pt-3">
<div class="flex items-center justify-between text-sm font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(total) }}</span>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,110 @@
<script setup lang="ts">
import { Minus, Plus, Trash2 } from '@lucide/vue';
import { DecimalInput } from '@/components/form/decimal-input';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Field,
FieldLabel,
} from '@/components/ui/field';
import { formatRupiah } from '@/lib/rupiah';
import type { PurchaseCartItem } from '@/types/purchase';
defineProps<{
cart: PurchaseCartItem[];
lineSubtotal: (item: PurchaseCartItem) => number;
}>();
const emit = defineEmits<{
remove: [index: number];
'adjust-quantity': [index: number, delta: number];
'sync-quantity': [index: number];
}>();
</script>
<template>
<div v-if="cart.length === 0" class="rounded-lg border border-dashed text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
<EmptyDescription>
Pilih varian bahan baku di sebelah kiri untuk menambahkan ke keranjang.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<div
v-for="(item, index) in cart"
:key="item.raw_material_price_id"
class="rounded-lg border p-3"
>
<div class="flex gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ item.raw_material_name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-7 shrink-0"
@click="emit('remove', index)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
<Field>
<FieldLabel class="text-xs">Jumlah ({{ item.unit_abbreviation }})</FieldLabel>
<div class="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
class="size-8 shrink-0"
@click="emit('adjust-quantity', index, -1)"
>
<Minus class="size-3.5" />
</Button>
<DecimalInput
v-model="item.quantity"
class="h-8 text-center"
@change="emit('sync-quantity', index)"
/>
<Button
type="button"
variant="outline"
size="icon"
class="size-8 shrink-0"
@click="emit('adjust-quantity', index, 1)"
>
<Plus class="size-3.5" />
</Button>
</div>
</Field>
<p class="text-xs text-muted-foreground">
Harga satuan Rp {{ formatRupiah(item.unit_price) }}
</p>
<p class="text-right text-sm font-medium">
Rp {{ formatRupiah(lineSubtotal(item)) }}
</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,125 @@
<script setup lang="ts">
import { Minus, Plus, Search } from '@lucide/vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import { Input } from '@/components/ui/input';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import type { PurchaseCartItem, PurchaseCatalogItem } from '@/types/purchase';
import type { PurchaseCatalogPrice } from './usePurchasePosCart';
defineProps<{
filteredCatalog: PurchaseCatalogItem[];
getCartItem: (priceId: number) => PurchaseCartItem | undefined;
}>();
const search = defineModel<string>('search', { required: true });
const emit = defineEmits<{
'add-to-cart': [rawMaterial: PurchaseCatalogItem, price: PurchaseCatalogPrice];
'decrease-qty': [priceId: number];
}>();
</script>
<template>
<Card class="min-w-0">
<CardHeader class="pb-3">
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="search" placeholder="Cari bahan baku..." class="pl-9" />
</div>
<div v-if="filteredCatalog.length === 0" class="py-12 text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada bahan baku ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan bahan baku yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3">
<PosCatalogCard
v-for="rawMaterial in filteredCatalog"
:key="rawMaterial.id"
:title="rawMaterial.name"
:cover-image="getFirstCoverImage(rawMaterial.prices)"
>
<template #header-extra>
<Badge variant="secondary" class="mt-1.5">
{{ rawMaterial.unit_label }}
</Badge>
</template>
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div
v-for="price in rawMaterial.prices"
:key="price.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[
'cursor-pointer hover:bg-muted/30',
getCartItem(price.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : '',
]"
@click="!getCartItem(price.id) && emit('add-to-cart', rawMaterial, price)"
>
<PosCatalogVariantThumb :items="price.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ price.variant }}
</p>
<p class="text-xs tabular-nums text-muted-foreground">
{{ price.price_formatted }}
</p>
</div>
<div v-if="getCartItem(price.id)" class="flex shrink-0 items-center gap-1.5">
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('decrease-qty', price.id)"
>
<Minus class="size-3.5" />
</Button>
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
{{ getCartItem(price.id)!.quantity }}
</span>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('add-to-cart', rawMaterial, price)"
>
<Plus class="size-3.5" />
</Button>
</div>
<Button
v-else
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
@click.stop="emit('add-to-cart', rawMaterial, price)"
>
<Plus class="size-3.5" />
</Button>
</div>
</PosCatalogCard>
</div>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,82 @@
<script setup lang="ts">
import { Save } from '@lucide/vue';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import {
Field,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors, type FormWithErrors } from '@/lib/form';
import { formatRupiah } from '@/lib/rupiah';
import type { MediaUploadState } from '@/types/media';
defineProps<{
form: FormWithErrors & {
discount: string;
shipping_cost: string;
notes: string;
processing?: boolean;
};
subtotal: number;
total: number;
cartEmpty: boolean;
submitLabel: string;
}>();
const photoState = defineModel<MediaUploadState>('photoState', { required: true });
</script>
<template>
<Separator />
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Subtotal</span>
<span class="font-medium">Rp {{ formatRupiah(subtotal) }}</span>
</div>
<Field>
<FieldLabel for="discount">Diskon</FieldLabel>
<RupiahInput id="discount" v-model="form.discount" placeholder="0" />
<FieldError :errors="formErrors(form, 'discount')" />
</Field>
<Field>
<FieldLabel for="shipping_cost">Ongkir</FieldLabel>
<RupiahInput id="shipping_cost" v-model="form.shipping_cost" placeholder="0" />
<FieldError :errors="formErrors(form, 'shipping_cost')" />
</Field>
<div class="flex justify-between text-base font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(total) }}</span>
</div>
</div>
<Field>
<FieldLabel for="notes">Keterangan</FieldLabel>
<Textarea
id="notes"
v-model="form.notes"
placeholder="Contoh: Belanja batch 2"
rows="2"
:maxlength="FIELD_LIMITS.notes"
/>
<FieldError :errors="formErrors(form, 'notes')" />
</Field>
<MediaDropzone
id="purchase-photos"
v-model="photoState"
label="Bukti Transaksi"
:max-files="1"
:errors="formErrors(form, 'photos')"
/>
<Button type="submit" class="w-full" :disabled="form.processing || cartEmpty">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</template>

View File

@ -1,55 +1,24 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Minus, Plus, Save, Search, ShoppingCart, Trash2 } from '@lucide/vue';
import { ShoppingCart } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { DecimalInput } from '@/components/form/decimal-input';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
import { store as syncDraftRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/purchases/draft_items';
import { parseRupiah } from '@/lib/rupiah';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { MediaItem, MediaUploadState } from '@/types/media';
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.vue';
import PurchasePosCartDetailDialog from './PurchasePosCartDetailDialog.vue';
import PurchasePosCartSummaryItems from './PurchasePosCartSummaryItems.vue';
import PurchasePosCatalogPanel from './PurchasePosCatalogPanel.vue';
import PurchasePosCheckoutSection from './PurchasePosCheckoutSection.vue';
import PurchasePosMetadataFields from './PurchasePosMetadataFields.vue';
import { usePurchasePosCart } from './usePurchasePosCart';
const props = defineProps<{
suppliers: SelectOption[];
catalog: PurchaseCatalogItem[];
@ -68,8 +37,6 @@ const props = defineProps<{
}>();
const isCreateMode = computed(() => props.method === 'post');
const search = ref('');
const cart = ref<PurchaseCartItem[]>([]);
const cartDetailOpen = ref(false);
const photoState = ref<MediaUploadState>(createMediaUploadState());
@ -80,6 +47,29 @@ const form = useForm({
notes: '',
});
const {
search,
cart,
filteredCatalog,
subtotal,
setCart,
loadDraftItems,
getCartItem,
lineSubtotal,
addToCart,
removeFromCart,
adjustQuantity,
syncCartItemQuantity,
decreasePriceQty,
} = usePurchasePosCart({
catalog: () => props.catalog,
isCreateMode: () => isCreateMode.value,
});
const discountAmount = computed(() => Number(parseRupiah(form.discount)) || 0);
const shippingAmount = computed(() => Number(parseRupiah(form.shipping_cost)) || 0);
const total = computed(() => Math.max(subtotal.value - discountAmount.value + shippingAmount.value, 0));
function populateForm() {
if (!props.initialData) {
return;
@ -92,195 +82,11 @@ function populateForm() {
photoState.value = createMediaUploadState(
props.initialData.photos ? [props.initialData.photos] : [],
);
cart.value = props.initialData.items.map((item) => ({ ...item }));
setCart(props.initialData.items);
}
watch(
() => props.initialData,
() => {
populateForm();
},
{ immediate: true },
);
function populateDraftItems() {
if (!isCreateMode.value || !props.draftItems?.length) {
return;
}
cart.value = props.draftItems.map((item) => ({ ...item }));
}
populateDraftItems();
function upsertCartItem(item: PurchaseCartItem) {
const index = cart.value.findIndex(
(cartItem) => cartItem.raw_material_price_id === item.raw_material_price_id,
);
if (index === -1) {
cart.value.push({ ...item });
return;
}
cart.value[index] = { ...item };
}
type CatalogPrice = {
id: number;
variant: string;
price_input: string;
price_formatted: string;
images?: PurchaseCatalogItem['prices'][number]['images'];
};
const filteredCatalog = computed(() => {
const keyword = search.value.trim().toLowerCase();
if (!keyword) {
return props.catalog;
}
return props.catalog.filter((rawMaterial) =>
rawMaterial.name.toLowerCase().includes(keyword)
|| rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)),
);
});
const subtotal = computed(() =>
cart.value.reduce((sum, item) => sum + lineSubtotal(item), 0),
);
const discountAmount = computed(() => Number(parseRupiah(form.discount)) || 0);
const shippingAmount = computed(() => Number(parseRupiah(form.shipping_cost)) || 0);
const total = computed(() => Math.max(subtotal.value - discountAmount.value + shippingAmount.value, 0));
function getCartItem(priceId: number): PurchaseCartItem | undefined {
return cart.value.find((item) => item.raw_material_price_id === priceId);
}
async function decreasePriceQty(priceId: number) {
const index = cart.value.findIndex((item) => item.raw_material_price_id === priceId);
if (index !== -1) {
await adjustQuantity(index, -1);
}
}
function lineSubtotal(item: PurchaseCartItem): number {
const quantity = Number(item.quantity) || 0;
return Math.round(quantity * item.unit_price);
}
async function syncDraftItem(priceId: number, quantity: number) {
const { item } = await apiFetch<{ item: PurchaseCartItem }>(syncDraftRoute.url(), {
method: 'POST',
body: JSON.stringify({
raw_material_price_id: priceId,
quantity,
}),
});
upsertCartItem(item);
}
async function addToCart(rawMaterial: PurchaseCatalogItem, price: CatalogPrice) {
const existing = cart.value.find(
(item) => item.raw_material_price_id === price.id,
);
const nextQty = existing ? (Number(existing.quantity) || 0) + 1 : 1;
if (isCreateMode.value) {
try {
await syncDraftItem(price.id, nextQty);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.');
}
return;
}
if (existing) {
existing.quantity = String(nextQty);
return;
}
cart.value.push({
raw_material_price_id: price.id,
raw_material_name: rawMaterial.name,
variant: price.variant,
unit_abbreviation: rawMaterial.unit_abbreviation,
quantity: '1',
unit_price: Number(price.price_input),
images: price.images ?? [],
});
}
async function removeFromCart(index: number) {
const item = cart.value[index];
if (isCreateMode.value) {
try {
await apiFetch(destroyDraftRoute.url(item.raw_material_price_id), {
method: 'DELETE',
});
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menghapus item dari keranjang.');
return;
}
}
cart.value.splice(index, 1);
}
async function adjustQuantity(index: number, delta: number) {
const item = cart.value[index];
const nextQty = (Number(item.quantity) || 0) + delta;
if (nextQty <= 0) {
await removeFromCart(index);
return;
}
if (isCreateMode.value) {
try {
await syncDraftItem(item.raw_material_price_id, nextQty);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
return;
}
item.quantity = String(nextQty);
}
async function syncCartItemQuantity(index: number) {
const item = cart.value[index];
const nextQty = Number(item.quantity) || 0;
if (nextQty <= 0) {
await removeFromCart(index);
return;
}
if (!isCreateMode.value) {
return;
}
try {
await syncDraftItem(item.raw_material_price_id, nextQty);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
}
watch(() => props.initialData, populateForm, { immediate: true });
loadDraftItems(props.draftItems ?? []);
function buildFormData(): FormData {
const formData = new FormData();
@ -323,7 +129,7 @@ function submit() {
form.transform(() => payload).post(props.submitUrl, {
forceFormData: true,
onError: (errors: any) => {
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
}
@ -334,75 +140,13 @@ function submit() {
<template>
<div class="grid gap-4 xl:grid-cols-[1fr_380px]">
<Card class="min-w-0">
<CardHeader class="pb-3">
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="search" placeholder="Cari bahan baku..." class="pl-9" />
</div>
<div v-if="filteredCatalog.length === 0" class="py-12 text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada bahan baku ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan bahan baku yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3">
<PosCatalogCard v-for="rawMaterial in filteredCatalog" :key="rawMaterial.id"
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)">
<template #header-extra>
<Badge variant="secondary" class="mt-1.5">
{{ rawMaterial.unit_label }}
</Badge>
</template>
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div v-for="price in rawMaterial.prices" :key="price.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[
'cursor-pointer hover:bg-muted/30',
getCartItem(price.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : ''
]" @click="!getCartItem(price.id) && addToCart(rawMaterial, price)">
<PosCatalogVariantThumb :items="price.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ price.variant }}
</p>
<p class="text-xs tabular-nums text-muted-foreground">
{{ price.price_formatted }}
</p>
</div>
<div v-if="getCartItem(price.id)" class="flex items-center gap-1.5 shrink-0">
<Button type="button" variant="outline" size="icon-sm"
@click.stop="decreasePriceQty(price.id)">
<Minus class="size-3.5" />
</Button>
<span class="text-xs font-semibold min-w-[1.25rem] text-center tabular-nums">
{{ getCartItem(price.id)!.quantity }}
</span>
<Button type="button" variant="outline" size="icon-sm"
@click.stop="addToCart(rawMaterial, price)">
<Plus class="size-3.5" />
</Button>
</div>
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
@click.stop="addToCart(rawMaterial, price)">
<Plus class="size-3.5" />
</Button>
</div>
</PosCatalogCard>
</div>
</CardContent>
</Card>
<PurchasePosCatalogPanel
v-model:search="search"
:filtered-catalog="filteredCatalog"
:get-cart-item="getCartItem"
@add-to-cart="addToCart"
@decrease-qty="decreasePriceQty"
/>
<Card class="h-fit xl:sticky xl:top-4">
<CardHeader class="pb-3">
@ -411,9 +155,12 @@ function submit() {
<ShoppingCart class="size-4" />
Ringkasan Belanja
</span>
<button v-if="cart.length > 0" type="button"
<button
v-if="cart.length > 0"
type="button"
class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80"
@click="cartDetailOpen = true">
@click="cartDetailOpen = true"
>
Lihat Detail
</button>
</CardTitle>
@ -422,123 +169,24 @@ function submit() {
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="supplier" required>Supplier</FieldLabel>
<Select v-model="form.supplier_id">
<SelectTrigger id="supplier" class="w-full">
<SelectValue placeholder="Pilih supplier" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="supplier in suppliers" :key="supplier.value"
:value="String(supplier.value)">
{{ supplier.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="formErrors(form, 'supplier_id')" />
</Field>
<PurchasePosMetadataFields :form="form" :suppliers="suppliers" />
<div v-if="cart.length === 0"
class="rounded-lg border border-dashed text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
<EmptyDescription>
Pilih varian bahan baku di sebelah kiri untuk menambahkan ke keranjang.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<PurchasePosCartSummaryItems
:cart="cart"
:line-subtotal="lineSubtotal"
@remove="removeFromCart"
@adjust-quantity="adjustQuantity"
@sync-quantity="syncCartItemQuantity"
/>
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<div v-for="(item, index) in cart" :key="item.raw_material_price_id"
class="rounded-lg border p-3">
<div class="flex gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ item.raw_material_name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant }}
</p>
</div>
<Button type="button" variant="ghost" size="icon"
class="text-destructive hover:text-destructive size-7 shrink-0"
@click="removeFromCart(index)">
<Trash2 class="size-3.5" />
</Button>
</div>
<Field>
<FieldLabel class="text-xs">Jumlah ({{ item.unit_abbreviation }})
</FieldLabel>
<div class="flex items-center gap-1">
<Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, -1)">
<Minus class="size-3.5" />
</Button>
<DecimalInput v-model="item.quantity" class="h-8 text-center"
@change="syncCartItemQuantity(index)" />
<Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, 1)">
<Plus class="size-3.5" />
</Button>
</div>
</Field>
<p class="text-xs text-muted-foreground">
Harga satuan Rp {{ formatRupiah(item.unit_price) }}
</p>
<p class="text-right text-sm font-medium">
Rp {{ formatRupiah(lineSubtotal(item)) }}
</p>
</div>
</div>
</div>
</div>
<Separator />
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Subtotal</span>
<span class="font-medium">Rp {{ formatRupiah(subtotal) }}</span>
</div>
<Field>
<FieldLabel for="discount">Diskon</FieldLabel>
<RupiahInput id="discount" v-model="form.discount" placeholder="0" />
<FieldError :errors="formErrors(form, 'discount')" />
</Field>
<Field>
<FieldLabel for="shipping_cost">Ongkir</FieldLabel>
<RupiahInput id="shipping_cost" v-model="form.shipping_cost" placeholder="0" />
<FieldError :errors="formErrors(form, 'shipping_cost')" />
</Field>
<div class="flex justify-between text-base font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(total) }}</span>
</div>
</div>
<Field>
<FieldLabel for="notes">Keterangan</FieldLabel>
<Textarea id="notes" v-model="form.notes" placeholder="Contoh: Belanja batch 2" rows="2"
:maxlength="FIELD_LIMITS.notes" />
<FieldError :errors="formErrors(form, 'notes')" />
</Field>
<MediaDropzone id="purchase-photos" v-model="photoState" label="Bukti Transaksi"
:max-files="1" :errors="formErrors(form, 'photos')" />
<Button type="submit" class="w-full" :disabled="form.processing || cart.length === 0">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
<PurchasePosCheckoutSection
v-model:photo-state="photoState"
:form="form"
:subtotal="subtotal"
:total="total"
:cart-empty="cart.length === 0"
:submit-label="submitLabel"
/>
</FieldSet>
</FieldGroup>
</form>
@ -546,34 +194,10 @@ function submit() {
</Card>
</div>
<Dialog v-model:open="cartDetailOpen">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Detail Keranjang</DialogTitle>
</DialogHeader>
<div class="max-h-96 space-y-3 overflow-y-auto overscroll-y-contain scrollbar-thin">
<div v-for="item in cart" :key="`detail-${item.raw_material_price_id}`" class="rounded-lg border p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ item.raw_material_name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ item.variant }}</p>
</div>
<span class="shrink-0 text-xs font-medium tabular-nums">
{{ item.quantity }} {{ item.unit_abbreviation }}
</span>
</div>
<div class="mt-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>@ Rp {{ formatRupiah(item.unit_price) }}</span>
<span class="font-medium text-foreground">Rp {{ formatRupiah(lineSubtotal(item)) }}</span>
</div>
</div>
</div>
<div class="border-t pt-3">
<div class="flex items-center justify-between text-sm font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(total) }}</span>
</div>
</div>
</DialogContent>
</Dialog>
<PurchasePosCartDetailDialog
v-model:open="cartDetailOpen"
:cart="cart"
:line-subtotal="lineSubtotal"
:total="total"
/>
</template>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import {
Field,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { formErrors, type FormWithErrors } from '@/lib/form';
import type { SelectOption } from '@/types/purchase';
defineProps<{
form: FormWithErrors & { supplier_id: string };
suppliers: SelectOption[];
}>();
</script>
<template>
<Field>
<FieldLabel for="supplier" required>Supplier</FieldLabel>
<Select v-model="form.supplier_id">
<SelectTrigger id="supplier" class="w-full">
<SelectValue placeholder="Pilih supplier" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="supplier in suppliers"
:key="supplier.value"
:value="String(supplier.value)"
>
{{ supplier.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="formErrors(form, 'supplier_id')" />
</Field>
</template>

View File

@ -0,0 +1,206 @@
import { computed, ref, type MaybeRefOrGetter, toValue } from 'vue';
import { toast } from 'vue-sonner';
import { apiFetch } from '@/lib/api';
import { store as syncDraftRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/purchases/draft_items';
import type { PurchaseCartItem, PurchaseCatalogItem } from '@/types/purchase';
export type PurchaseCatalogPrice = {
id: number;
variant: string;
price_input: string;
price_formatted: string;
images?: PurchaseCatalogItem['prices'][number]['images'];
};
export function usePurchasePosCart(options: {
catalog: MaybeRefOrGetter<PurchaseCatalogItem[]>;
isCreateMode: MaybeRefOrGetter<boolean>;
}) {
const search = ref('');
const cart = ref<PurchaseCartItem[]>([]);
function setCart(items: PurchaseCartItem[]) {
cart.value = items.map((item) => ({ ...item }));
}
function loadDraftItems(items: PurchaseCartItem[]) {
if (!toValue(options.isCreateMode) || items.length === 0) {
return;
}
cart.value = items.map((item) => ({ ...item }));
}
function upsertCartItem(item: PurchaseCartItem) {
const index = cart.value.findIndex(
(cartItem) => cartItem.raw_material_price_id === item.raw_material_price_id,
);
if (index === -1) {
cart.value.push({ ...item });
return;
}
cart.value[index] = { ...item };
}
const filteredCatalog = computed(() => {
const keyword = search.value.trim().toLowerCase();
const catalog = toValue(options.catalog);
if (!keyword) {
return catalog;
}
return catalog.filter((rawMaterial) =>
rawMaterial.name.toLowerCase().includes(keyword)
|| rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)),
);
});
const subtotal = computed(() =>
cart.value.reduce((sum, item) => sum + lineSubtotal(item), 0),
);
function getCartItem(priceId: number): PurchaseCartItem | undefined {
return cart.value.find((item) => item.raw_material_price_id === priceId);
}
function lineSubtotal(item: PurchaseCartItem): number {
const quantity = Number(item.quantity) || 0;
return Math.round(quantity * item.unit_price);
}
async function syncDraftItem(priceId: number, quantity: number) {
const { item } = await apiFetch<{ item: PurchaseCartItem }>(syncDraftRoute.url(), {
method: 'POST',
body: JSON.stringify({
raw_material_price_id: priceId,
quantity,
}),
});
upsertCartItem(item);
}
async function addToCart(rawMaterial: PurchaseCatalogItem, price: PurchaseCatalogPrice) {
const existing = cart.value.find(
(item) => item.raw_material_price_id === price.id,
);
const nextQty = existing ? (Number(existing.quantity) || 0) + 1 : 1;
if (toValue(options.isCreateMode)) {
try {
await syncDraftItem(price.id, nextQty);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.');
}
return;
}
if (existing) {
existing.quantity = String(nextQty);
return;
}
cart.value.push({
raw_material_price_id: price.id,
raw_material_name: rawMaterial.name,
variant: price.variant,
unit_abbreviation: rawMaterial.unit_abbreviation,
quantity: '1',
unit_price: Number(price.price_input),
images: price.images ?? [],
});
}
async function removeFromCart(index: number) {
const item = cart.value[index];
if (toValue(options.isCreateMode)) {
try {
await apiFetch(destroyDraftRoute.url(item.raw_material_price_id), {
method: 'DELETE',
});
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menghapus item dari keranjang.');
return;
}
}
cart.value.splice(index, 1);
}
async function adjustQuantity(index: number, delta: number) {
const item = cart.value[index];
const nextQty = (Number(item.quantity) || 0) + delta;
if (nextQty <= 0) {
await removeFromCart(index);
return;
}
if (toValue(options.isCreateMode)) {
try {
await syncDraftItem(item.raw_material_price_id, nextQty);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
return;
}
item.quantity = String(nextQty);
}
async function syncCartItemQuantity(index: number) {
const item = cart.value[index];
const nextQty = Number(item.quantity) || 0;
if (nextQty <= 0) {
await removeFromCart(index);
return;
}
if (!toValue(options.isCreateMode)) {
return;
}
try {
await syncDraftItem(item.raw_material_price_id, nextQty);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
}
async function decreasePriceQty(priceId: number) {
const index = cart.value.findIndex((item) => item.raw_material_price_id === priceId);
if (index !== -1) {
await adjustQuantity(index, -1);
}
}
return {
search,
cart,
filteredCatalog,
subtotal,
setCart,
loadDraftItems,
getCartItem,
lineSubtotal,
addToCart,
removeFromCart,
adjustQuantity,
syncCartItemQuantity,
decreasePriceQty,
};
}

View File

@ -1,11 +1,10 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import DataTableActions from './data-table-actions.vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyDescription,
@ -20,11 +19,13 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { usePaginationSummary } from '@/composables/usePaginationSummary';
import { groupedTableRowNumber } from '@/lib/grouped-table';
import type {
DataTablePagination,
DataTablePaginationLink,
} from '@/types/data-table';
import type { PurchaseListItem } from '@/types/purchase';
import type { PurchaseItemDetail, PurchaseListItem } from '@/types/purchase';
const props = defineProps<{
purchases: PurchaseListItem[];
@ -40,33 +41,20 @@ const emit = defineEmits<{
}>();
const showingCount = computed(() => props.purchases.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
return null;
}
const { total } = props.pagination;
if (total === 0) {
return 'Menampilkan 0 belanja';
}
return `Menampilkan ${showingCount.value} belanja dari ${total}`;
});
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'belanja');
function rowNumber(index: number): number {
return (props.firstItem ?? 1) + index;
return groupedTableRowNumber(props.firstItem, index);
}
interface GroupedPurchaseItems {
rawMaterialId: number;
rawMaterialName: string;
unitLabel?: string;
items: any[];
items: PurchaseItemDetail[];
}
function getGroupedItems(items: any[]): GroupedPurchaseItems[] {
function getGroupedItems(items: PurchaseItemDetail[]): GroupedPurchaseItems[] {
const groups: Record<number, GroupedPurchaseItems> = {};
items.forEach((item) => {
@ -191,21 +179,10 @@ function getGroupedItems(items: any[]): GroupedPurchaseItems[] {
</Empty>
</div>
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-sm">
{{ paginationSummary }}
</p>
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
<Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
:disabled="!link.url || link.active" as-child>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>
<span v-else v-html="link.label" />
</Button>
</div>
</div>
<GroupedTableFooter
:summary="paginationSummary"
:pagination="pagination"
:pagination-links="paginationLinks"
/>
</div>
</template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { ref, watch } from 'vue';
import { ref, watch, computed } from 'vue';
import { toast } from 'vue-sonner';
import { RowApproveAction } from '@/components/button';
import { RupiahInput } from '@/components/form/rupiah-input';
@ -120,6 +120,12 @@ function setSharedPrice(type: string, value: string) {
}));
}
const resultPricesErrors = computed(() => {
const message = (verifyForm.errors as Record<string, string | undefined>).result_prices;
return message ? [message] : [];
});
function buildResultPricesPayload() {
return verifyForm.variant_prices.map((row) => ({
product_variant_id: row.product_variant_id,
@ -226,7 +232,7 @@ function submitVerify() {
</div>
</div>
<FieldError :errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []" />
<FieldError :errors="resultPricesErrors" />
<Field>
<FieldLabel for="stock-verification-note">Catatan Verifikasi</FieldLabel>

View File

@ -1,134 +1,45 @@
<script setup lang="ts">
import BackButton from '@/components/button/BackButton.vue';
import { Head, router } from '@inertiajs/vue3';
import { Loader2, Send } from '@lucide/vue';
import { ref } from 'vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DatePicker } from '@/components/ui/date-picker';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CatalogProduct } from '@/types/stok-opname';
import { Head, router } from '@inertiajs/vue3';
import { useDebounceFn } from '@vueuse/core';
import { CheckCircle, Loader2, Send } from '@lucide/vue';
import { computed, onMounted, ref, watch } from 'vue';
import { index, auto_save, submit } from '@/routes/admin/manage/stok-opnames';
import { index, submit } from '@/routes/admin/manage/stok-opnames';
import StokOpnameAutoSaveStatus from './form/StokOpnameAutoSaveStatus.vue';
import StokOpnameInfoSection from './form/StokOpnameInfoSection.vue';
import StokOpnameProductTable from './form/StokOpnameProductTable.vue';
import { useStokOpnameForm } from './form/useStokOpnameForm';
const props = defineProps<{
catalog: CatalogProduct[];
}>();
const opnameDate = ref(new Date().toISOString().split('T')[0]);
const notes = ref('');
const stokOpnameId = ref<number | null>(null);
const saving = ref(false);
const lastSaved = ref<string | null>(null);
const submitting = ref(false);
// Flatten all variants into a list
interface VariantRow {
product_name: string;
variant_id: number;
variant_name: string;
system_stock: number;
physical_stock: number;
notes: string;
}
const variantRows = ref<VariantRow[]>([]);
onMounted(() => {
const rows: VariantRow[] = [];
for (const product of props.catalog) {
for (const variant of product.variants) {
rows.push({
product_name: product.name,
variant_id: variant.id,
variant_name: variant.name,
system_stock: variant.stock,
physical_stock: 0,
notes: '',
});
}
}
variantRows.value = rows;
const {
opnameDate,
notes,
stokOpnameId,
saving,
lastSaved,
variantRows,
itemsPayload,
groupedProducts,
onRowChange,
} = useStokOpnameForm({
catalog: () => props.catalog,
initialOpnameDate: new Date().toISOString().split('T')[0],
initialNotes: '',
initialStokOpnameId: null,
});
const itemsPayload = computed(() =>
variantRows.value
.filter((row) => row.physical_stock > 0 || row.notes.trim() !== '')
.map((row) => ({
product_variant_id: row.variant_id,
physical_stock: row.physical_stock,
notes: row.notes || null,
}))
);
interface ProductGroup {
product_name: string;
rows: VariantRow[];
startIndex: number;
}
const groupedProducts = computed<ProductGroup[]>(() => {
const groups: ProductGroup[] = [];
let currentProduct = '';
let currentGroup: ProductGroup | null = null;
let idx = 0;
for (const row of variantRows.value) {
if (row.product_name !== currentProduct) {
currentProduct = row.product_name;
currentGroup = { product_name: row.product_name, rows: [], startIndex: idx };
groups.push(currentGroup);
}
currentGroup!.rows.push(row);
idx++;
}
return groups;
});
const autoSave = useDebounceFn(async () => {
if (variantRows.value.length === 0) return;
saving.value = true;
try {
const response = await fetch(auto_save.url(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '',
},
body: JSON.stringify({
stok_opname_id: stokOpnameId.value,
opname_date: opnameDate.value,
notes: notes.value || null,
items: itemsPayload.value,
}),
});
if (response.ok) {
const data = await response.json();
stokOpnameId.value = data.stok_opname_id;
lastSaved.value = new Date().toLocaleTimeString('id-ID');
}
} catch (e) {
console.error('Auto-save failed:', e);
} finally {
saving.value = false;
}
}, 1000);
watch([opnameDate, notes], () => autoSave());
function onPhysicalStockChange() {
autoSave();
}
function submitForVerification() {
if (!stokOpnameId.value) return;
if (!stokOpnameId.value) {
return;
}
submitting.value = true;
router.post(submit.url(stokOpnameId.value), {}, {
onFinish: () => {
@ -151,123 +62,19 @@ function submitForVerification() {
</div>
<div class="flex items-center gap-3">
<div v-if="saving" class="text-muted-foreground flex items-center gap-1.5 text-sm">
<Loader2 class="size-3.5 animate-spin" />
Menyimpan...
</div>
<div v-else-if="lastSaved" class="text-muted-foreground flex items-center gap-1.5 text-sm">
<CheckCircle class="size-3.5 text-green-600" />
Tersimpan {{ lastSaved }}
</div>
<StokOpnameAutoSaveStatus :saving="saving" :last-saved="lastSaved" />
<BackButton :href="index.url()" />
</div>
</div>
<div class="space-y-6">
<Card>
<CardHeader>
<CardTitle>Informasi Dasar</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="opname_date">Tanggal Opname</Label>
<DatePicker
id="opname_date"
v-model="opnameDate"
placeholder="Pilih tanggal opname"
/>
</div>
</div>
<StokOpnameInfoSection v-model:opname-date="opnameDate" v-model:notes="notes" />
<div class="space-y-2">
<Label for="notes">Catatan</Label>
<Textarea
id="notes"
v-model="notes"
placeholder="Catatan stok opname (opsional)"
rows="2"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Daftar Produk</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-2">
<div
v-for="(group, gIdx) in groupedProducts"
:key="group.product_name"
class="overflow-hidden rounded-md border"
>
<div class="bg-muted/60 border-b px-4 py-2.5">
<span class="text-sm font-semibold">{{ group.product_name }}</span>
</div>
<table class="w-full text-sm">
<thead>
<tr class="border-b">
<th class="text-muted-foreground h-9 w-12 px-4 text-center text-xs font-medium">No.</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Varian</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Sistem</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Fisik</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Selisih</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Catatan</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rIdx) in group.rows"
:key="row.variant_id"
class="border-b transition-colors last:border-b-0 hover:bg-muted/30"
>
<td class="text-muted-foreground p-3 text-center">{{ group.startIndex + rIdx + 1 }}</td>
<td class="p-3">{{ row.variant_name }}</td>
<td class="p-3 text-right tabular-nums">{{ row.system_stock }}</td>
<td class="p-3 text-right">
<Input
v-model.number="row.physical_stock"
type="number"
min="0"
class="ml-auto w-24 text-right tabular-nums"
@input="onPhysicalStockChange"
/>
</td>
<td class="p-3 text-right tabular-nums">
<span
:class="{
'text-green-600 font-semibold': row.physical_stock - row.system_stock > 0,
'text-red-600 font-semibold': row.physical_stock - row.system_stock < 0,
'text-muted-foreground': row.physical_stock - row.system_stock === 0,
}"
>
{{ row.physical_stock - row.system_stock > 0 ? '+' : '' }}{{ row.physical_stock - row.system_stock }}
</span>
</td>
<td class="p-3">
<Input
v-model="row.notes"
placeholder="Catatan..."
class="w-full min-w-[120px]"
@input="onPhysicalStockChange"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-if="variantRows.length === 0"
class="text-muted-foreground py-8 text-center"
>
Tidak ada produk aktif.
</div>
</CardContent>
</Card>
<StokOpnameProductTable
:grouped-products="groupedProducts"
:is-empty="variantRows.length === 0"
@row-change="onRowChange"
/>
<div class="flex justify-end gap-3">
<BackButton :href="index.url()" label="Kembali" />

View File

@ -1,146 +1,51 @@
<script setup lang="ts">
import BackButton from '@/components/button/BackButton.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DatePicker } from '@/components/ui/date-picker';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CatalogProduct, StokOpnameDetail } from '@/types/stok-opname';
import { Head, router } from '@inertiajs/vue3';
import { useDebounceFn } from '@vueuse/core';
import { CheckCircle, Loader2, Send } from '@lucide/vue';
import { computed, onMounted, ref, watch } from 'vue';
import { index, auto_save, submit } from '@/routes/admin/manage/stok-opnames';
import { Loader2, Send } from '@lucide/vue';
import { ref } from 'vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { stokOpnameStatusBadgeVariant } from '@/constants/stok-opname-status';
import type { CatalogProduct, StokOpnameDetail } from '@/types/stok-opname';
import { index, submit } from '@/routes/admin/manage/stok-opnames';
import StokOpnameAutoSaveStatus from './form/StokOpnameAutoSaveStatus.vue';
import StokOpnameInfoSection from './form/StokOpnameInfoSection.vue';
import StokOpnameProductTable from './form/StokOpnameProductTable.vue';
import { useStokOpnameForm } from './form/useStokOpnameForm';
const props = defineProps<{
stokOpname: StokOpnameDetail;
catalog: CatalogProduct[];
}>();
const opnameDate = ref(props.stokOpname.opname_date);
const notes = ref(props.stokOpname.notes ?? '');
const stokOpnameId = ref<number>(props.stokOpname.id);
const saving = ref(false);
const lastSaved = ref<string | null>(null);
const submitting = ref(false);
interface VariantRow {
product_name: string;
variant_id: number;
variant_name: string;
system_stock: number;
physical_stock: number;
notes: string;
}
const variantRows = ref<VariantRow[]>([]);
onMounted(() => {
const rows: VariantRow[] = [];
// Build a map of existing items
const existingItems = new Map<number, { physical_stock: number; notes: string }>();
for (const item of props.stokOpname.items) {
existingItems.set(item.product_variant_id, {
physical_stock: item.physical_stock,
notes: item.notes ?? '',
});
}
for (const product of props.catalog) {
for (const variant of product.variants) {
const existing = existingItems.get(variant.id);
rows.push({
product_name: product.name,
variant_id: variant.id,
variant_name: variant.name,
system_stock: variant.stock,
physical_stock: existing?.physical_stock ?? 0,
notes: existing?.notes ?? '',
});
}
}
variantRows.value = rows;
const {
opnameDate,
notes,
stokOpnameId,
saving,
lastSaved,
variantRows,
itemsPayload,
groupedProducts,
onRowChange,
} = useStokOpnameForm({
catalog: () => props.catalog,
initialOpnameDate: props.stokOpname.opname_date,
initialNotes: props.stokOpname.notes ?? '',
initialStokOpnameId: props.stokOpname.id,
existingItems: () => props.stokOpname.items,
});
const itemsPayload = computed(() =>
variantRows.value
.filter((row) => row.physical_stock > 0 || row.notes.trim() !== '')
.map((row) => ({
product_variant_id: row.variant_id,
physical_stock: row.physical_stock,
notes: row.notes || null,
}))
);
interface ProductGroup {
product_name: string;
rows: VariantRow[];
startIndex: number;
}
const groupedProducts = computed<ProductGroup[]>(() => {
const groups: ProductGroup[] = [];
let currentProduct = '';
let currentGroup: ProductGroup | null = null;
let idx = 0;
for (const row of variantRows.value) {
if (row.product_name !== currentProduct) {
currentProduct = row.product_name;
currentGroup = { product_name: row.product_name, rows: [], startIndex: idx };
groups.push(currentGroup);
}
currentGroup!.rows.push(row);
idx++;
}
return groups;
});
const autoSave = useDebounceFn(async () => {
if (variantRows.value.length === 0) return;
saving.value = true;
try {
const response = await fetch(auto_save.url(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '',
},
body: JSON.stringify({
stok_opname_id: stokOpnameId.value,
opname_date: opnameDate.value,
notes: notes.value || null,
items: itemsPayload.value,
}),
});
if (response.ok) {
const data = await response.json();
stokOpnameId.value = data.stok_opname_id;
lastSaved.value = new Date().toLocaleTimeString('id-ID');
}
} catch (e) {
console.error('Auto-save failed:', e);
} finally {
saving.value = false;
}
}, 1000);
watch([opnameDate, notes], () => autoSave());
function onPhysicalStockChange() {
autoSave();
}
const canSubmit = props.stokOpname.status === 'draft' || props.stokOpname.status === 'rejected';
function submitForVerification() {
if (!stokOpnameId.value) return;
if (!stokOpnameId.value) {
return;
}
submitting.value = true;
router.post(submit.url(stokOpnameId.value), {}, {
onFinish: () => {
@ -158,13 +63,7 @@ function submitForVerification() {
<div class="space-y-1">
<div class="flex items-center gap-3">
<h2 class="text-2xl font-bold tracking-tight">Stok Opname</h2>
<Badge
:variant="
stokOpname.status === 'verified' ? 'default' :
stokOpname.status === 'pending' ? 'outline' :
stokOpname.status === 'rejected' ? 'destructive' : 'secondary'
"
>
<Badge :variant="stokOpnameStatusBadgeVariant(stokOpname.status)">
{{ stokOpname.status_label }}
</Badge>
</div>
@ -174,129 +73,28 @@ function submitForVerification() {
</div>
<div class="flex items-center gap-3">
<div v-if="saving" class="text-muted-foreground flex items-center gap-1.5 text-sm">
<Loader2 class="size-3.5 animate-spin" />
Menyimpan...
</div>
<div v-else-if="lastSaved" class="text-muted-foreground flex items-center gap-1.5 text-sm">
<CheckCircle class="size-3.5 text-green-600" />
Tersimpan {{ lastSaved }}
</div>
<StokOpnameAutoSaveStatus :saving="saving" :last-saved="lastSaved" />
<BackButton :href="index.url()" />
</div>
</div>
<div class="space-y-6">
<Card>
<CardHeader>
<CardTitle>Informasi Dasar</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="opname_date">Tanggal Opname</Label>
<DatePicker
id="opname_date"
v-model="opnameDate"
placeholder="Pilih tanggal opname"
/>
</div>
</div>
<StokOpnameInfoSection
v-model:opname-date="opnameDate"
v-model:notes="notes"
:verification-notes="stokOpname.verification_notes"
/>
<div class="space-y-2">
<Label for="notes">Catatan</Label>
<Textarea
id="notes"
v-model="notes"
placeholder="Catatan stok opname (opsional)"
rows="2"
/>
</div>
<div
v-if="stokOpname.verification_notes"
class="rounded-lg border bg-muted/50 p-4"
>
<Label class="text-sm font-medium">Catatan Verifikasi</Label>
<p class="mt-1 text-sm">{{ stokOpname.verification_notes }}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Daftar Produk</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-2">
<div
v-for="(group, gIdx) in groupedProducts"
:key="group.product_name"
class="overflow-hidden rounded-md border"
>
<div class="bg-muted/60 border-b px-4 py-2.5">
<span class="text-sm font-semibold">{{ group.product_name }}</span>
</div>
<table class="w-full text-sm">
<thead>
<tr class="border-b">
<th class="text-muted-foreground h-9 w-12 px-4 text-center text-xs font-medium">No.</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Varian</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Sistem</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Fisik</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Selisih</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Catatan</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rIdx) in group.rows"
:key="row.variant_id"
class="border-b transition-colors last:border-b-0 hover:bg-muted/30"
>
<td class="text-muted-foreground p-3 text-center">{{ group.startIndex + rIdx + 1 }}</td>
<td class="p-3">{{ row.variant_name }}</td>
<td class="p-3 text-right tabular-nums">{{ row.system_stock }}</td>
<td class="p-3 text-right">
<Input
v-model.number="row.physical_stock"
type="number"
min="0"
class="ml-auto w-24 text-right tabular-nums"
@input="onPhysicalStockChange"
/>
</td>
<td class="p-3 text-right tabular-nums">
<span
:class="{
'text-green-600 font-semibold': row.physical_stock - row.system_stock > 0,
'text-red-600 font-semibold': row.physical_stock - row.system_stock < 0,
'text-muted-foreground': row.physical_stock - row.system_stock === 0,
}"
>
{{ row.physical_stock - row.system_stock > 0 ? '+' : '' }}{{ row.physical_stock - row.system_stock }}
</span>
</td>
<td class="p-3">
<Input
v-model="row.notes"
placeholder="Catatan..."
class="w-full min-w-[120px]"
@input="onPhysicalStockChange"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</CardContent>
</Card>
<StokOpnameProductTable
:grouped-products="groupedProducts"
:is-empty="variantRows.length === 0"
@row-change="onRowChange"
/>
<div class="flex justify-end gap-3">
<BackButton :href="index.url()" label="Kembali" />
<Button
v-if="stokOpname.status === 'draft' || stokOpname.status === 'rejected'"
v-if="canSubmit"
:disabled="itemsPayload.length === 0 || submitting"
@click="submitForVerification"
>

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import { CheckCircle, Loader2 } from '@lucide/vue';
defineProps<{
saving: boolean;
lastSaved: string | null;
}>();
</script>
<template>
<div v-if="saving" class="text-muted-foreground flex items-center gap-1.5 text-sm">
<Loader2 class="size-3.5 animate-spin" />
Menyimpan...
</div>
<div v-else-if="lastSaved" class="text-muted-foreground flex items-center gap-1.5 text-sm">
<CheckCircle class="size-3.5 text-green-600" />
Tersimpan {{ lastSaved }}
</div>
</template>

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
defineProps<{
verificationNotes?: string | null;
}>();
const opnameDate = defineModel<string>('opnameDate', { required: true });
const notes = defineModel<string>('notes', { required: true });
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Informasi Dasar</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="opname_date">Tanggal Opname</Label>
<DatePicker
id="opname_date"
v-model="opnameDate"
placeholder="Pilih tanggal opname"
/>
</div>
</div>
<div class="space-y-2">
<Label for="notes">Catatan</Label>
<Textarea
id="notes"
v-model="notes"
placeholder="Catatan stok opname (opsional)"
rows="2"
/>
</div>
<div v-if="verificationNotes" class="rounded-lg border bg-muted/50 p-4">
<Label class="text-sm font-medium">Catatan Verifikasi</Label>
<p class="mt-1 text-sm">{{ verificationNotes }}</p>
</div>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,96 @@
<script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { groupedTableRowNumber } from '@/lib/grouped-table';
import {
stokOpnameDifference,
stokOpnameDifferenceClass,
stokOpnameDifferenceText,
} from '@/lib/stok-opname-display';
import type { StokOpnameProductGroup } from '@/types/stok-opname';
defineProps<{
groupedProducts: StokOpnameProductGroup[];
isEmpty: boolean;
}>();
const emit = defineEmits<{
'row-change': [];
}>();
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Daftar Produk</CardTitle>
</CardHeader>
<CardContent>
<div v-if="isEmpty" class="text-muted-foreground py-8 text-center">
Tidak ada produk aktif.
</div>
<div v-else class="space-y-2">
<div
v-for="group in groupedProducts"
:key="group.product_name"
class="overflow-hidden rounded-md border"
>
<div class="bg-muted/60 border-b px-4 py-2.5">
<span class="text-sm font-semibold">{{ group.product_name }}</span>
</div>
<table class="w-full text-sm">
<thead>
<tr class="border-b">
<th class="text-muted-foreground h-9 w-12 px-4 text-center text-xs font-medium">No.</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Varian</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Sistem</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Fisik</th>
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Selisih</th>
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Catatan</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rIdx) in group.rows"
:key="row.variant_id"
class="border-b transition-colors last:border-b-0 hover:bg-muted/30"
>
<td class="text-muted-foreground p-3 text-center">
{{ groupedTableRowNumber(group.startIndex, rIdx) }}
</td>
<td class="p-3">{{ row.variant_name }}</td>
<td class="p-3 text-right tabular-nums">
{{ row.system_stock_formatted ?? row.system_stock }}
</td>
<td class="p-3 text-right">
<Input
v-model.number="row.physical_stock"
type="number"
min="0"
class="ml-auto w-24 text-right tabular-nums"
@input="emit('row-change')"
/>
</td>
<td class="p-3 text-right tabular-nums">
<span
:class="stokOpnameDifferenceClass(stokOpnameDifference(row.physical_stock, row.system_stock))"
>
{{ stokOpnameDifferenceText(stokOpnameDifference(row.physical_stock, row.system_stock)) }}
</span>
</td>
<td class="p-3">
<Input
v-model="row.notes"
placeholder="Catatan..."
class="w-full min-w-[120px]"
@input="emit('row-change')"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,140 @@
import { useDebounceFn } from '@vueuse/core';
import { computed, ref, watch, type MaybeRefOrGetter, toValue } from 'vue';
import { apiFetch } from '@/lib/api';
import { auto_save } from '@/routes/admin/manage/stok-opnames';
import type {
CatalogProduct,
StokOpnameExistingItem,
StokOpnameProductGroup,
StokOpnameVariantRow,
} from '@/types/stok-opname';
export function buildStokOpnameVariantRows(
catalog: CatalogProduct[],
existingItems: StokOpnameExistingItem[] = [],
): StokOpnameVariantRow[] {
const existingMap = new Map<number, { physical_stock: number; notes: string }>();
for (const item of existingItems) {
existingMap.set(item.product_variant_id, {
physical_stock: item.physical_stock,
notes: item.notes ?? '',
});
}
const rows: StokOpnameVariantRow[] = [];
for (const product of catalog) {
for (const variant of product.variants) {
const existing = existingMap.get(variant.id);
rows.push({
product_name: product.name,
variant_id: variant.id,
variant_name: variant.name,
system_stock: variant.stock,
system_stock_formatted: variant.stock_formatted,
physical_stock: existing?.physical_stock ?? 0,
notes: existing?.notes ?? '',
});
}
}
return rows;
}
export function useStokOpnameForm(options: {
catalog: MaybeRefOrGetter<CatalogProduct[]>;
initialOpnameDate: MaybeRefOrGetter<string>;
initialNotes: MaybeRefOrGetter<string>;
initialStokOpnameId: MaybeRefOrGetter<number | null>;
existingItems?: MaybeRefOrGetter<StokOpnameExistingItem[]>;
}) {
const opnameDate = ref(toValue(options.initialOpnameDate));
const notes = ref(toValue(options.initialNotes));
const stokOpnameId = ref<number | null>(toValue(options.initialStokOpnameId));
const saving = ref(false);
const lastSaved = ref<string | null>(null);
const variantRows = ref<StokOpnameVariantRow[]>(
buildStokOpnameVariantRows(
toValue(options.catalog),
toValue(options.existingItems) ?? [],
),
);
const itemsPayload = computed(() =>
variantRows.value
.filter((row) => row.physical_stock > 0 || row.notes.trim() !== '')
.map((row) => ({
product_variant_id: row.variant_id,
physical_stock: row.physical_stock,
notes: row.notes || null,
})),
);
const groupedProducts = computed<StokOpnameProductGroup[]>(() => {
const groups: StokOpnameProductGroup[] = [];
let currentProduct = '';
let currentGroup: StokOpnameProductGroup | null = null;
let idx = 0;
for (const row of variantRows.value) {
if (row.product_name !== currentProduct) {
currentProduct = row.product_name;
currentGroup = { product_name: row.product_name, rows: [], startIndex: idx };
groups.push(currentGroup);
}
currentGroup!.rows.push(row);
idx++;
}
return groups;
});
const autoSave = useDebounceFn(async () => {
if (variantRows.value.length === 0) {
return;
}
saving.value = true;
try {
const data = await apiFetch<{ stok_opname_id: number }>(auto_save.url(), {
method: 'POST',
body: JSON.stringify({
stok_opname_id: stokOpnameId.value,
opname_date: opnameDate.value,
notes: notes.value || null,
items: itemsPayload.value,
}),
});
stokOpnameId.value = data.stok_opname_id;
lastSaved.value = new Date().toLocaleTimeString('id-ID');
} catch {
// Auto-save is silent; user can retry on next change.
} finally {
saving.value = false;
}
}, 1000);
watch([opnameDate, notes], () => autoSave());
function onRowChange() {
autoSave();
}
return {
opnameDate,
notes,
stokOpnameId,
saving,
lastSaved,
variantRows,
itemsPayload,
groupedProducts,
onRowChange,
};
}

View File

@ -2,17 +2,11 @@ import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/data-table';
import type { StokOpnameListItem, StokOpnameStatus } from '@/types/stok-opname';
import { stokOpnameStatusBadgeVariant } from '@/constants/stok-opname-status';
import type { StokOpnameListItem } from '@/types/stok-opname';
import DataTableActions from './data-table-actions.vue';
import { cn } from '@/lib/utils';
const statusVariant: Record<StokOpnameStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
draft: 'secondary',
pending: 'outline',
verified: 'default',
rejected: 'destructive',
};
export function createColumns(
onEdit: (item: StokOpnameListItem) => void,
onSubmit: (item: StokOpnameListItem) => void,
@ -30,7 +24,7 @@ export function createColumns(
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
cell: ({ row }) => h(Badge, {
variant: statusVariant[row.original.status],
variant: stokOpnameStatusBadgeVariant(row.original.status),
class: cn(row.original.status === 'verified' && 'bg-green-600 text-white'),
}, () => row.original.status_label),
},

View File

@ -11,6 +11,8 @@ import { StockStatus } from '@/constants/stock-status';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableFilterDef } from '@/types/data-table';
import type { CategoryOption, PaginatedProducts } from '@/types/product';
import type { MasterOutOfStockGroup } from '@/types/master-inventory';
import MasterOutOfStockCatalogSection from '@/components/master/MasterOutOfStockCatalogSection.vue';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import { index, create } from '@/routes/admin/master/products';
@ -19,6 +21,7 @@ import ProductGroupedTable from './table/ProductGroupedTable.vue';
const props = defineProps<{
products: PaginatedProducts;
categories: CategoryOption[];
outOfStockGroups: MasterOutOfStockGroup[];
filters: {
search: string;
sort?: string;
@ -119,6 +122,13 @@ watch(
/>
</div>
<MasterOutOfStockCatalogSection
v-if="outOfStockGroups.length > 0"
title="Produk Stok Habis"
description="Varian produk aktif dengan stok bagus 0 pcs."
:groups="outOfStockGroups"
/>
<Card class="min-w-0">
<CardContent class="min-w-0">
<ProductGroupedTable

View File

@ -1,42 +1,20 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Plus, Save, Trash2 } from '@lucide/vue';
import { Plus, Save } from '@lucide/vue';
import { computed } from 'vue';
import { toast } from 'vue-sonner';
import { NumberInput } from '@/components/form/number-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { useVariantList } from '@/composables/useVariantList';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
import type { CategoryOption, ProductVariantFormItem } from '@/types/product';
import type { CategoryOption, ProductFormInitialData, ProductVariantFormItem } from '@/types/product';
import ProductInfoSection from './ProductInfoSection.vue';
import ProductVariantSection from './ProductVariantSection.vue';
const props = withDefaults(
defineProps<{
categories: CategoryOption[];
initialData?: {
name?: string;
description?: string;
category_ids?: number[];
variants?: Array<{
id?: number;
name?: string;
stock?: number | string;
stock_retail?: number | string;
images?: Array<{ id: number; url: string; thumb_url: string }>;
}>;
};
initialData?: ProductFormInitialData;
submitUrl: string;
method?: 'post' | 'put';
submitLabel?: string;
@ -95,6 +73,8 @@ const form = useForm({
category_ids: props.initialData?.category_ids ?? [],
});
const categoryError = computed(() => form.errors.category_ids);
function toggleCategory(categoryId: number, checked: boolean) {
if (checked) {
if (!form.category_ids.includes(categoryId)) {
@ -107,12 +87,6 @@ function toggleCategory(categoryId: number, checked: boolean) {
form.category_ids = form.category_ids.filter((id) => id !== categoryId);
}
function isCategoryChecked(categoryId: number): boolean {
return form.category_ids.includes(categoryId);
}
const categoryError = computed(() => form.errors.category_ids);
function buildFormData(): FormData {
const formData = new FormData();
@ -125,8 +99,8 @@ function buildFormData(): FormData {
appendToFormData(formData, (formData, index, variant) => {
formData.append(`variants[${index}][name]`, variant.name.trim());
formData.append(`variants[${index}][stock]`, String(Number.parseInt(variant.stock, 10) || 0));
formData.append(`variants[${index}][stock_retail]`, String(Number.parseInt(variant.stock_retail, 10) || 0));
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
formData.append(`variants[${index}][stock_retail]`, String(Number.parseInt(String(variant.stock_retail), 10) || 0));
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
}, props.method);
@ -136,7 +110,7 @@ function buildFormData(): FormData {
function submit() {
const options = {
forceFormData: true,
onError: (errors: any) => {
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
}
@ -157,94 +131,31 @@ function submit() {
<CardTitle>Informasi Produk</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="name" required>Nama Produk</FieldLabel>
<Input id="name" v-model="form.name" type="text" placeholder="Contoh: Midi Olla Dress"
:maxlength="FIELD_LIMITS.name" />
<FieldError :errors="formErrors(form, 'name')" />
</Field>
<Field>
<FieldLabel for="description">Deskripsi</FieldLabel>
<Textarea id="description" v-model="form.description"
placeholder="Contoh: Hi Sobat, New arrival dari DST dengan Olla Dress. Yuks check detail produk: Detail: * Bahan Cey Flow Bordir * Ld 120 cm * PB 110 cm * Bisa jadi dress..."
rows="4" />
<FieldError :errors="formErrors(form, 'description')" />
</Field>
<Field>
<FieldLabel required>Kategori</FieldLabel>
<div v-if="categories.length > 0"
class="grid gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
<label v-for="category in categories" :key="category.value"
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2">
<input type="checkbox" class="size-4 rounded border-input"
:checked="isCategoryChecked(category.value)"
@change="toggleCategory(category.value, ($event.target as HTMLInputElement).checked)">
<span class="text-sm">{{ category.label }}</span>
</label>
</div>
<p v-else class="text-muted-foreground text-sm">
Belum ada kategori. Tambahkan kategori terlebih dahulu.
</p>
<FieldError :errors="categoryError ? [categoryError] : []" />
</Field>
</FieldSet>
</FieldGroup>
<ProductInfoSection
:form="form"
:categories="categories"
:category-error="categoryError"
@update:name="form.name = $event"
@update:description="form.description = $event"
@toggle-category="toggleCategory"
/>
</CardContent>
</Card>
<Card v-for="(variant, index) in variants" :key="variant.client_id">
<CardHeader class="flex flex-row items-start justify-between gap-4">
<CardTitle>Varian {{ index + 1 }}</CardTitle>
<div class="flex items-center gap-2">
<Button v-if="variants.length > 1" type="button" variant="outline" size="icon"
class="text-destructive hover:text-destructive size-8"
@click="removeVariant(variant.client_id)">
<Trash2 class="size-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel :for="`variant_name_${variant.client_id}`" required>
Nama Varian
</FieldLabel>
<Input :id="`variant_name_${variant.client_id}`" :model-value="variant.name" type="text"
placeholder="Contoh: Polkadot" :maxlength="FIELD_LIMITS.variantName"
@update:model-value="setVariantField(variant.client_id, 'name', String($event))" />
<FieldError :errors="variantErrors(form, variant.client_id, 'name')" />
</Field>
<Field>
<FieldLabel :for="`variant_stock_${variant.client_id}`" required>
Stok
</FieldLabel>
<NumberInput :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock"
@update:model-value="setVariantField(variant.client_id, 'stock', String($event))" />
<FieldError :errors="variantErrors(form, variant.client_id, 'stock')" />
</Field>
<Field>
<FieldLabel :for="`variant_stock_retail_${variant.client_id}`" required>
Stok Ecer
</FieldLabel>
<NumberInput :id="`variant_stock_retail_${variant.client_id}`" :model-value="variant.stock_retail"
@update:model-value="setVariantField(variant.client_id, 'stock_retail', String($event))" />
<FieldError :errors="variantErrors(form, variant.client_id, 'stock_retail')" />
</Field>
</FieldSet>
<div>
<MediaDropzone :id="`variant_images_${variant.client_id}`" v-model="variant.media"
label="Foto Varian" :max-files="5" required
:errors="variantErrors(form, variant.client_id, 'images')" />
</div>
</FieldGroup>
</CardContent>
</Card>
<ProductVariantSection
v-for="(variant, index) in variants"
:key="variant.client_id"
:form="form"
:variant="variant"
:index="index"
:can-remove="variants.length > 1"
:variant-errors="(clientId, field) => variantErrors(form, clientId, field)"
@remove="removeVariant(variant.client_id)"
@update:name="setVariantField(variant.client_id, 'name', $event)"
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
@update:stock-retail="setVariantField(variant.client_id, 'stock_retail', $event)"
@update:media="setVariantField(variant.client_id, 'media', $event)"
/>
<div class="flex items-center justify-between gap-2">
<Button type="button" variant="outline" @click="addVariant">

View File

@ -0,0 +1,92 @@
<script setup lang="ts">
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import type { CategoryOption } from '@/types/product';
defineProps<{
form: {
name: string;
description: string;
category_ids: number[];
errors: Record<string, string>;
};
categories: CategoryOption[];
categoryError?: string;
}>();
const emit = defineEmits<{
'update:name': [value: string];
'update:description': [value: string];
'toggle-category': [categoryId: number, checked: boolean];
}>();
function isCategoryChecked(categoryIds: number[], categoryId: number): boolean {
return categoryIds.includes(categoryId);
}
</script>
<template>
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="name" required>Nama Produk</FieldLabel>
<Input
id="name"
:model-value="form.name"
type="text"
placeholder="Contoh: Midi Olla Dress"
:maxlength="FIELD_LIMITS.name"
@update:model-value="emit('update:name', String($event))"
/>
<FieldError :errors="formErrors(form, 'name')" />
</Field>
<Field>
<FieldLabel for="description">Deskripsi</FieldLabel>
<Textarea
id="description"
:model-value="form.description"
placeholder="Contoh: Hi Sobat, New arrival dari DST dengan Olla Dress. Yuks check detail produk: Detail: * Bahan Cey Flow Bordir * Ld 120 cm * PB 110 cm * Bisa jadi dress..."
rows="4"
@update:model-value="emit('update:description', String($event))"
/>
<FieldError :errors="formErrors(form, 'description')" />
</Field>
<Field>
<FieldLabel required>Kategori</FieldLabel>
<div
v-if="categories.length > 0"
class="grid gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"
>
<label
v-for="category in categories"
:key="category.value"
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2"
>
<input
type="checkbox"
class="size-4 rounded border-input"
:checked="isCategoryChecked(form.category_ids, category.value)"
@change="emit('toggle-category', category.value, ($event.target as HTMLInputElement).checked)"
>
<span class="text-sm">{{ category.label }}</span>
</label>
</div>
<p v-else class="text-muted-foreground text-sm">
Belum ada kategori. Tambahkan kategori terlebih dahulu.
</p>
<FieldError :errors="categoryError ? [categoryError] : []" />
</Field>
</FieldSet>
</FieldGroup>
</template>

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import { Trash2 } from '@lucide/vue';
import { NumberInput } from '@/components/form/number-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { FIELD_LIMITS } from '@/lib/field-limits';
import type { ProductVariantFormItem } from '@/types/product';
import type { MediaUploadState } from '@/types/media';
defineProps<{
form: {
errors: Record<string, string>;
};
variant: ProductVariantFormItem;
index: number;
canRemove: boolean;
variantErrors: (clientId: string, field: string) => string[];
}>();
const emit = defineEmits<{
remove: [];
'update:name': [value: string];
'update:stock': [value: string];
'update:stock-retail': [value: string];
'update:media': [value: MediaUploadState];
}>();
</script>
<template>
<Card>
<CardHeader class="flex flex-row items-start justify-between gap-4">
<CardTitle>Varian {{ index + 1 }}</CardTitle>
<Button
v-if="canRemove"
type="button"
variant="outline"
size="icon"
class="text-destructive hover:text-destructive size-8"
@click="emit('remove')"
>
<Trash2 class="size-4" />
</Button>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel :for="`variant_name_${variant.client_id}`" required>
Nama Varian
</FieldLabel>
<Input
:id="`variant_name_${variant.client_id}`"
:model-value="variant.name"
type="text"
placeholder="Contoh: Polkadot"
:maxlength="FIELD_LIMITS.variantName"
@update:model-value="emit('update:name', String($event))"
/>
<FieldError :errors="variantErrors(variant.client_id, 'name')" />
</Field>
<Field>
<FieldLabel :for="`variant_stock_${variant.client_id}`" required>
Stok
</FieldLabel>
<NumberInput
:id="`variant_stock_${variant.client_id}`"
:model-value="variant.stock"
@update:model-value="emit('update:stock', String($event))"
/>
<FieldError :errors="variantErrors(variant.client_id, 'stock')" />
</Field>
<Field>
<FieldLabel :for="`variant_stock_retail_${variant.client_id}`" required>
Stok Ecer
</FieldLabel>
<NumberInput
:id="`variant_stock_retail_${variant.client_id}`"
:model-value="variant.stock_retail"
@update:model-value="emit('update:stock-retail', String($event))"
/>
<FieldError :errors="variantErrors(variant.client_id, 'stock_retail')" />
</Field>
</FieldSet>
<div>
<MediaDropzone
:id="`variant_images_${variant.client_id}`"
:model-value="variant.media"
label="Foto Varian"
:max-files="5"
required
:errors="variantErrors(variant.client_id, 'images')"
@update:model-value="emit('update:media', $event)"
/>
</div>
</FieldGroup>
</CardContent>
</Card>
</template>

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowLeftRight } from '@lucide/vue';
import { computed, ref } from 'vue';
import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import StockRetailTransferModal from '@/components/modal/StockRetailTransferModal.vue';
import { Badge } from '@/components/ui/badge';
@ -17,6 +17,8 @@ import {
TableRow,
} from '@/components/ui/table';
import { useCan } from '@/composables/useCan';
import { usePaginationSummary } from '@/composables/usePaginationSummary';
import { groupedTableRowNumber } from '@/lib/grouped-table';
import type {
DataTableFilterDef,
DataTablePagination,
@ -49,27 +51,10 @@ const emit = defineEmits<{
const { can } = useCan();
const showingCount = computed(() => props.products.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
return null;
}
const { total } = props.pagination;
if (total === 0) {
return 'Menampilkan 0 produk';
}
return `Menampilkan ${showingCount.value} produk dari ${total}`;
});
function formatStock(value: number): string {
return value.toLocaleString('id-ID');
}
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'produk');
function rowNumber(index: number): number {
return (props.firstItem ?? 1) + index;
return groupedTableRowNumber(props.firstItem, index);
}
// Stock Retail Transfer Modal
@ -124,17 +109,17 @@ function onTransferSubmitted() {
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span>
Total stok bagus <strong class="text-primary">
{{product.variants.reduce((acc, v) => acc + v.stock, 0)}}
{{ product.total_stock_formatted }}
</strong>
</span>
<span>
Total stok reject <strong class="text-destructive">
{{product.variants.reduce((acc, v) => acc + v.reject_stock, 0)}}
{{ product.total_reject_stock_formatted }}
</strong>
</span>
<span>
Total stok ecer <strong class="text-blue-600">
{{product.variants.reduce((acc, v) => acc + v.stock_retail, 0)}}
{{ product.total_retail_stock_formatted }}
</strong>
</span>
</div>
@ -173,13 +158,13 @@ function onTransferSubmitted() {
<MediaThumbnailCell :items="variant.images ?? []" />
</TableCell>
<TableCell class="tabular-nums">
{{ formatStock(variant.stock) }}
{{ variant.stock_formatted }}
</TableCell>
<TableCell class="tabular-nums text-destructive">
{{ formatStock(variant.reject_stock) }}
{{ variant.reject_stock_formatted }}
</TableCell>
<TableCell class="tabular-nums text-blue-600">
{{ formatStock(variant.stock_retail) }}
{{ variant.stock_retail_formatted }}
</TableCell>
<TableCell>
<div v-if="variant.prices?.length" class="space-y-0.5 text-xs">
@ -218,22 +203,11 @@ function onTransferSubmitted() {
<DataTableEmpty v-else />
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-sm">
{{ paginationSummary }}
</p>
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
<Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
:disabled="!link.url || link.active" as-child>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>
<span v-else v-html="link.label" />
</Button>
</div>
</div>
<GroupedTableFooter
:summary="paginationSummary"
:pagination="pagination"
:pagination-links="paginationLinks"
/>
</div>
<StockRetailTransferModal

View File

@ -11,6 +11,8 @@ import { StockStatus } from '@/constants/stock-status';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableFilterDef } from '@/types/data-table';
import type { PaginatedRawMaterials } from '@/types/raw-material';
import type { MasterOutOfStockGroup } from '@/types/master-inventory';
import MasterOutOfStockCatalogSection from '@/components/master/MasterOutOfStockCatalogSection.vue';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import { index, create } from '@/routes/admin/master/raw_materials';
@ -18,6 +20,8 @@ import RawMaterialGroupedTable from './table/RawMaterialGroupedTable.vue';
const props = defineProps<{
rawMaterials: PaginatedRawMaterials;
outOfStockGroups: MasterOutOfStockGroup[];
lowStockGroups: MasterOutOfStockGroup[];
filters: {
search: string;
sort?: string;
@ -108,6 +112,24 @@ watch(
/>
</div>
<div v-if="outOfStockGroups.length > 0 || lowStockGroups.length > 0" class="space-y-4">
<MasterOutOfStockCatalogSection
v-if="outOfStockGroups.length > 0"
title="Bahan Baku Stok Habis"
description="Varian bahan baku aktif dengan stok 0."
:groups="outOfStockGroups"
/>
<MasterOutOfStockCatalogSection
v-if="lowStockGroups.length > 0"
title="Bahan Baku Stok Menipis"
description="Varian bahan baku aktif di bawah batas minimum stok."
severity="warning"
:groups="lowStockGroups"
empty-title="Tidak ada stok menipis"
empty-description="Semua varian bahan baku masih di atas batas minimum."
/>
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<RawMaterialGroupedTable

View File

@ -1,31 +1,11 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
import { Plus, Save } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import { DecimalInput } from '@/components/form/decimal-input';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import FieldDescription from '@/components/ui/field/FieldDescription.vue';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FieldError } from '@/components/ui/field';
import { useVariantList } from '@/composables/useVariantList';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { parseRupiah } from '@/lib/rupiah';
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
@ -34,6 +14,9 @@ import type {
RawMaterialPriceFormItem,
RawMaterialUnit,
} from '@/types/raw-material';
import RawMaterialInfoSection from './RawMaterialInfoSection.vue';
import RawMaterialSharedPriceSection from './RawMaterialSharedPriceSection.vue';
import RawMaterialVariantSection from './RawMaterialVariantSection.vue';
const props = withDefaults(
defineProps<{
@ -65,7 +48,6 @@ function createClientId(): string {
const {
items: prices,
addItem: addPriceRaw,
removeItem: removePrice,
setField: setPriceField,
appendToFormData,
@ -186,137 +168,47 @@ function buildFormData(): FormData {
}
function submit() {
const options = {
const payload = buildFormData();
form.transform(() => payload).post(props.submitUrl, {
forceFormData: true,
onError: (errors: any) => {
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
}
},
};
const payload = buildFormData();
form.transform(() => payload).post(props.submitUrl, options);
});
}
</script>
<template>
<form @submit.prevent="submit">
<div class="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Bahan Baku</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet :class="['grid gap-4', method === 'put' ? 'md:grid-cols-1' : 'md:grid-cols-2']">
<Field>
<FieldLabel for="name" required>Nama Bahan Baku</FieldLabel>
<Input id="name" v-model="form.name" type="text"
placeholder="Contoh: Kain Katun Premium" :maxlength="FIELD_LIMITS.name" />
<FieldError :errors="formErrors(form, 'name')" />
</Field>
<RawMaterialInfoSection :form="form" :units="units" :method="method" />
<Field v-if="method !== 'put'">
<FieldLabel for="unit" required>Satuan</FieldLabel>
<Select v-model="form.unit">
<SelectTrigger id="unit" class="w-full">
<SelectValue placeholder="Pilih satuan" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in units" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<FieldError :errors="formErrors(form, 'unit')" />
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
<RawMaterialSharedPriceSection
:form="form"
:prices="prices"
:use-same-price="useSamePrice"
@toggle-use-same-price="toggleUseSamePrice"
@set-shared-price="setSharedPrice"
/>
<Card v-if="prices.length > 1">
<CardHeader>
<CardTitle>Harga Bersama</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<label class="mb-4 flex cursor-pointer items-center gap-2">
<input type="checkbox" class="size-4 rounded border-input" :checked="useSamePrice"
@change="toggleUseSamePrice(($event.target as HTMLInputElement).checked)">
<span class="text-sm">Gunakan harga yang sama untuk semua varian</span>
</label>
<Field v-if="useSamePrice && prices[0]">
<FieldLabel :for="`shared_price_${prices[0].client_id}`" required>
Harga
</FieldLabel>
<RupiahInput :id="`shared_price_${prices[0].client_id}`" :model-value="prices[0].price"
@update:model-value="setSharedPrice" />
<FieldError :errors="formErrors(form, 'prices.0.price')" />
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card v-for="(price, index) in prices" :key="price.client_id">
<CardHeader class="flex flex-row items-start justify-between gap-4">
<CardTitle>Varian {{ index + 1 }}</CardTitle>
<div class="flex items-center gap-2">
<Button v-if="prices.length > 1 && !useSamePrice" type="button" variant="outline" size="sm"
@click="applyPriceToAllVariants(price.client_id)">
<Copy class="size-4" />
Terapkan Harga ke Semua
</Button>
<Button v-if="prices.length > 1" type="button" variant="outline" size="icon"
class="text-destructive hover:text-destructive size-8"
@click="removePrice(price.client_id)">
<Trash2 class="size-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet
class="grid gap-4 md:grid-cols-3">
<Field>
<FieldLabel :for="`variant_${price.client_id}`" required>
Nama Varian
</FieldLabel>
<Input :id="`variant_${price.client_id}`" :model-value="price.variant" type="text"
placeholder="Contoh: Premium / 40s" :maxlength="FIELD_LIMITS.variantName"
@update:model-value="setPriceField(price.client_id, 'variant', String($event))" />
<FieldError :errors="priceErrors(form, price.client_id, 'variant')" />
</Field>
<Field>
<FieldLabel :for="`stock_${price.client_id}`" required>
Stok
</FieldLabel>
<DecimalInput :id="`stock_${price.client_id}`" :model-value="price.stock"
@update:model-value="setPriceField(price.client_id, 'stock', String($event))" />
<FieldError :errors="priceErrors(form, price.client_id, 'stock')" />
</Field>
<Field>
<FieldLabel :for="`price_${price.client_id}`" required>
Harga
</FieldLabel>
<RupiahInput :id="`price_${price.client_id}`" :model-value="price.price"
@update:model-value="setPriceValue(price.client_id, $event)" />
<FieldDescription>Harga adalah harga per satuan bahan baku.</FieldDescription>
<FieldError :errors="priceErrors(form, price.client_id, 'price')" />
</Field>
</FieldSet>
<div class="mt-4">
<MediaDropzone :id="`price_images_${price.client_id}`" v-model="price.media"
label="Foto Varian" :max-files="5" required
:errors="priceErrors(form, price.client_id, 'images')" />
</div>
</FieldGroup>
</CardContent>
</Card>
<RawMaterialVariantSection
v-for="(price, index) in prices"
:key="price.client_id"
:form="form"
:price="price"
:index="index"
:total-prices="prices.length"
:use-same-price="useSamePrice"
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
@remove="removePrice(price.client_id)"
@apply-price-to-all="applyPriceToAllVariants(price.client_id)"
@update:variant="setPriceField(price.client_id, 'variant', $event)"
@update:stock="setPriceField(price.client_id, 'stock', $event)"
@update:price="setPriceValue(price.client_id, $event)"
/>
<FieldError :errors="formErrors(form, 'prices')" />

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors, type FormWithErrors } from '@/lib/form';
import type { EnumOption } from '@/types/raw-material';
defineProps<{
form: FormWithErrors & { name: string; unit: string };
units: EnumOption[];
method: 'post' | 'put';
}>();
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Informasi Bahan Baku</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet :class="['grid gap-4', method === 'put' ? 'md:grid-cols-1' : 'md:grid-cols-2']">
<Field>
<FieldLabel for="name" required>Nama Bahan Baku</FieldLabel>
<Input
id="name"
v-model="form.name"
type="text"
placeholder="Contoh: Kain Katun Premium"
:maxlength="FIELD_LIMITS.name"
/>
<FieldError :errors="formErrors(form, 'name')" />
</Field>
<Field v-if="method !== 'put'">
<FieldLabel for="unit" required>Satuan</FieldLabel>
<Select v-model="form.unit">
<SelectTrigger id="unit" class="w-full">
<SelectValue placeholder="Pilih satuan" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in units" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<FieldError :errors="formErrors(form, 'unit')" />
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import { RupiahInput } from '@/components/form/rupiah-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { formErrors, type FormWithErrors } from '@/lib/form';
import type { RawMaterialPriceFormItem } from '@/types/raw-material';
defineProps<{
form: FormWithErrors;
prices: RawMaterialPriceFormItem[];
useSamePrice: boolean;
}>();
const emit = defineEmits<{
'toggle-use-same-price': [checked: boolean];
'set-shared-price': [value: string];
}>();
</script>
<template>
<Card v-if="prices.length > 1">
<CardHeader>
<CardTitle>Harga Bersama</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<label class="mb-4 flex cursor-pointer items-center gap-2">
<input
type="checkbox"
class="size-4 rounded border-input"
:checked="useSamePrice"
@change="emit('toggle-use-same-price', ($event.target as HTMLInputElement).checked)"
>
<span class="text-sm">Gunakan harga yang sama untuk semua varian</span>
</label>
<Field v-if="useSamePrice && prices[0]">
<FieldLabel :for="`shared_price_${prices[0].client_id}`" required>
Harga
</FieldLabel>
<RupiahInput
:id="`shared_price_${prices[0].client_id}`"
:model-value="prices[0].price"
@update:model-value="emit('set-shared-price', $event)"
/>
<FieldError :errors="formErrors(form, 'prices.0.price')" />
</Field>
</FieldGroup>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,121 @@
<script setup lang="ts">
import { Copy, Trash2 } from '@lucide/vue';
import { DecimalInput } from '@/components/form/decimal-input';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import FieldDescription from '@/components/ui/field/FieldDescription.vue';
import { Input } from '@/components/ui/input';
import { FIELD_LIMITS } from '@/lib/field-limits';
import type { FormWithErrors } from '@/lib/form';
import type { RawMaterialPriceFormItem } from '@/types/raw-material';
defineProps<{
form: FormWithErrors;
price: RawMaterialPriceFormItem;
index: number;
totalPrices: number;
useSamePrice: boolean;
priceErrors: (clientId: string, field: string) => string[];
}>();
const emit = defineEmits<{
remove: [];
'apply-price-to-all': [];
'update:variant': [value: string];
'update:stock': [value: string];
'update:price': [value: string];
}>();
</script>
<template>
<Card>
<CardHeader class="flex flex-row items-start justify-between gap-4">
<CardTitle>Varian {{ index + 1 }}</CardTitle>
<div class="flex items-center gap-2">
<Button
v-if="totalPrices > 1 && !useSamePrice"
type="button"
variant="outline"
size="sm"
@click="emit('apply-price-to-all')"
>
<Copy class="size-4" />
Terapkan Harga ke Semua
</Button>
<Button
v-if="totalPrices > 1"
type="button"
variant="outline"
size="icon"
class="text-destructive hover:text-destructive size-8"
@click="emit('remove')"
>
<Trash2 class="size-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4 md:grid-cols-3">
<Field>
<FieldLabel :for="`variant_${price.client_id}`" required>
Nama Varian
</FieldLabel>
<Input
:id="`variant_${price.client_id}`"
:model-value="price.variant"
type="text"
placeholder="Contoh: Premium / 40s"
:maxlength="FIELD_LIMITS.variantName"
@update:model-value="emit('update:variant', String($event))"
/>
<FieldError :errors="priceErrors(price.client_id, 'variant')" />
</Field>
<Field>
<FieldLabel :for="`stock_${price.client_id}`" required>
Stok
</FieldLabel>
<DecimalInput
:id="`stock_${price.client_id}`"
:model-value="price.stock"
@update:model-value="emit('update:stock', String($event))"
/>
<FieldError :errors="priceErrors(price.client_id, 'stock')" />
</Field>
<Field>
<FieldLabel :for="`price_${price.client_id}`" required>
Harga
</FieldLabel>
<RupiahInput
:id="`price_${price.client_id}`"
:model-value="price.price"
@update:model-value="emit('update:price', $event)"
/>
<FieldDescription>Harga adalah harga per satuan bahan baku.</FieldDescription>
<FieldError :errors="priceErrors(price.client_id, 'price')" />
</Field>
</FieldSet>
<div class="mt-4">
<MediaDropzone
:id="`price_images_${price.client_id}`"
v-model="price.media"
label="Foto Varian"
:max-files="5"
required
:errors="priceErrors(price.client_id, 'images')"
/>
</div>
</FieldGroup>
</CardContent>
</Card>
</template>

View File

@ -1,11 +1,10 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
@ -14,7 +13,8 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatRupiah } from '@/lib/rupiah';
import { usePaginationSummary } from '@/composables/usePaginationSummary';
import { groupedTableRowNumber } from '@/lib/grouped-table';
import type {
DataTableFilterDef,
DataTablePagination,
@ -41,23 +41,10 @@ const emit = defineEmits<{
}>();
const showingCount = computed(() => props.materials.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
return null;
}
const { total } = props.pagination;
if (total === 0) {
return 'Menampilkan 0 bahan baku';
}
return `Menampilkan ${showingCount.value} bahan baku dari ${total}`;
});
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'bahan baku');
function rowNumber(index: number): number {
return (props.firstItem ?? 1) + index;
return groupedTableRowNumber(props.firstItem, index);
}
</script>
@ -88,18 +75,13 @@ function rowNumber(index: number): number {
</p>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span>
Total stok <strong class="text-primary"> {{material.prices.reduce((acc, price) =>
acc +
Number(price.stock), 0)
}}
{{ material.unit_abbreviation }}
Total stok <strong class="text-primary">
{{ material.total_stock_formatted }}
</strong>
</span>
<span>
Total harga <strong class="text-primary"> Rp
{{formatRupiah(material.prices.reduce((acc, price) => acc +
Number(price.stock) * Number(price.price), 0))
}}
Total harga <strong class="text-primary">
{{ material.total_inventory_value_formatted }}
</strong>
</span>
</div>
@ -148,21 +130,10 @@ function rowNumber(index: number): number {
<DataTableEmpty v-else />
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-sm">
{{ paginationSummary }}
</p>
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
<Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
:disabled="!link.url || link.active" as-child>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>
<span v-else v-html="link.label" />
</Button>
</div>
</div>
<GroupedTableFooter
:summary="paginationSummary"
:pagination="pagination"
:pagination-links="paginationLinks"
/>
</div>
</template>

View File

@ -1,3 +1,7 @@
import type { Paginated, SelectOption } from '@/types/common';
export type { SelectOption } from '@/types/common';
export type ActivityLogChange = {
field: string;
old: unknown;
@ -26,20 +30,4 @@ export type ActivityLogFilters = {
subject_type?: string;
};
export type PaginatedActivityLogs = {
data: ActivityLogListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};
export type SelectOption = {
value: string;
label: string;
};
export type PaginatedActivityLogs = Paginated<ActivityLogListItem>;

View File

@ -1,4 +1,5 @@
import type { MediaItem } from '@/types/media';
import type { Paginated } from '@/types/common';
export type CashAccount = {
id: number;
@ -31,15 +32,4 @@ export type CashTransactionFormData = {
remove_media_ids: number[];
};
export type PaginatedCashTransactions = {
data: CashTransactionListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};
export type PaginatedCashTransactions = Paginated<CashTransactionListItem>;

View File

@ -1,3 +1,5 @@
import type { Paginated } from '@/types/common';
export type CategoryListItem = {
id: number;
name: string;
@ -14,15 +16,4 @@ export type CategoryFilters = {
direction?: 'asc' | 'desc' | null;
};
export type PaginatedCategories = {
data: CategoryListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};
export type PaginatedCategories = Paginated<CategoryListItem>;

View File

@ -0,0 +1,24 @@
export type PaginationLink = {
url: string | null;
label: string;
active: boolean;
};
export type Paginated<T> = {
data: T[];
current_page: number;
per_page: number;
last_page: number;
total: number;
links: PaginationLink[];
};
export type SelectOption = {
value: number;
label: string;
};
export type EnumOption = {
value: string;
label: string;
};

View File

@ -1,3 +1,5 @@
import type { Paginated } from '@/types/common';
export type CustomerListItem = {
id: number;
name: string;
@ -17,15 +19,4 @@ export type CustomerFilters = {
direction?: 'asc' | 'desc' | null;
};
export type PaginatedCustomers = {
data: CustomerListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};
export type PaginatedCustomers = Paginated<CustomerListItem>;

View File

@ -1,4 +1,5 @@
import type { MediaItem } from '@/types/media';
import type { Paginated } from '@/types/common';
import type { ProductListItem } from '@/types/product';
import type { RawMaterialListItem } from '@/types/raw-material';
@ -70,6 +71,7 @@ export type CuttingListItem = {
total_production_cost_formatted?: string;
total_result_pieces?: number;
total_material_usage?: number;
total_material_usage_summary_formatted?: string;
estimated_cost_per_unit?: number;
estimated_cost_per_unit_formatted?: string;
created_by?: {
@ -149,18 +151,7 @@ export type CuttingEditItem = {
}>;
};
export type PaginatedCuttings = {
data: CuttingListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};
export type PaginatedCuttings = Paginated<CuttingListItem>;
export type CuttingRawMaterialCatalogItem = RawMaterialListItem;
export type CuttingProductCatalogItem = ProductListItem;

View File

@ -1,3 +1,5 @@
import type { Paginated } from '@/types/common';
export type EmployeeAdvancePayment = {
id: number;
employee_advance_id: number;
@ -39,18 +41,7 @@ export type EmployeeAdvanceFormData = {
due_date: string;
};
export type PaginatedEmployeeAdvances = {
data: EmployeeAdvanceListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};
export type PaginatedEmployeeAdvances = Paginated<EmployeeAdvanceListItem>;
export type EmployeeAdvanceSummary = {
outstanding_amount: number;

Some files were not shown because too many files have changed in this diff Show More