feat: overhaul POS management workflows including enhanced purchase, order, cutting, and stock-opname forms with updated master data handling.
This commit is contained in:
parent
b3790f2b2f
commit
816c2a0e2a
@ -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/`.
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ public function rules(): array
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'nama',
|
||||
'name' => 'Nama',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,6 +63,7 @@ public function catalogItems(): array
|
||||
'id' => $variant->id,
|
||||
'name' => $variant->name,
|
||||
'stock' => $variant->stock,
|
||||
'stock_formatted' => $variant->stock_formatted,
|
||||
]),
|
||||
])
|
||||
->toArray();
|
||||
|
||||
108
app/Services/Master/MasterInventoryService.php
Normal file
108
app/Services/Master/MasterInventoryService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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, ',', '.'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
38
resources/js/components/data-table/GroupedTableFooter.vue
Normal file
38
resources/js/components/data-table/GroupedTableFooter.vue
Normal 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>
|
||||
25
resources/js/components/display/RupiahText.vue
Normal file
25
resources/js/components/display/RupiahText.vue
Normal 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>
|
||||
@ -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 {
|
||||
13
resources/js/composables/usePaginationSummary.ts
Normal file
13
resources/js/composables/usePaginationSummary.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
21
resources/js/constants/stok-opname-status.ts
Normal file
21
resources/js/constants/stok-opname-status.ts
Normal 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];
|
||||
}
|
||||
1
resources/js/lib/badge-variant.ts
Normal file
1
resources/js/lib/badge-variant.ts
Normal file
@ -0,0 +1 @@
|
||||
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
23
resources/js/lib/grouped-table.ts
Normal file
23
resources/js/lib/grouped-table.ts
Normal 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}`;
|
||||
}
|
||||
19
resources/js/lib/stok-opname-display.ts
Normal file
19
resources/js/lib/stok-opname-display.ts
Normal 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}`;
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
},
|
||||
|
||||
@ -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,
|
||||
),
|
||||
},
|
||||
|
||||
@ -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,
|
||||
),
|
||||
},
|
||||
|
||||
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
307
resources/js/pages/admin/manage/orders/form/useOrderPosCart.ts
Normal file
307
resources/js/pages/admin/manage/orders/form/useOrderPosCart.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')" />
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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>;
|
||||
|
||||
24
resources/js/types/common.ts
Normal file
24
resources/js/types/common.ts
Normal 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;
|
||||
};
|
||||
@ -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>;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user