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
|
## 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
|
### 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/`.
|
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\Http\Requests\Admin\ToggleStatusRequest;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Services\Master\CategoryService;
|
use App\Services\Master\CategoryService;
|
||||||
|
use App\Services\Master\MasterInventoryService;
|
||||||
use App\Services\Master\ProductService;
|
use App\Services\Master\ProductService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@ -22,6 +23,7 @@ class ProductController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ProductService $productService,
|
private readonly ProductService $productService,
|
||||||
private readonly CategoryService $categoryService,
|
private readonly CategoryService $categoryService,
|
||||||
|
private readonly MasterInventoryService $masterInventoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@ -34,6 +36,7 @@ public function index(Request $request): Response
|
|||||||
return Inertia::render('admin/master/products/Index', [
|
return Inertia::render('admin/master/products/Index', [
|
||||||
'products' => $this->productService->paginateForIndex($tableQuery, $isActive, $categoryId, $stockStatus),
|
'products' => $this->productService->paginateForIndex($tableQuery, $isActive, $categoryId, $stockStatus),
|
||||||
'categories' => $this->categoryService->getSelectOptions(),
|
'categories' => $this->categoryService->getSelectOptions(),
|
||||||
|
'outOfStockGroups' => $this->masterInventoryService->outOfStockProductGroups(),
|
||||||
'filters' => $this->dataTableFilters($tableQuery, [
|
'filters' => $this->dataTableFilters($tableQuery, [
|
||||||
'is_active' => $isActive,
|
'is_active' => $isActive,
|
||||||
'category_id' => $categoryId,
|
'category_id' => $categoryId,
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Http\Requests\Admin\Master\RawMaterialRequest;
|
use App\Http\Requests\Admin\Master\RawMaterialRequest;
|
||||||
use App\Http\Requests\Admin\ToggleStatusRequest;
|
use App\Http\Requests\Admin\ToggleStatusRequest;
|
||||||
use App\Models\RawMaterial;
|
use App\Models\RawMaterial;
|
||||||
|
use App\Services\Master\MasterInventoryService;
|
||||||
use App\Services\Master\RawMaterialService;
|
use App\Services\Master\RawMaterialService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@ -21,6 +22,7 @@ class RawMaterialController extends Controller
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly RawMaterialService $rawMaterialService,
|
private readonly RawMaterialService $rawMaterialService,
|
||||||
|
private readonly MasterInventoryService $masterInventoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@ -31,6 +33,8 @@ public function index(Request $request): Response
|
|||||||
|
|
||||||
return Inertia::render('admin/master/raw-materials/Index', [
|
return Inertia::render('admin/master/raw-materials/Index', [
|
||||||
'rawMaterials' => $this->rawMaterialService->paginateForIndex($tableQuery, $isActive, $stockStatus),
|
'rawMaterials' => $this->rawMaterialService->paginateForIndex($tableQuery, $isActive, $stockStatus),
|
||||||
|
'outOfStockGroups' => $this->masterInventoryService->outOfStockRawMaterialGroups(),
|
||||||
|
'lowStockGroups' => $this->masterInventoryService->lowStockRawMaterialGroups(),
|
||||||
'filters' => $this->dataTableFilters($tableQuery, [
|
'filters' => $this->dataTableFilters($tableQuery, [
|
||||||
'is_active' => $isActive,
|
'is_active' => $isActive,
|
||||||
'stock_status' => $stockStatus,
|
'stock_status' => $stockStatus,
|
||||||
|
|||||||
@ -39,14 +39,14 @@ public function rules(): array
|
|||||||
public function attributes(): array
|
public function attributes(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'email' => 'email',
|
'email' => 'Email',
|
||||||
'username' => 'username',
|
'username' => 'Username',
|
||||||
'full_name' => 'nama lengkap',
|
'full_name' => 'Nama Lengkap',
|
||||||
'phone_number' => 'nomor telepon',
|
'phone_number' => 'Nomor Telepon',
|
||||||
'gender' => 'jenis kelamin',
|
'gender' => 'Jenis Kelamin',
|
||||||
'birth_date' => 'tanggal lahir',
|
'birth_date' => 'Tanggal Lahir',
|
||||||
'address' => 'alamat',
|
'address' => 'Alamat',
|
||||||
'profile_photo' => 'foto profil',
|
'profile_photo' => 'Foto Profil',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,18 +57,18 @@ public function rules(): array
|
|||||||
public function attributes(): array
|
public function attributes(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'email' => 'email',
|
'email' => 'Email',
|
||||||
'username' => 'username',
|
'username' => 'Username',
|
||||||
'full_name' => 'nama lengkap',
|
'full_name' => 'Nama Lengkap',
|
||||||
'phone_number' => 'nomor telepon',
|
'phone_number' => 'Nomor Telepon',
|
||||||
'gender' => 'jenis kelamin',
|
'gender' => 'Jenis Kelamin',
|
||||||
'birth_date' => 'tanggal lahir',
|
'birth_date' => 'Tanggal Lahir',
|
||||||
'address' => 'alamat',
|
'address' => 'Alamat',
|
||||||
'profile_photo' => 'foto profil',
|
'profile_photo' => 'Foto Profil',
|
||||||
'role' => 'role',
|
'role' => 'Role',
|
||||||
'join_date' => 'tanggal bergabung',
|
'join_date' => 'Tanggal Bergabung',
|
||||||
'employment_status' => 'status kepegawaian',
|
'employment_status' => 'Status Kepegawaian',
|
||||||
'base_salary' => 'gaji pokok',
|
'base_salary' => 'Gaji Pokok',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,17 +62,17 @@ public function rules(): array
|
|||||||
public function attributes(): array
|
public function attributes(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'description' => 'keterangan',
|
'description' => 'Keterangan',
|
||||||
'materials' => 'bahan baku',
|
'materials' => 'Bahan Baku',
|
||||||
'materials.*.raw_material_price_id' => 'bahan baku',
|
'materials.*.raw_material_price_id' => 'Bahan Baku',
|
||||||
'materials.*.material_usage' => 'pemakaian',
|
'materials.*.material_usage' => 'Pemakaian',
|
||||||
'results' => 'hasil produk',
|
'results' => 'Hasil Produk',
|
||||||
'results.*.product_variant_id' => 'varian produk',
|
'results.*.product_variant_id' => 'Varian Produk',
|
||||||
'results.*.cutting_result' => 'hasil',
|
'results.*.cutting_result' => 'Hasil',
|
||||||
'results.*.sampel' => 'sampel',
|
'results.*.sampel' => 'Sampel',
|
||||||
'results.*.hasil_cutting_diluar_sampel' => 'hasil cutting diluar sampel',
|
'results.*.hasil_cutting_diluar_sampel' => 'Diluar Sampel',
|
||||||
'sewing_cost' => 'jasa jahit',
|
'sewing_cost' => 'Jasa Jahit',
|
||||||
'other_cost' => 'biaya lainnya',
|
'other_cost' => 'Biaya Lainnya',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,23 +68,23 @@ public function rules(): array
|
|||||||
public function attributes(): array
|
public function attributes(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'customer_id' => 'pelanggan',
|
'customer_id' => 'Pelanggan',
|
||||||
'marketing_id' => 'marketing',
|
'marketing_id' => 'Marketing',
|
||||||
'channel' => 'channel',
|
'channel' => 'Channel',
|
||||||
'price_type' => 'tipe harga',
|
'price_type' => 'Tipe Harga',
|
||||||
'payment_type' => 'tipe pembayaran',
|
'payment_type' => 'Tipe Pembayaran',
|
||||||
'is_affiliate' => 'afiliasi',
|
'is_affiliate' => 'Pesanan Afiliasi',
|
||||||
'tiktok_order_id' => 'ID pesanan TikTok Shop',
|
'tiktok_order_id' => 'ID Pesanan TikTok Shop',
|
||||||
'shopee_order_id' => 'ID pesanan Shopee',
|
'shopee_order_id' => 'ID Pesanan Shopee',
|
||||||
'discount' => 'diskon',
|
'discount' => 'Diskon',
|
||||||
'nego_price' => 'harga nego',
|
'nego_price' => 'Harga Nego',
|
||||||
'notes' => 'keterangan',
|
'notes' => 'Keterangan',
|
||||||
'status' => 'status pesanan',
|
'status' => 'Status Pesanan',
|
||||||
'items' => 'produk',
|
'items' => 'Produk',
|
||||||
'items.*.product_variant_id' => 'varian produk',
|
'items.*.product_variant_id' => 'Varian Produk',
|
||||||
'items.*.quantity' => 'jumlah',
|
'items.*.quantity' => 'Jumlah',
|
||||||
'items.*.stock_quality' => 'kualitas stok',
|
'items.*.stock_quality' => 'Kualitas Stok',
|
||||||
...$this->photoUploadAttributes('bukti transaksi'),
|
...$this->photoUploadAttributes('Bukti Transaksi'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,14 +52,14 @@ public function rules(): array
|
|||||||
public function attributes(): array
|
public function attributes(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'supplier_id' => 'supplier',
|
'supplier_id' => 'Supplier',
|
||||||
'discount' => 'diskon',
|
'discount' => 'Diskon',
|
||||||
'shipping_cost' => 'ongkir',
|
'shipping_cost' => 'Ongkir',
|
||||||
'notes' => 'keterangan',
|
'notes' => 'Keterangan',
|
||||||
'items' => 'bahan baku',
|
'items' => 'Bahan Baku',
|
||||||
'items.*.raw_material_price_id' => 'bahan baku',
|
'items.*.raw_material_price_id' => 'Bahan Baku',
|
||||||
'items.*.quantity' => 'jumlah',
|
'items.*.quantity' => 'Jumlah',
|
||||||
...$this->photoUploadAttributes('bukti transaksi'),
|
...$this->photoUploadAttributes('Bukti Transaksi'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,4 +40,16 @@ public function messages(): array
|
|||||||
'items.*.physical_stock.min' => 'Stok fisik tidak boleh negatif.',
|
'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
|
public function attributes(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => 'nama',
|
'name' => 'Nama',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,9 +35,9 @@ public function rules(): array
|
|||||||
public function attributes(): array
|
public function attributes(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => 'nama',
|
'name' => 'Nama',
|
||||||
'phone_number' => 'nomor telepon',
|
'phone_number' => 'Nomor Telepon',
|
||||||
'address' => 'alamat',
|
'address' => 'Alamat',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,15 +53,15 @@ public function rules(): array
|
|||||||
public function attributes(): array
|
public function attributes(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => 'nama produk',
|
'name' => 'Nama Produk',
|
||||||
'description' => 'deskripsi',
|
'description' => 'Deskripsi',
|
||||||
'category_ids' => 'kategori',
|
'category_ids' => 'Kategori',
|
||||||
'category_ids.*' => 'kategori',
|
'category_ids.*' => 'Kategori',
|
||||||
'variants' => 'varian',
|
'variants' => 'Varian',
|
||||||
'variants.*.name' => 'nama varian',
|
'variants.*.name' => 'Nama Varian',
|
||||||
'variants.*.stock' => 'stok',
|
'variants.*.stock' => 'Stok',
|
||||||
'variants.*.stock_retail' => 'stok ecer',
|
'variants.*.stock_retail' => 'Stok Ecer',
|
||||||
...$this->variantImageAttributes('variants', 'foto varian'),
|
...$this->variantImageAttributes('variants', 'Foto Varian'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,13 +51,13 @@ public function rules(): array
|
|||||||
public function attributes(): array
|
public function attributes(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => 'nama bahan baku',
|
'name' => 'Nama Bahan Baku',
|
||||||
'unit' => 'satuan',
|
'unit' => 'Satuan',
|
||||||
'prices' => 'varian',
|
'prices' => 'Varian',
|
||||||
'prices.*.variant' => 'nama varian',
|
'prices.*.variant' => 'Nama Varian',
|
||||||
'prices.*.price' => 'harga',
|
'prices.*.price' => 'Harga',
|
||||||
'prices.*.stock' => 'stok',
|
'prices.*.stock' => 'Stok',
|
||||||
...$this->variantImageAttributes('prices', 'foto varian'),
|
...$this->variantImageAttributes('prices', 'Foto Varian'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,9 +35,9 @@ public function rules(): array
|
|||||||
public function attributes(): array
|
public function attributes(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => 'nama',
|
'name' => 'Nama',
|
||||||
'phone_number' => 'nomor telepon',
|
'phone_number' => 'Nomor Telepon',
|
||||||
'address' => 'alamat',
|
'address' => 'Alamat',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,11 @@
|
|||||||
use App\Enums\OwnerVerificationStatus;
|
use App\Enums\OwnerVerificationStatus;
|
||||||
use App\Models\Concerns\HasPendingOwnerVerification;
|
use App\Models\Concerns\HasPendingOwnerVerification;
|
||||||
use App\Models\Concerns\InteractsWithActivityLog;
|
use App\Models\Concerns\InteractsWithActivityLog;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -23,6 +25,7 @@
|
|||||||
* @method static \Illuminate\Database\Eloquent\Builder inactive()
|
* @method static \Illuminate\Database\Eloquent\Builder inactive()
|
||||||
*/
|
*/
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
|
#[Appends(['total_stock_formatted', 'total_reject_stock_formatted', 'total_retail_stock_formatted'])]
|
||||||
#[Sluggable(from: 'name', to: 'slug')]
|
#[Sluggable(from: 'name', to: 'slug')]
|
||||||
class Product extends Model
|
class Product extends Model
|
||||||
{
|
{
|
||||||
@ -51,7 +54,29 @@ protected function inactive(Builder $query): void
|
|||||||
$query->where('is_active', false);
|
$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
|
public static function getActiveWithVariantsAndCategories(): Collection
|
||||||
{
|
{
|
||||||
return self::query()
|
return self::query()
|
||||||
@ -65,7 +90,7 @@ public static function getActiveWithVariantsAndCategories(): Collection
|
|||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Relation
|
// 6. Relation
|
||||||
public function categories(): BelongsToMany
|
public function categories(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Category::class, 'product_categories');
|
return $this->belongsToMany(Category::class, 'product_categories');
|
||||||
|
|||||||
@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
use App\Models\Concerns\HasModuleMedia;
|
use App\Models\Concerns\HasModuleMedia;
|
||||||
use App\Models\Concerns\InteractsWithActivityLog;
|
use App\Models\Concerns\InteractsWithActivityLog;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@ -13,6 +15,7 @@
|
|||||||
use Spatie\MediaLibrary\HasMedia;
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
|
#[Appends(['stock_formatted', 'reject_stock_formatted', 'stock_retail_formatted'])]
|
||||||
class ProductVariant extends Model implements HasMedia
|
class ProductVariant extends Model implements HasMedia
|
||||||
{
|
{
|
||||||
// 1. Use Trait
|
// 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
|
public static function mediaModuleName(): string
|
||||||
{
|
{
|
||||||
return 'product';
|
return 'product';
|
||||||
@ -46,7 +71,7 @@ public function registerMediaCollections(): void
|
|||||||
$this->addMediaCollection('images');
|
$this->addMediaCollection('images');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Relation
|
// 6. Relation
|
||||||
public function cuttingResultPrices(): HasMany
|
public function cuttingResultPrices(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(CuttingResultPrice::class);
|
return $this->hasMany(CuttingResultPrice::class);
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
#[Appends(['unit_abbreviation', 'unit_label'])]
|
#[Appends(['unit_abbreviation', 'unit_label', 'total_stock_formatted', 'total_inventory_value_formatted'])]
|
||||||
class RawMaterial extends Model
|
class RawMaterial extends Model
|
||||||
{
|
{
|
||||||
// 1. Use Trait
|
// 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
|
// 5. Relation
|
||||||
public function ownerVerificationRequests(): MorphMany
|
public function ownerVerificationRequests(): MorphMany
|
||||||
{
|
{
|
||||||
|
|||||||
@ -890,10 +890,30 @@ private function formatQuantityInput(float $value): string
|
|||||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
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
|
private function appendCostPreview(Cutting $cutting): void
|
||||||
{
|
{
|
||||||
$cutting->setAttribute('total_result_pieces', (int) $cutting->results->sum('cutting_result'));
|
$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', (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);
|
$totalMaterialCost = $cutting->total_material_cost ?? $this->calculateTotalMaterialCost($cutting);
|
||||||
$sewingCost = (int) ($cutting->sewing_cost ?? 0);
|
$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('available_actions', $actions);
|
||||||
$order->setAttribute('is_editable', $order->status->isEditable());
|
$order->setAttribute('is_editable', $order->status->isEditable());
|
||||||
|
$order->setAttribute(
|
||||||
|
'marketplace_settings_snapshot',
|
||||||
|
$this->enrichMarketplaceSnapshot($order->marketplace_settings_snapshot),
|
||||||
|
);
|
||||||
|
|
||||||
return $order;
|
return $order;
|
||||||
});
|
});
|
||||||
@ -271,6 +275,10 @@ public function findForShow(Order $order): Order
|
|||||||
|
|
||||||
$order->setAttribute('available_actions', $availableActions);
|
$order->setAttribute('available_actions', $availableActions);
|
||||||
$order->setAttribute('is_editable', $order->status->isEditable());
|
$order->setAttribute('is_editable', $order->status->isEditable());
|
||||||
|
$order->setAttribute(
|
||||||
|
'marketplace_settings_snapshot',
|
||||||
|
$this->enrichMarketplaceSnapshot($order->marketplace_settings_snapshot),
|
||||||
|
);
|
||||||
|
|
||||||
return $order;
|
return $order;
|
||||||
}
|
}
|
||||||
@ -895,4 +903,17 @@ private function presentVariantPricesFromCollection(int $variantId, Collection $
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->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,
|
'id' => $variant->id,
|
||||||
'name' => $variant->name,
|
'name' => $variant->name,
|
||||||
'stock' => $variant->stock,
|
'stock' => $variant->stock,
|
||||||
|
'stock_formatted' => $variant->stock_formatted,
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->toArray();
|
->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,
|
'is_affiliate' => $isAffiliate,
|
||||||
'fees' => $feeSnapshot['fees'],
|
'fees' => $feeSnapshot['fees'],
|
||||||
'base_amount' => $totalAmount,
|
'base_amount' => $totalAmount,
|
||||||
|
'base_amount_formatted' => 'Rp '.number_format($totalAmount, 0, ',', '.'),
|
||||||
'results' => $calculation['results'],
|
'results' => $calculation['results'],
|
||||||
'total_fee_amount' => $calculation['total_fee_amount'],
|
'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' => $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 { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table';
|
||||||
import { computed, provide } from 'vue';
|
import { computed, provide } from 'vue';
|
||||||
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
||||||
|
import { buildPaginationSummary } from '@/lib/grouped-table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@ -109,20 +110,13 @@ provide('data-table-sort', {
|
|||||||
|
|
||||||
const showingCount = computed(() => props.paginationDisplayedCount ?? props.data.length);
|
const showingCount = computed(() => props.paginationDisplayedCount ?? props.data.length);
|
||||||
|
|
||||||
const paginationSummary = computed(() => {
|
const paginationSummary = computed(() =>
|
||||||
if (!props.pagination) {
|
buildPaginationSummary(
|
||||||
return null;
|
props.pagination,
|
||||||
}
|
showingCount.value,
|
||||||
|
props.paginationItemLabel,
|
||||||
const { total } = props.pagination;
|
),
|
||||||
const label = props.paginationItemLabel;
|
);
|
||||||
|
|
||||||
if (total === 0) {
|
|
||||||
return `Menampilkan 0 ${label}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Menampilkan ${showingCount.value} ${label} dari ${total}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
function resolveRowSpan(row: TData, columnId: string): number | undefined {
|
function resolveRowSpan(row: TData, columnId: string): number | undefined {
|
||||||
const column = resolvedColumns.value.find((item) => {
|
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 { router } from '@inertiajs/vue3';
|
||||||
import { ChevronDown, PackageX, TriangleAlert } from '@lucide/vue';
|
import { ChevronDown, PackageX, TriangleAlert } from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import PosCatalogCard from '@/pages/admin/manage/shared/PosCatalogCard.vue';
|
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||||
import PosCatalogVariantThumb from '@/pages/admin/manage/shared/PosCatalogVariantThumb.vue';
|
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
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 = {
|
export const CuttingStatus = {
|
||||||
IN_PROGRESS: 'in_progress',
|
IN_PROGRESS: 'in_progress',
|
||||||
COMPLETED: 'completed',
|
COMPLETED: 'completed',
|
||||||
@ -5,3 +9,53 @@ export const CuttingStatus = {
|
|||||||
VERIFIED: 'verified',
|
VERIFIED: 'verified',
|
||||||
REJECTED: 'rejected',
|
REJECTED: 'rejected',
|
||||||
} as const;
|
} 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 = {
|
export const EmployeeAdvanceStatus = {
|
||||||
PENDING: 'pending',
|
PENDING: 'pending',
|
||||||
APPROVED: 'approved',
|
APPROVED: 'approved',
|
||||||
@ -5,3 +7,18 @@ export const EmployeeAdvanceStatus = {
|
|||||||
PARTIALLY_PAID: 'partially_paid',
|
PARTIALLY_PAID: 'partially_paid',
|
||||||
PAID: 'paid',
|
PAID: 'paid',
|
||||||
} as const;
|
} 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 './setting-section';
|
||||||
export * from './appearance-mode';
|
export * from './appearance-mode';
|
||||||
export * from './catalog-sort-option';
|
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 = {
|
export const LeaveRequestStatus = {
|
||||||
PENDING: 'pending',
|
PENDING: 'pending',
|
||||||
APPROVED: 'approved',
|
APPROVED: 'approved',
|
||||||
REJECTED: 'rejected',
|
REJECTED: 'rejected',
|
||||||
} as const;
|
} 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 = {
|
export const OrderPaymentType = {
|
||||||
CASH: 'cash',
|
CASH: 'cash',
|
||||||
|
TRANSFER: 'transfer',
|
||||||
|
QRIS: 'qris',
|
||||||
MARKETPLACE: 'marketplace',
|
MARKETPLACE: 'marketplace',
|
||||||
} as const;
|
} 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 = {
|
export const OrderStatus = {
|
||||||
PENDING: 'pending',
|
PENDING: 'pending',
|
||||||
PROCESSING: 'processing',
|
PROCESSING: 'processing',
|
||||||
COMPLETED: 'completed',
|
COMPLETED: 'completed',
|
||||||
CANCELLED: 'cancelled',
|
CANCELLED: 'cancelled',
|
||||||
} as const;
|
} 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 = {
|
export const PayrollStatus = {
|
||||||
UNPAID: 'unpaid',
|
UNPAID: 'unpaid',
|
||||||
PAID: 'paid',
|
PAID: 'paid',
|
||||||
} as const;
|
} 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 { h } from 'vue';
|
||||||
import { DataTableColumnHeader } from '@/components/data-table';
|
import { DataTableColumnHeader } from '@/components/data-table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 type { EmployeeAdvanceListItem } from '@/types/employee-advance';
|
||||||
import DataTableActions from './data-table-actions.vue';
|
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(
|
export function createColumns(
|
||||||
authEmployeeId: number | null,
|
authEmployeeId: number | null,
|
||||||
onEdit: (employeeAdvance: EmployeeAdvanceListItem) => void,
|
onEdit: (employeeAdvance: EmployeeAdvanceListItem) => void,
|
||||||
@ -78,7 +63,7 @@ export function createColumns(
|
|||||||
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
|
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
|
||||||
cell: ({ row }) => h(
|
cell: ({ row }) => h(
|
||||||
Badge,
|
Badge,
|
||||||
{ variant: statusVariant(row.original.status) },
|
{ variant: employeeAdvanceStatusBadgeVariant(row.original.status) },
|
||||||
() => row.original.status_label,
|
() => row.original.status_label,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,14 +2,10 @@ import type { ColumnDef } from '@tanstack/vue-table';
|
|||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
import { DataTableColumnHeader } from '@/components/data-table';
|
import { DataTableColumnHeader } from '@/components/data-table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 type { PayrollListItem } from '@/types/payroll';
|
||||||
import DataTableActions from './data-table-actions.vue';
|
import DataTableActions from './data-table-actions.vue';
|
||||||
|
|
||||||
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
|
||||||
return status === PayrollStatus.PAID ? 'secondary' : 'outline';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createColumns(
|
export function createColumns(
|
||||||
onAdjust: (payroll: PayrollListItem) => void,
|
onAdjust: (payroll: PayrollListItem) => void,
|
||||||
): ColumnDef<PayrollListItem>[] {
|
): ColumnDef<PayrollListItem>[] {
|
||||||
@ -45,7 +41,7 @@ export function createColumns(
|
|||||||
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
|
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
|
||||||
cell: ({ row }) => h(
|
cell: ({ row }) => h(
|
||||||
Badge,
|
Badge,
|
||||||
{ variant: statusVariant(row.original.status) },
|
{ variant: payrollStatusBadgeVariant(row.original.status) },
|
||||||
() => row.original.status_label,
|
() => row.original.status_label,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,21 +2,10 @@ import type { ColumnDef } from '@tanstack/vue-table';
|
|||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
import { DataTableColumnHeader } from '@/components/data-table';
|
import { DataTableColumnHeader } from '@/components/data-table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 type { LeaveRequestListItem } from '@/types/leave-request';
|
||||||
import DataTableActions from './data-table-actions.vue';
|
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(
|
export function createColumns(
|
||||||
authEmployeeId: number | null,
|
authEmployeeId: number | null,
|
||||||
onEdit: (leaveRequest: LeaveRequestListItem) => void,
|
onEdit: (leaveRequest: LeaveRequestListItem) => void,
|
||||||
@ -55,7 +44,7 @@ export function createColumns(
|
|||||||
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
|
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
|
||||||
cell: ({ row }) => h(
|
cell: ({ row }) => h(
|
||||||
Badge,
|
Badge,
|
||||||
{ variant: statusVariant(row.original.status) },
|
{ variant: leaveRequestStatusBadgeVariant(row.original.status) },
|
||||||
() => row.original.status_label,
|
() => 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">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import DataTableActions from './data-table-actions.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 { 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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -15,6 +13,9 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} 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 { CuttingListItem } from '@/types/cutting';
|
||||||
import type {
|
import type {
|
||||||
DataTablePagination,
|
DataTablePagination,
|
||||||
@ -35,39 +36,10 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showingCount = computed(() => props.cuttings.length);
|
const showingCount = computed(() => props.cuttings.length);
|
||||||
|
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'cutting');
|
||||||
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}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
function rowNumber(index: number): number {
|
function rowNumber(index: number): number {
|
||||||
return (props.firstItem ?? 1) + index;
|
return groupedTableRowNumber(props.firstItem, 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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupedCuttingMaterials {
|
interface GroupedCuttingMaterials {
|
||||||
@ -129,32 +101,6 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
|||||||
return Object.values(groups);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -174,7 +120,7 @@ function getGroupedMaterialUsage(materials: any[]): MaterialUsageGroup[] {
|
|||||||
<h3 class="font-medium leading-tight">
|
<h3 class="font-medium leading-tight">
|
||||||
Cutting #{{ cutting.id }}
|
Cutting #{{ cutting.id }}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge :variant="statusVariant(cutting.status)">
|
<Badge :variant="cuttingStatusBadgeVariant(cutting.status)">
|
||||||
{{ cutting.status_label }}
|
{{ cutting.status_label }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@ -188,7 +134,7 @@ function getGroupedMaterialUsage(materials: any[]): MaterialUsageGroup[] {
|
|||||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
<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 ??
|
<span>Total Hasil Cutting <strong class="text-primary">{{ cutting.total_result_pieces ??
|
||||||
0 }} pcs</strong></span>
|
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">{{
|
<span>Biaya Bahan <strong class="text-primary">{{
|
||||||
cutting.total_material_cost_formatted ?? 'Rp 0' }}</strong></span>
|
cutting.total_material_cost_formatted ?? 'Rp 0' }}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
@ -292,21 +238,10 @@ function getGroupedMaterialUsage(materials: any[]): MaterialUsageGroup[] {
|
|||||||
|
|
||||||
<DataTableEmpty v-else description="Silakan lakukan pencarian untuk menemukan data yang Anda cari." />
|
<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">
|
<GroupedTableFooter
|
||||||
<p class="text-muted-foreground text-sm">
|
:summary="paginationSummary"
|
||||||
{{ paginationSummary }}
|
:pagination="pagination"
|
||||||
</p>
|
:pagination-links="paginationLinks"
|
||||||
|
/>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { router, useForm } from '@inertiajs/vue3';
|
import { router, useForm } from '@inertiajs/vue3';
|
||||||
import { Check, RotateCcw, Scissors, X } from '@lucide/vue';
|
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { RowDeleteAction, RowEditAction, RowStatusAction } from '@/components/button';
|
import { RowDeleteAction, RowEditAction, RowStatusAction } from '@/components/button';
|
||||||
@ -28,7 +27,11 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useCan } from '@/composables/useCan';
|
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 { FIELD_LIMITS } from '@/lib/field-limits';
|
||||||
import { parseRupiah } from '@/lib/rupiah';
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
import { edit, destroy, transition_status } from '@/routes/admin/manage/cuttings';
|
import { edit, destroy, transition_status } from '@/routes/admin/manage/cuttings';
|
||||||
@ -158,19 +161,7 @@ function canPerformAction(action: CuttingStatusAction): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusConfirmDescription(action: CuttingStatusAction): string {
|
function statusConfirmDescription(action: CuttingStatusAction): string {
|
||||||
if (action.status === CuttingStatus.COMPLETED) {
|
return cuttingStatusTransitionConfirmDescription(action.status);
|
||||||
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 '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openStatusConfirm(action: CuttingStatusAction) {
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -292,7 +268,7 @@ function actionIcon(status: string) {
|
|||||||
action-label="Verifikasi Stok" />
|
action-label="Verifikasi Stok" />
|
||||||
|
|
||||||
<template v-for="action in availableActions" :key="action.status">
|
<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)" />
|
:label="action.label" :destructive="action.destructive" @click="openStatusConfirm(action)" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import { Head, usePage } from '@inertiajs/vue3';
|
|||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { index, create } from '@/routes/admin/manage/orders';
|
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';
|
import OrderGroupedTable from './table/OrderGroupedTable.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
@ -1,21 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, router } from '@inertiajs/vue3';
|
import { Head, Link, router } from '@inertiajs/vue3';
|
||||||
import {
|
import {
|
||||||
Check,
|
|
||||||
CreditCard,
|
CreditCard,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
Pencil,
|
Pencil,
|
||||||
Phone,
|
Phone,
|
||||||
Receipt,
|
Receipt,
|
||||||
Send,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
X,
|
|
||||||
} from '@lucide/vue';
|
} from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
|
import { RowDeleteAction, RowDetailAction, RowEditAction, RowStatusAction } from '@/components/button';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||||
|
import OrderPrintButton from '@/components/order/OrderPrintButton.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@ -31,12 +30,15 @@ import { useCan } from '@/composables/useCan';
|
|||||||
import { useDestroy } from '@/composables/useDestroy';
|
import { useDestroy } from '@/composables/useDestroy';
|
||||||
import { MarketplaceFeeScope } from '@/constants/marketplace-fee-scope';
|
import { MarketplaceFeeScope } from '@/constants/marketplace-fee-scope';
|
||||||
import { MarketplaceFeeValueType } from '@/constants/marketplace-fee-value-type';
|
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 { StockQuality } from '@/constants/stock-quality';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
import { index, edit, destroy, transition_status } from '@/routes/admin/manage/orders';
|
import { index, edit, destroy, transition_status } from '@/routes/admin/manage/orders';
|
||||||
import type { OrderDetail, OrderStatusAction } from '@/types/order';
|
import type { OrderDetail, OrderStatusAction } from '@/types/order';
|
||||||
import OrderPrintButton from './form/OrderPrintButton.vue';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
order: OrderDetail;
|
order: OrderDetail;
|
||||||
@ -57,48 +59,12 @@ const { open: deleteConfirmOpen, processing: deleteProcessing, destroy: destroyO
|
|||||||
errorMessage: 'Gagal menghapus pesanan.',
|
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 {
|
function canPerformAction(action: OrderStatusAction): boolean {
|
||||||
return can(action.permission);
|
return can(action.permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusConfirmDescription(action: OrderStatusAction): string {
|
function statusConfirmDescription(action: OrderStatusAction): string {
|
||||||
if (action.status === OrderStatus.PROCESSING) {
|
return orderStatusTransitionConfirmDescription(action.status, props.order.order_number);
|
||||||
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.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openStatusConfirm(action: OrderStatusAction) {
|
function openStatusConfirm(action: OrderStatusAction) {
|
||||||
@ -143,7 +109,7 @@ const summaryRows = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (props.order.nego_price != null && props.order.nego_price > 0) {
|
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;
|
return rows;
|
||||||
@ -234,7 +200,7 @@ const feeEntries = computed(() => {
|
|||||||
:variant="action.destructive ? 'outline' : 'default'"
|
:variant="action.destructive ? 'outline' : 'default'"
|
||||||
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
|
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
|
||||||
@click="openStatusConfirm(action)">
|
@click="openStatusConfirm(action)">
|
||||||
<component :is="actionIcon(action.status)" class="size-3.5" />
|
<component :is="orderStatusActionIcon(action.status)" class="size-3.5" />
|
||||||
{{ action.label }}
|
{{ action.label }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
@ -276,7 +242,7 @@ const feeEntries = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-muted-foreground text-sm">Status</p>
|
<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 }}
|
{{ order.status_label }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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">
|
<script setup lang="ts">
|
||||||
import { useForm, usePage } from '@inertiajs/vue3';
|
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 { computed, ref, watch } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyTitle,
|
|
||||||
} from '@/components/ui/empty';
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
FieldLabel,
|
|
||||||
FieldSet,
|
FieldSet,
|
||||||
} from '@/components/ui/field';
|
} 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 { useCan } from '@/composables/useCan';
|
||||||
import { OrderChannel } from '@/constants/order-channel';
|
import { OrderChannel } from '@/constants/order-channel';
|
||||||
import { OrderPaymentType } from '@/constants/order-payment-type';
|
import { OrderPaymentType } from '@/constants/order-payment-type';
|
||||||
import { OrderStatus } from '@/constants/order-status';
|
import { OrderStatus } from '@/constants/order-status';
|
||||||
import { PaperSize } from '@/constants/paper-size';
|
import { PaperSize } from '@/constants/paper-size';
|
||||||
import { StockQuality } from '@/constants/stock-quality';
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
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 type { Auth } from '@/types/auth';
|
import type { Auth } from '@/types/auth';
|
||||||
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||||
import type { MediaItem, MediaUploadState } 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 { 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 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<{
|
const props = defineProps<{
|
||||||
customers: SelectOption[];
|
customers: SelectOption[];
|
||||||
@ -95,34 +60,14 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isCreateMode = computed(() => props.method === 'post');
|
const isCreateMode = computed(() => props.method === 'post');
|
||||||
|
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const authUser = computed(() => (page.props.auth as Auth).user);
|
const authUser = computed(() => (page.props.auth as Auth).user);
|
||||||
const isMarketingUser = computed(() =>
|
const isMarketingUser = computed(() =>
|
||||||
(authUser.value?.roles?.includes('marketing-offline') || authUser.value?.roles?.includes('marketing-online')) ?? false,
|
(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(() =>
|
const defaultMarketingId = computed(() =>
|
||||||
isMarketingUser.value && authUser.value ? String(authUser.value.id) : '',
|
isMarketingUser.value && authUser.value ? String(authUser.value.id) : '',
|
||||||
);
|
);
|
||||||
@ -132,19 +77,66 @@ const form = useForm({
|
|||||||
marketing_id: defaultMarketingId.value || 'none',
|
marketing_id: defaultMarketingId.value || 'none',
|
||||||
channel: 'store',
|
channel: 'store',
|
||||||
price_type: 'retail',
|
price_type: 'retail',
|
||||||
payment_type: OrderPaymentType.CASH,
|
payment_type: OrderPaymentType.CASH as string,
|
||||||
is_affiliate: false,
|
is_affiliate: false,
|
||||||
tiktok_order_id: '',
|
tiktok_order_id: '',
|
||||||
shopee_order_id: '',
|
shopee_order_id: '',
|
||||||
discount: '',
|
discount: '',
|
||||||
nego_price: '',
|
nego_price: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
status: OrderStatus.PENDING,
|
status: OrderStatus.PENDING as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isStoreChannel = computed(() => form.channel === 'store');
|
const isStoreChannel = computed(() => form.channel === 'store');
|
||||||
const isMarketplaceChannel = computed(() => form.channel === OrderChannel.SHOPEE || form.channel === OrderChannel.TIKTOK);
|
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() {
|
function populateForm() {
|
||||||
if (!props.initialData) {
|
if (!props.initialData) {
|
||||||
@ -166,29 +158,11 @@ function populateForm() {
|
|||||||
photoState.value = createMediaUploadState(
|
photoState.value = createMediaUploadState(
|
||||||
props.initialData.photos ? [props.initialData.photos] : [],
|
props.initialData.photos ? [props.initialData.photos] : [],
|
||||||
);
|
);
|
||||||
cart.value = props.initialData.items.map((item) => ({
|
setCart(props.initialData.items);
|
||||||
...item,
|
|
||||||
stock_quality: item.stock_quality ?? StockQuality.GOOD,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(() => props.initialData, populateForm, { immediate: true });
|
||||||
() => props.initialData,
|
loadDraftItems(props.draftItems ?? []);
|
||||||
() => {
|
|
||||||
populateForm();
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
function populateDraftItems() {
|
|
||||||
if (!isCreateMode.value || !props.draftItems?.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cart.value = props.draftItems.map((item) => ({ ...item }));
|
|
||||||
}
|
|
||||||
|
|
||||||
populateDraftItems();
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => form.channel,
|
() => 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 {
|
function buildFormData(): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
@ -467,7 +200,6 @@ function buildFormData(): FormData {
|
|||||||
formData.append('marketing_id', String(authUser.value.id));
|
formData.append('marketing_id', String(authUser.value.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// For cashier, force store channel with retail price type and cash payment
|
|
||||||
if (isCashierUser.value) {
|
if (isCashierUser.value) {
|
||||||
formData.append('channel', 'store');
|
formData.append('channel', 'store');
|
||||||
formData.append('price_type', 'retail');
|
formData.append('price_type', 'retail');
|
||||||
@ -510,7 +242,7 @@ function buildFormData(): FormData {
|
|||||||
cart.value.forEach((item, index) => {
|
cart.value.forEach((item, index) => {
|
||||||
formData.append(`items[${index}][product_variant_id]`, String(item.product_variant_id));
|
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}][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, {
|
form.transform(() => payload).post(props.submitUrl, {
|
||||||
forceFormData: true,
|
forceFormData: true,
|
||||||
onError: (errors: any) => {
|
onError: (errors: Record<string, string>) => {
|
||||||
if (errors.system) {
|
if (errors.system) {
|
||||||
toast.error(errors.system);
|
toast.error(errors.system);
|
||||||
}
|
}
|
||||||
@ -539,98 +271,16 @@ function submit() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid gap-4 xl:grid-cols-[1fr_380px]">
|
<div class="grid gap-4 xl:grid-cols-[1fr_380px]">
|
||||||
<Card class="min-w-0">
|
<OrderPosCatalogPanel
|
||||||
<CardHeader class="pb-3">
|
v-model:search="search"
|
||||||
<CardTitle class="text-base">Pilih Produk</CardTitle>
|
v-model:selected-stock-quality="selectedStockQuality"
|
||||||
</CardHeader>
|
:is-cashier-user="isCashierUser"
|
||||||
<CardContent class="space-y-4">
|
:filtered-catalog="filteredCatalog"
|
||||||
<div class="flex gap-4">
|
:get-variant-price="getVariantPrice"
|
||||||
<div class="relative flex-1">
|
:get-cart-item="getCartItem"
|
||||||
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
@add-to-cart="addToCart"
|
||||||
<Input v-model="search" placeholder="Cari produk..." class="pl-9" />
|
@decrease-qty="decreaseVariantQty"
|
||||||
</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>
|
|
||||||
|
|
||||||
<Card class="h-fit xl:sticky xl:top-4">
|
<Card class="h-fit xl:sticky xl:top-4">
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-3">
|
||||||
@ -639,9 +289,12 @@ function submit() {
|
|||||||
<ShoppingCart class="size-4" />
|
<ShoppingCart class="size-4" />
|
||||||
Ringkasan Pesanan
|
Ringkasan Pesanan
|
||||||
</span>
|
</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"
|
class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80"
|
||||||
@click="cartDetailOpen = true">
|
@click="cartDetailOpen = true"
|
||||||
|
>
|
||||||
Lihat Detail
|
Lihat Detail
|
||||||
</button>
|
</button>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@ -650,284 +303,44 @@ function submit() {
|
|||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit">
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<FieldSet class="grid gap-4">
|
<FieldSet class="grid gap-4">
|
||||||
<Field v-if="!isCashierUser">
|
<OrderPosMetadataFields
|
||||||
<FieldLabel for="channel" required>Channel</FieldLabel>
|
:form="form"
|
||||||
<Select v-model="form.channel">
|
:channels="channels"
|
||||||
<SelectTrigger id="channel" class="w-full">
|
:store-price-types="storePriceTypes"
|
||||||
<SelectValue placeholder="Pilih channel" />
|
:payment-types="paymentTypes"
|
||||||
</SelectTrigger>
|
:customers="customers"
|
||||||
<SelectContent>
|
:marketings="marketings"
|
||||||
<SelectGroup>
|
:is-cashier-user="isCashierUser"
|
||||||
<SelectItem v-for="channel in channels" :key="channel.value"
|
:is-store-channel="isStoreChannel"
|
||||||
:value="channel.value">
|
:is-marketplace-channel="isMarketplaceChannel"
|
||||||
{{ channel.label }}
|
:is-marketing-user="isMarketingUser"
|
||||||
</SelectItem>
|
:can-create-customer="can('customers.create')"
|
||||||
</SelectGroup>
|
@open-customer-form="customerFormOpen = true"
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
|
||||||
<FieldError :errors="formErrors(form, 'channel')" />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field v-if="isStoreChannel && !isCashierUser">
|
<OrderPosCartSummaryItems
|
||||||
<FieldLabel for="price_type" required>Tipe Harga</FieldLabel>
|
:cart="cart"
|
||||||
<Select v-model="form.price_type">
|
:stock-quality-label="stockQualityLabel"
|
||||||
<SelectTrigger id="price_type" class="w-full">
|
:line-subtotal="lineSubtotal"
|
||||||
<SelectValue placeholder="Pilih tipe harga" />
|
@remove="removeFromCart"
|
||||||
</SelectTrigger>
|
@adjust-quantity="adjustQuantity"
|
||||||
<SelectContent>
|
@sync-quantity="syncCartItemQuantity"
|
||||||
<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">
|
<OrderPosCheckoutSection
|
||||||
<FieldLabel>Tipe Harga</FieldLabel>
|
v-model:print-after-save="printAfterSave"
|
||||||
<div class="flex h-9 items-center rounded-md border bg-muted/40 px-3 text-sm">
|
v-model:selected-paper-size="selectedPaperSize"
|
||||||
{{ form.channel === OrderChannel.SHOPEE ? 'Shopee' : 'TikTok' }}
|
v-model:photo-state="photoState"
|
||||||
</div>
|
:form="form"
|
||||||
</Field>
|
:subtotal="subtotal"
|
||||||
|
:total-amount="totalAmount"
|
||||||
<Field v-if="form.channel === OrderChannel.TIKTOK && !isCashierUser">
|
:show-photo-input="showPhotoInput"
|
||||||
<FieldLabel for="tiktok_order_id" :required="form.channel === OrderChannel.TIKTOK">ID
|
:is-create-mode="isCreateMode"
|
||||||
Pesanan
|
:can-complete="can('orders.complete')"
|
||||||
TikTok Shop</FieldLabel>
|
:can-create="can('orders.create')"
|
||||||
<Input id="tiktok_order_id" v-model="form.tiktok_order_id"
|
:cart-empty="cart.length === 0"
|
||||||
placeholder="Masukkan ID pesanan TikTok Shop" />
|
:submit-label="submitLabel"
|
||||||
<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>
|
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</form>
|
</form>
|
||||||
@ -937,38 +350,11 @@ function submit() {
|
|||||||
|
|
||||||
<CustomerQuickCreateModal v-model:open="customerFormOpen" @created="onCustomerCreated" />
|
<CustomerQuickCreateModal v-model:open="customerFormOpen" @created="onCustomerCreated" />
|
||||||
|
|
||||||
<Dialog v-model:open="cartDetailOpen">
|
<OrderPosCartDetailDialog
|
||||||
<DialogContent class="sm:max-w-lg">
|
v-model:open="cartDetailOpen"
|
||||||
<DialogHeader>
|
:cart="cart"
|
||||||
<DialogTitle>Detail Keranjang</DialogTitle>
|
:stock-quality-label="stockQualityLabel"
|
||||||
</DialogHeader>
|
:line-subtotal="lineSubtotal"
|
||||||
<div class="max-h-96 space-y-3 overflow-y-auto overscroll-y-contain scrollbar-thin">
|
:total-amount="totalAmount"
|
||||||
<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>
|
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import DataTableActions from './data-table-actions.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 DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
||||||
|
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyDescription,
|
EmptyDescription,
|
||||||
@ -21,6 +18,9 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import { usePaginationSummary } from '@/composables/usePaginationSummary';
|
||||||
|
import { orderStatusBadgeVariant } from '@/constants/order-status';
|
||||||
|
import { groupedTableRowNumber } from '@/lib/grouped-table';
|
||||||
import type {
|
import type {
|
||||||
DataTablePagination,
|
DataTablePagination,
|
||||||
DataTablePaginationLink,
|
DataTablePaginationLink,
|
||||||
@ -41,39 +41,10 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showingCount = computed(() => props.orders.length);
|
const showingCount = computed(() => props.orders.length);
|
||||||
|
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'pesanan');
|
||||||
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}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
function rowNumber(index: number): number {
|
function rowNumber(index: number): number {
|
||||||
return (props.firstItem ?? 1) + index;
|
return groupedTableRowNumber(props.firstItem, 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';
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -94,7 +65,7 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
|
|||||||
<h3 class="font-medium leading-tight">
|
<h3 class="font-medium leading-tight">
|
||||||
{{ order.order_number }}
|
{{ order.order_number }}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge :variant="statusVariant(order.status)">
|
<Badge :variant="orderStatusBadgeVariant(order.status)">
|
||||||
{{ order.status_label }}
|
{{ order.status_label }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
@ -131,10 +102,10 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
|
|||||||
<span>Total <strong class="text-primary">{{ order.total_amount_formatted
|
<span>Total <strong class="text-primary">{{ order.total_amount_formatted
|
||||||
}}</strong></span>
|
}}</strong></span>
|
||||||
<span v-if="order.marketplace_settings_snapshot?.total_fee_amount" class="text-destructive">
|
<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>
|
||||||
<span v-if="order.marketplace_settings_snapshot?.net_amount">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="order.notes" class="text-muted-foreground text-sm">
|
<p v-if="order.notes" class="text-muted-foreground text-sm">
|
||||||
@ -197,21 +168,10 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
|
|||||||
</Empty>
|
</Empty>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<GroupedTableFooter
|
||||||
<p class="text-muted-foreground text-sm">
|
:summary="paginationSummary"
|
||||||
{{ paginationSummary }}
|
:pagination="pagination"
|
||||||
</p>
|
:pagination-links="paginationLinks"
|
||||||
|
/>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { router } from '@inertiajs/vue3';
|
import { router } from '@inertiajs/vue3';
|
||||||
import { Check, Send, X } from '@lucide/vue';
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { RowDeleteAction, RowDetailAction, RowEditAction, RowStatusAction } from '@/components/button';
|
import { RowDeleteAction, RowDetailAction, RowEditAction, RowStatusAction } from '@/components/button';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||||
|
import OrderPrintButton from '@/components/order/OrderPrintButton.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useCan } from '@/composables/useCan';
|
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 { show, edit, destroy, transition_status } from '@/routes/admin/manage/orders';
|
||||||
import type { OrderListItem, OrderStatusAction } from '@/types/order';
|
import type { OrderListItem, OrderStatusAction } from '@/types/order';
|
||||||
import OrderPrintButton from '../form/OrderPrintButton.vue';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
order: OrderListItem;
|
order: OrderListItem;
|
||||||
@ -32,15 +34,7 @@ function canPerformAction(action: OrderStatusAction): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusConfirmDescription(action: OrderStatusAction): string {
|
function statusConfirmDescription(action: OrderStatusAction): string {
|
||||||
if (action.status === OrderStatus.PROCESSING) {
|
return orderStatusTransitionConfirmDescription(action.status, props.order.order_number);
|
||||||
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.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openStatusConfirm(action: OrderStatusAction) {
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -92,13 +74,13 @@ function actionIcon(status: string) {
|
|||||||
<OrderPrintButton :order="order" />
|
<OrderPrintButton :order="order" />
|
||||||
|
|
||||||
<template v-for="action in availableActions" :key="action.status">
|
<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)" />
|
:label="action.label" :destructive="action.destructive" @click="openStatusConfirm(action)" />
|
||||||
|
|
||||||
<Button v-else-if="canPerformAction(action)" size="sm" :variant="action.destructive ? 'outline' : 'default'"
|
<Button v-else-if="canPerformAction(action)" size="sm" :variant="action.destructive ? 'outline' : 'default'"
|
||||||
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
|
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
|
||||||
@click="openStatusConfirm(action)">
|
@click="openStatusConfirm(action)">
|
||||||
<component :is="actionIcon(action.status)" class="size-3.5" />
|
<component :is="orderStatusActionIcon(action.status)" class="size-3.5" />
|
||||||
{{ action.label }}
|
{{ action.label }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
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 { computed, ref, watch } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyTitle,
|
|
||||||
} from '@/components/ui/empty';
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
FieldLabel,
|
|
||||||
FieldSet,
|
FieldSet,
|
||||||
} from '@/components/ui/field';
|
} from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
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 { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||||
import type { MediaItem, MediaUploadState } from '@/types/media';
|
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||||
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
|
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
|
||||||
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
|
import PurchasePosCartDetailDialog from './PurchasePosCartDetailDialog.vue';
|
||||||
import PosCatalogVariantThumb from '../../shared/PosCatalogVariantThumb.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<{
|
const props = defineProps<{
|
||||||
suppliers: SelectOption[];
|
suppliers: SelectOption[];
|
||||||
catalog: PurchaseCatalogItem[];
|
catalog: PurchaseCatalogItem[];
|
||||||
@ -68,8 +37,6 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isCreateMode = computed(() => props.method === 'post');
|
const isCreateMode = computed(() => props.method === 'post');
|
||||||
const search = ref('');
|
|
||||||
const cart = ref<PurchaseCartItem[]>([]);
|
|
||||||
const cartDetailOpen = ref(false);
|
const cartDetailOpen = ref(false);
|
||||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||||
|
|
||||||
@ -80,6 +47,29 @@ const form = useForm({
|
|||||||
notes: '',
|
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() {
|
function populateForm() {
|
||||||
if (!props.initialData) {
|
if (!props.initialData) {
|
||||||
return;
|
return;
|
||||||
@ -92,195 +82,11 @@ function populateForm() {
|
|||||||
photoState.value = createMediaUploadState(
|
photoState.value = createMediaUploadState(
|
||||||
props.initialData.photos ? [props.initialData.photos] : [],
|
props.initialData.photos ? [props.initialData.photos] : [],
|
||||||
);
|
);
|
||||||
cart.value = props.initialData.items.map((item) => ({ ...item }));
|
setCart(props.initialData.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(() => props.initialData, populateForm, { immediate: true });
|
||||||
() => props.initialData,
|
loadDraftItems(props.draftItems ?? []);
|
||||||
() => {
|
|
||||||
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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFormData(): FormData {
|
function buildFormData(): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@ -323,7 +129,7 @@ function submit() {
|
|||||||
|
|
||||||
form.transform(() => payload).post(props.submitUrl, {
|
form.transform(() => payload).post(props.submitUrl, {
|
||||||
forceFormData: true,
|
forceFormData: true,
|
||||||
onError: (errors: any) => {
|
onError: (errors: Record<string, string>) => {
|
||||||
if (errors.system) {
|
if (errors.system) {
|
||||||
toast.error(errors.system);
|
toast.error(errors.system);
|
||||||
}
|
}
|
||||||
@ -334,75 +140,13 @@ function submit() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid gap-4 xl:grid-cols-[1fr_380px]">
|
<div class="grid gap-4 xl:grid-cols-[1fr_380px]">
|
||||||
<Card class="min-w-0">
|
<PurchasePosCatalogPanel
|
||||||
<CardHeader class="pb-3">
|
v-model:search="search"
|
||||||
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
|
:filtered-catalog="filteredCatalog"
|
||||||
</CardHeader>
|
:get-cart-item="getCartItem"
|
||||||
<CardContent class="space-y-4">
|
@add-to-cart="addToCart"
|
||||||
<div class="relative">
|
@decrease-qty="decreasePriceQty"
|
||||||
<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>
|
|
||||||
|
|
||||||
<Card class="h-fit xl:sticky xl:top-4">
|
<Card class="h-fit xl:sticky xl:top-4">
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-3">
|
||||||
@ -411,9 +155,12 @@ function submit() {
|
|||||||
<ShoppingCart class="size-4" />
|
<ShoppingCart class="size-4" />
|
||||||
Ringkasan Belanja
|
Ringkasan Belanja
|
||||||
</span>
|
</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"
|
class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80"
|
||||||
@click="cartDetailOpen = true">
|
@click="cartDetailOpen = true"
|
||||||
|
>
|
||||||
Lihat Detail
|
Lihat Detail
|
||||||
</button>
|
</button>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@ -422,123 +169,24 @@ function submit() {
|
|||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit">
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<FieldSet class="grid gap-4">
|
<FieldSet class="grid gap-4">
|
||||||
<Field>
|
<PurchasePosMetadataFields :form="form" :suppliers="suppliers" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div v-if="cart.length === 0"
|
<PurchasePosCartSummaryItems
|
||||||
class="rounded-lg border border-dashed text-center text-sm text-muted-foreground">
|
:cart="cart"
|
||||||
<Empty>
|
:line-subtotal="lineSubtotal"
|
||||||
<EmptyHeader>
|
@remove="removeFromCart"
|
||||||
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
|
@adjust-quantity="adjustQuantity"
|
||||||
<EmptyDescription>
|
@sync-quantity="syncCartItemQuantity"
|
||||||
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">
|
<PurchasePosCheckoutSection
|
||||||
<div v-for="(item, index) in cart" :key="item.raw_material_price_id"
|
v-model:photo-state="photoState"
|
||||||
class="rounded-lg border p-3">
|
:form="form"
|
||||||
<div class="flex gap-3">
|
:subtotal="subtotal"
|
||||||
<div class="min-w-0 flex-1 space-y-2">
|
:total="total"
|
||||||
<div class="flex items-start justify-between gap-2">
|
:cart-empty="cart.length === 0"
|
||||||
<div class="min-w-0">
|
:submit-label="submitLabel"
|
||||||
<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>
|
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</form>
|
</form>
|
||||||
@ -546,34 +194,10 @@ function submit() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog v-model:open="cartDetailOpen">
|
<PurchasePosCartDetailDialog
|
||||||
<DialogContent class="sm:max-w-lg">
|
v-model:open="cartDetailOpen"
|
||||||
<DialogHeader>
|
:cart="cart"
|
||||||
<DialogTitle>Detail Keranjang</DialogTitle>
|
:line-subtotal="lineSubtotal"
|
||||||
</DialogHeader>
|
:total="total"
|
||||||
<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>
|
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import DataTableActions from './data-table-actions.vue';
|
import DataTableActions from './data-table-actions.vue';
|
||||||
import DataTableToolbar from '@/components/data-table/DataTableToolbar.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 MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyDescription,
|
EmptyDescription,
|
||||||
@ -20,11 +19,13 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import { usePaginationSummary } from '@/composables/usePaginationSummary';
|
||||||
|
import { groupedTableRowNumber } from '@/lib/grouped-table';
|
||||||
import type {
|
import type {
|
||||||
DataTablePagination,
|
DataTablePagination,
|
||||||
DataTablePaginationLink,
|
DataTablePaginationLink,
|
||||||
} from '@/types/data-table';
|
} from '@/types/data-table';
|
||||||
import type { PurchaseListItem } from '@/types/purchase';
|
import type { PurchaseItemDetail, PurchaseListItem } from '@/types/purchase';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
purchases: PurchaseListItem[];
|
purchases: PurchaseListItem[];
|
||||||
@ -40,33 +41,20 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showingCount = computed(() => props.purchases.length);
|
const showingCount = computed(() => props.purchases.length);
|
||||||
|
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'belanja');
|
||||||
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}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
function rowNumber(index: number): number {
|
function rowNumber(index: number): number {
|
||||||
return (props.firstItem ?? 1) + index;
|
return groupedTableRowNumber(props.firstItem, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupedPurchaseItems {
|
interface GroupedPurchaseItems {
|
||||||
rawMaterialId: number;
|
rawMaterialId: number;
|
||||||
rawMaterialName: string;
|
rawMaterialName: string;
|
||||||
unitLabel?: string;
|
unitLabel?: string;
|
||||||
items: any[];
|
items: PurchaseItemDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGroupedItems(items: any[]): GroupedPurchaseItems[] {
|
function getGroupedItems(items: PurchaseItemDetail[]): GroupedPurchaseItems[] {
|
||||||
const groups: Record<number, GroupedPurchaseItems> = {};
|
const groups: Record<number, GroupedPurchaseItems> = {};
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
@ -191,21 +179,10 @@ function getGroupedItems(items: any[]): GroupedPurchaseItems[] {
|
|||||||
</Empty>
|
</Empty>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<GroupedTableFooter
|
||||||
<p class="text-muted-foreground text-sm">
|
:summary="paginationSummary"
|
||||||
{{ paginationSummary }}
|
:pagination="pagination"
|
||||||
</p>
|
:pagination-links="paginationLinks"
|
||||||
|
/>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { RowApproveAction } from '@/components/button';
|
import { RowApproveAction } from '@/components/button';
|
||||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
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() {
|
function buildResultPricesPayload() {
|
||||||
return verifyForm.variant_prices.map((row) => ({
|
return verifyForm.variant_prices.map((row) => ({
|
||||||
product_variant_id: row.product_variant_id,
|
product_variant_id: row.product_variant_id,
|
||||||
@ -226,7 +232,7 @@ function submitVerify() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FieldError :errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []" />
|
<FieldError :errors="resultPricesErrors" />
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="stock-verification-note">Catatan Verifikasi</FieldLabel>
|
<FieldLabel for="stock-verification-note">Catatan Verifikasi</FieldLabel>
|
||||||
|
|||||||
@ -1,134 +1,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import BackButton from '@/components/button/BackButton.vue';
|
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 { 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 AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
import type { CatalogProduct } from '@/types/stok-opname';
|
import type { CatalogProduct } from '@/types/stok-opname';
|
||||||
import { Head, router } from '@inertiajs/vue3';
|
import { index, submit } from '@/routes/admin/manage/stok-opnames';
|
||||||
import { useDebounceFn } from '@vueuse/core';
|
import StokOpnameAutoSaveStatus from './form/StokOpnameAutoSaveStatus.vue';
|
||||||
import { CheckCircle, Loader2, Send } from '@lucide/vue';
|
import StokOpnameInfoSection from './form/StokOpnameInfoSection.vue';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import StokOpnameProductTable from './form/StokOpnameProductTable.vue';
|
||||||
import { index, auto_save, submit } from '@/routes/admin/manage/stok-opnames';
|
import { useStokOpnameForm } from './form/useStokOpnameForm';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
catalog: CatalogProduct[];
|
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);
|
const submitting = ref(false);
|
||||||
|
|
||||||
// Flatten all variants into a list
|
const {
|
||||||
interface VariantRow {
|
opnameDate,
|
||||||
product_name: string;
|
notes,
|
||||||
variant_id: number;
|
stokOpnameId,
|
||||||
variant_name: string;
|
saving,
|
||||||
system_stock: number;
|
lastSaved,
|
||||||
physical_stock: number;
|
variantRows,
|
||||||
notes: string;
|
itemsPayload,
|
||||||
}
|
groupedProducts,
|
||||||
|
onRowChange,
|
||||||
const variantRows = ref<VariantRow[]>([]);
|
} = useStokOpnameForm({
|
||||||
|
catalog: () => props.catalog,
|
||||||
onMounted(() => {
|
initialOpnameDate: new Date().toISOString().split('T')[0],
|
||||||
const rows: VariantRow[] = [];
|
initialNotes: '',
|
||||||
for (const product of props.catalog) {
|
initialStokOpnameId: null,
|
||||||
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 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() {
|
function submitForVerification() {
|
||||||
if (!stokOpnameId.value) return;
|
if (!stokOpnameId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
router.post(submit.url(stokOpnameId.value), {}, {
|
router.post(submit.url(stokOpnameId.value), {}, {
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
@ -151,123 +62,19 @@ function submitForVerification() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div v-if="saving" class="text-muted-foreground flex items-center gap-1.5 text-sm">
|
<StokOpnameAutoSaveStatus :saving="saving" :last-saved="lastSaved" />
|
||||||
<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>
|
|
||||||
<BackButton :href="index.url()" />
|
<BackButton :href="index.url()" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<Card>
|
<StokOpnameInfoSection v-model:opname-date="opnameDate" v-model:notes="notes" />
|
||||||
<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">
|
<StokOpnameProductTable
|
||||||
<Label for="notes">Catatan</Label>
|
:grouped-products="groupedProducts"
|
||||||
<Textarea
|
:is-empty="variantRows.length === 0"
|
||||||
id="notes"
|
@row-change="onRowChange"
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<BackButton :href="index.url()" label="Kembali" />
|
<BackButton :href="index.url()" label="Kembali" />
|
||||||
|
|||||||
@ -1,146 +1,51 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import BackButton from '@/components/button/BackButton.vue';
|
import BackButton from '@/components/button/BackButton.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 { Head, router } from '@inertiajs/vue3';
|
||||||
import { useDebounceFn } from '@vueuse/core';
|
import { Loader2, Send } from '@lucide/vue';
|
||||||
import { CheckCircle, Loader2, Send } from '@lucide/vue';
|
import { ref } from 'vue';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { Button } from '@/components/ui/button';
|
||||||
import { index, auto_save, submit } from '@/routes/admin/manage/stok-opnames';
|
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<{
|
const props = defineProps<{
|
||||||
stokOpname: StokOpnameDetail;
|
stokOpname: StokOpnameDetail;
|
||||||
catalog: CatalogProduct[];
|
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);
|
const submitting = ref(false);
|
||||||
|
|
||||||
interface VariantRow {
|
const {
|
||||||
product_name: string;
|
opnameDate,
|
||||||
variant_id: number;
|
notes,
|
||||||
variant_name: string;
|
stokOpnameId,
|
||||||
system_stock: number;
|
saving,
|
||||||
physical_stock: number;
|
lastSaved,
|
||||||
notes: string;
|
variantRows,
|
||||||
}
|
itemsPayload,
|
||||||
|
groupedProducts,
|
||||||
const variantRows = ref<VariantRow[]>([]);
|
onRowChange,
|
||||||
|
} = useStokOpnameForm({
|
||||||
onMounted(() => {
|
catalog: () => props.catalog,
|
||||||
const rows: VariantRow[] = [];
|
initialOpnameDate: props.stokOpname.opname_date,
|
||||||
|
initialNotes: props.stokOpname.notes ?? '',
|
||||||
// Build a map of existing items
|
initialStokOpnameId: props.stokOpname.id,
|
||||||
const existingItems = new Map<number, { physical_stock: number; notes: string }>();
|
existingItems: () => props.stokOpname.items,
|
||||||
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 itemsPayload = computed(() =>
|
const canSubmit = props.stokOpname.status === 'draft' || props.stokOpname.status === 'rejected';
|
||||||
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() {
|
function submitForVerification() {
|
||||||
if (!stokOpnameId.value) return;
|
if (!stokOpnameId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
router.post(submit.url(stokOpnameId.value), {}, {
|
router.post(submit.url(stokOpnameId.value), {}, {
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
@ -158,13 +63,7 @@ function submitForVerification() {
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="text-2xl font-bold tracking-tight">Stok Opname</h2>
|
<h2 class="text-2xl font-bold tracking-tight">Stok Opname</h2>
|
||||||
<Badge
|
<Badge :variant="stokOpnameStatusBadgeVariant(stokOpname.status)">
|
||||||
:variant="
|
|
||||||
stokOpname.status === 'verified' ? 'default' :
|
|
||||||
stokOpname.status === 'pending' ? 'outline' :
|
|
||||||
stokOpname.status === 'rejected' ? 'destructive' : 'secondary'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ stokOpname.status_label }}
|
{{ stokOpname.status_label }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@ -174,129 +73,28 @@ function submitForVerification() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div v-if="saving" class="text-muted-foreground flex items-center gap-1.5 text-sm">
|
<StokOpnameAutoSaveStatus :saving="saving" :last-saved="lastSaved" />
|
||||||
<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>
|
|
||||||
<BackButton :href="index.url()" />
|
<BackButton :href="index.url()" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<Card>
|
<StokOpnameInfoSection
|
||||||
<CardHeader>
|
v-model:opname-date="opnameDate"
|
||||||
<CardTitle>Informasi Dasar</CardTitle>
|
v-model:notes="notes"
|
||||||
</CardHeader>
|
:verification-notes="stokOpname.verification_notes"
|
||||||
<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">
|
<StokOpnameProductTable
|
||||||
<Label for="notes">Catatan</Label>
|
:grouped-products="groupedProducts"
|
||||||
<Textarea
|
:is-empty="variantRows.length === 0"
|
||||||
id="notes"
|
@row-change="onRowChange"
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<BackButton :href="index.url()" label="Kembali" />
|
<BackButton :href="index.url()" label="Kembali" />
|
||||||
<Button
|
<Button
|
||||||
v-if="stokOpname.status === 'draft' || stokOpname.status === 'rejected'"
|
v-if="canSubmit"
|
||||||
:disabled="itemsPayload.length === 0 || submitting"
|
:disabled="itemsPayload.length === 0 || submitting"
|
||||||
@click="submitForVerification"
|
@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 { h } from 'vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DataTableColumnHeader } from '@/components/data-table';
|
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 DataTableActions from './data-table-actions.vue';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const statusVariant: Record<StokOpnameStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
||||||
draft: 'secondary',
|
|
||||||
pending: 'outline',
|
|
||||||
verified: 'default',
|
|
||||||
rejected: 'destructive',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createColumns(
|
export function createColumns(
|
||||||
onEdit: (item: StokOpnameListItem) => void,
|
onEdit: (item: StokOpnameListItem) => void,
|
||||||
onSubmit: (item: StokOpnameListItem) => void,
|
onSubmit: (item: StokOpnameListItem) => void,
|
||||||
@ -30,7 +24,7 @@ export function createColumns(
|
|||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
|
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
|
||||||
cell: ({ row }) => h(Badge, {
|
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'),
|
class: cn(row.original.status === 'verified' && 'bg-green-600 text-white'),
|
||||||
}, () => row.original.status_label),
|
}, () => row.original.status_label),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import { StockStatus } from '@/constants/stock-status';
|
|||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
import type { DataTableFilterDef } from '@/types/data-table';
|
import type { DataTableFilterDef } from '@/types/data-table';
|
||||||
import type { CategoryOption, PaginatedProducts } from '@/types/product';
|
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 { Head } from '@inertiajs/vue3';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { index, create } from '@/routes/admin/master/products';
|
import { index, create } from '@/routes/admin/master/products';
|
||||||
@ -19,6 +21,7 @@ import ProductGroupedTable from './table/ProductGroupedTable.vue';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
products: PaginatedProducts;
|
products: PaginatedProducts;
|
||||||
categories: CategoryOption[];
|
categories: CategoryOption[];
|
||||||
|
outOfStockGroups: MasterOutOfStockGroup[];
|
||||||
filters: {
|
filters: {
|
||||||
search: string;
|
search: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
@ -119,6 +122,13 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<Card class="min-w-0">
|
||||||
<CardContent class="min-w-0">
|
<CardContent class="min-w-0">
|
||||||
<ProductGroupedTable
|
<ProductGroupedTable
|
||||||
|
|||||||
@ -1,42 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Plus, Save, Trash2 } from '@lucide/vue';
|
import { Plus, Save } from '@lucide/vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { useVariantList } from '@/composables/useVariantList';
|
||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
|
||||||
import { formErrors } from '@/lib/form';
|
|
||||||
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
categories: CategoryOption[];
|
categories: CategoryOption[];
|
||||||
initialData?: {
|
initialData?: ProductFormInitialData;
|
||||||
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 }>;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
submitUrl: string;
|
submitUrl: string;
|
||||||
method?: 'post' | 'put';
|
method?: 'post' | 'put';
|
||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
@ -95,6 +73,8 @@ const form = useForm({
|
|||||||
category_ids: props.initialData?.category_ids ?? [],
|
category_ids: props.initialData?.category_ids ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const categoryError = computed(() => form.errors.category_ids);
|
||||||
|
|
||||||
function toggleCategory(categoryId: number, checked: boolean) {
|
function toggleCategory(categoryId: number, checked: boolean) {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!form.category_ids.includes(categoryId)) {
|
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);
|
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 {
|
function buildFormData(): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
@ -125,8 +99,8 @@ function buildFormData(): FormData {
|
|||||||
|
|
||||||
appendToFormData(formData, (formData, index, variant) => {
|
appendToFormData(formData, (formData, index, variant) => {
|
||||||
formData.append(`variants[${index}][name]`, variant.name.trim());
|
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]`, String(Number.parseInt(String(variant.stock), 10) || 0));
|
||||||
formData.append(`variants[${index}][stock_retail]`, String(Number.parseInt(variant.stock_retail, 10) || 0));
|
formData.append(`variants[${index}][stock_retail]`, String(Number.parseInt(String(variant.stock_retail), 10) || 0));
|
||||||
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
||||||
}, props.method);
|
}, props.method);
|
||||||
|
|
||||||
@ -136,7 +110,7 @@ function buildFormData(): FormData {
|
|||||||
function submit() {
|
function submit() {
|
||||||
const options = {
|
const options = {
|
||||||
forceFormData: true,
|
forceFormData: true,
|
||||||
onError: (errors: any) => {
|
onError: (errors: Record<string, string>) => {
|
||||||
if (errors.system) {
|
if (errors.system) {
|
||||||
toast.error(errors.system);
|
toast.error(errors.system);
|
||||||
}
|
}
|
||||||
@ -157,94 +131,31 @@ function submit() {
|
|||||||
<CardTitle>Informasi Produk</CardTitle>
|
<CardTitle>Informasi Produk</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<FieldGroup>
|
<ProductInfoSection
|
||||||
<FieldSet class="grid gap-4">
|
:form="form"
|
||||||
<Field>
|
:categories="categories"
|
||||||
<FieldLabel for="name" required>Nama Produk</FieldLabel>
|
:category-error="categoryError"
|
||||||
<Input id="name" v-model="form.name" type="text" placeholder="Contoh: Midi Olla Dress"
|
@update:name="form.name = $event"
|
||||||
:maxlength="FIELD_LIMITS.name" />
|
@update:description="form.description = $event"
|
||||||
<FieldError :errors="formErrors(form, 'name')" />
|
@toggle-category="toggleCategory"
|
||||||
</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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card v-for="(variant, index) in variants" :key="variant.client_id">
|
<ProductVariantSection
|
||||||
<CardHeader class="flex flex-row items-start justify-between gap-4">
|
v-for="(variant, index) in variants"
|
||||||
<CardTitle>Varian {{ index + 1 }}</CardTitle>
|
:key="variant.client_id"
|
||||||
<div class="flex items-center gap-2">
|
:form="form"
|
||||||
<Button v-if="variants.length > 1" type="button" variant="outline" size="icon"
|
:variant="variant"
|
||||||
class="text-destructive hover:text-destructive size-8"
|
:index="index"
|
||||||
@click="removeVariant(variant.client_id)">
|
:can-remove="variants.length > 1"
|
||||||
<Trash2 class="size-4" />
|
:variant-errors="(clientId, field) => variantErrors(form, clientId, field)"
|
||||||
</Button>
|
@remove="removeVariant(variant.client_id)"
|
||||||
</div>
|
@update:name="setVariantField(variant.client_id, 'name', $event)"
|
||||||
</CardHeader>
|
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
|
||||||
<CardContent>
|
@update:stock-retail="setVariantField(variant.client_id, 'stock_retail', $event)"
|
||||||
<FieldGroup>
|
@update:media="setVariantField(variant.client_id, 'media', $event)"
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Button type="button" variant="outline" @click="addVariant">
|
<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">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
|
||||||
import { ArrowLeftRight } from '@lucide/vue';
|
import { ArrowLeftRight } from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { DataTableEmpty } from '@/components/data-table';
|
import { DataTableEmpty } from '@/components/data-table';
|
||||||
import DataTableToolbar from '@/components/data-table/DataTableToolbar.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 MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||||
import StockRetailTransferModal from '@/components/modal/StockRetailTransferModal.vue';
|
import StockRetailTransferModal from '@/components/modal/StockRetailTransferModal.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -17,6 +17,8 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { useCan } from '@/composables/useCan';
|
import { useCan } from '@/composables/useCan';
|
||||||
|
import { usePaginationSummary } from '@/composables/usePaginationSummary';
|
||||||
|
import { groupedTableRowNumber } from '@/lib/grouped-table';
|
||||||
import type {
|
import type {
|
||||||
DataTableFilterDef,
|
DataTableFilterDef,
|
||||||
DataTablePagination,
|
DataTablePagination,
|
||||||
@ -49,27 +51,10 @@ const emit = defineEmits<{
|
|||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
|
|
||||||
const showingCount = computed(() => props.products.length);
|
const showingCount = computed(() => props.products.length);
|
||||||
|
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'produk');
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowNumber(index: number): number {
|
function rowNumber(index: number): number {
|
||||||
return (props.firstItem ?? 1) + index;
|
return groupedTableRowNumber(props.firstItem, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stock Retail Transfer Modal
|
// 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">
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||||
<span>
|
<span>
|
||||||
Total stok bagus <strong class="text-primary">
|
Total stok bagus <strong class="text-primary">
|
||||||
{{product.variants.reduce((acc, v) => acc + v.stock, 0)}}
|
{{ product.total_stock_formatted }}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Total stok reject <strong class="text-destructive">
|
Total stok reject <strong class="text-destructive">
|
||||||
{{product.variants.reduce((acc, v) => acc + v.reject_stock, 0)}}
|
{{ product.total_reject_stock_formatted }}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Total stok ecer <strong class="text-blue-600">
|
Total stok ecer <strong class="text-blue-600">
|
||||||
{{product.variants.reduce((acc, v) => acc + v.stock_retail, 0)}}
|
{{ product.total_retail_stock_formatted }}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -173,13 +158,13 @@ function onTransferSubmitted() {
|
|||||||
<MediaThumbnailCell :items="variant.images ?? []" />
|
<MediaThumbnailCell :items="variant.images ?? []" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ formatStock(variant.stock) }}
|
{{ variant.stock_formatted }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums text-destructive">
|
<TableCell class="tabular-nums text-destructive">
|
||||||
{{ formatStock(variant.reject_stock) }}
|
{{ variant.reject_stock_formatted }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums text-blue-600">
|
<TableCell class="tabular-nums text-blue-600">
|
||||||
{{ formatStock(variant.stock_retail) }}
|
{{ variant.stock_retail_formatted }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div v-if="variant.prices?.length" class="space-y-0.5 text-xs">
|
<div v-if="variant.prices?.length" class="space-y-0.5 text-xs">
|
||||||
@ -218,22 +203,11 @@ function onTransferSubmitted() {
|
|||||||
|
|
||||||
<DataTableEmpty v-else />
|
<DataTableEmpty v-else />
|
||||||
|
|
||||||
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<GroupedTableFooter
|
||||||
<p class="text-muted-foreground text-sm">
|
:summary="paginationSummary"
|
||||||
{{ paginationSummary }}
|
:pagination="pagination"
|
||||||
</p>
|
:pagination-links="paginationLinks"
|
||||||
|
/>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StockRetailTransferModal
|
<StockRetailTransferModal
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import { StockStatus } from '@/constants/stock-status';
|
|||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
import type { DataTableFilterDef } from '@/types/data-table';
|
import type { DataTableFilterDef } from '@/types/data-table';
|
||||||
import type { PaginatedRawMaterials } from '@/types/raw-material';
|
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 { Head } from '@inertiajs/vue3';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { index, create } from '@/routes/admin/master/raw_materials';
|
import { index, create } from '@/routes/admin/master/raw_materials';
|
||||||
@ -18,6 +20,8 @@ import RawMaterialGroupedTable from './table/RawMaterialGroupedTable.vue';
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
rawMaterials: PaginatedRawMaterials;
|
rawMaterials: PaginatedRawMaterials;
|
||||||
|
outOfStockGroups: MasterOutOfStockGroup[];
|
||||||
|
lowStockGroups: MasterOutOfStockGroup[];
|
||||||
filters: {
|
filters: {
|
||||||
search: string;
|
search: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
@ -108,6 +112,24 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<Card class="min-w-0">
|
||||||
<CardContent class="min-w-0">
|
<CardContent class="min-w-0">
|
||||||
<RawMaterialGroupedTable
|
<RawMaterialGroupedTable
|
||||||
|
|||||||
@ -1,31 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
|
import { Plus, Save } from '@lucide/vue';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { FieldError } from '@/components/ui/field';
|
||||||
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 { useVariantList } from '@/composables/useVariantList';
|
import { useVariantList } from '@/composables/useVariantList';
|
||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
|
||||||
import { formErrors } from '@/lib/form';
|
import { formErrors } from '@/lib/form';
|
||||||
import { parseRupiah } from '@/lib/rupiah';
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
||||||
@ -34,6 +14,9 @@ import type {
|
|||||||
RawMaterialPriceFormItem,
|
RawMaterialPriceFormItem,
|
||||||
RawMaterialUnit,
|
RawMaterialUnit,
|
||||||
} from '@/types/raw-material';
|
} from '@/types/raw-material';
|
||||||
|
import RawMaterialInfoSection from './RawMaterialInfoSection.vue';
|
||||||
|
import RawMaterialSharedPriceSection from './RawMaterialSharedPriceSection.vue';
|
||||||
|
import RawMaterialVariantSection from './RawMaterialVariantSection.vue';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@ -65,7 +48,6 @@ function createClientId(): string {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
items: prices,
|
items: prices,
|
||||||
addItem: addPriceRaw,
|
|
||||||
removeItem: removePrice,
|
removeItem: removePrice,
|
||||||
setField: setPriceField,
|
setField: setPriceField,
|
||||||
appendToFormData,
|
appendToFormData,
|
||||||
@ -186,137 +168,47 @@ function buildFormData(): FormData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
const options = {
|
const payload = buildFormData();
|
||||||
|
|
||||||
|
form.transform(() => payload).post(props.submitUrl, {
|
||||||
forceFormData: true,
|
forceFormData: true,
|
||||||
onError: (errors: any) => {
|
onError: (errors: Record<string, string>) => {
|
||||||
if (errors.system) {
|
if (errors.system) {
|
||||||
toast.error(errors.system);
|
toast.error(errors.system);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const payload = buildFormData();
|
|
||||||
|
|
||||||
form.transform(() => payload).post(props.submitUrl, options);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit">
|
||||||
<div class="grid gap-6">
|
<div class="grid gap-6">
|
||||||
<Card>
|
<RawMaterialInfoSection :form="form" :units="units" :method="method" />
|
||||||
<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'">
|
<RawMaterialSharedPriceSection
|
||||||
<FieldLabel for="unit" required>Satuan</FieldLabel>
|
:form="form"
|
||||||
<Select v-model="form.unit">
|
:prices="prices"
|
||||||
<SelectTrigger id="unit" class="w-full">
|
:use-same-price="useSamePrice"
|
||||||
<SelectValue placeholder="Pilih satuan" />
|
@toggle-use-same-price="toggleUseSamePrice"
|
||||||
</SelectTrigger>
|
@set-shared-price="setSharedPrice"
|
||||||
<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>
|
|
||||||
|
|
||||||
<Card v-if="prices.length > 1">
|
<RawMaterialVariantSection
|
||||||
<CardHeader>
|
v-for="(price, index) in prices"
|
||||||
<CardTitle>Harga Bersama</CardTitle>
|
:key="price.client_id"
|
||||||
</CardHeader>
|
:form="form"
|
||||||
<CardContent>
|
:price="price"
|
||||||
<FieldGroup>
|
:index="index"
|
||||||
<label class="mb-4 flex cursor-pointer items-center gap-2">
|
:total-prices="prices.length"
|
||||||
<input type="checkbox" class="size-4 rounded border-input" :checked="useSamePrice"
|
:use-same-price="useSamePrice"
|
||||||
@change="toggleUseSamePrice(($event.target as HTMLInputElement).checked)">
|
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
|
||||||
<span class="text-sm">Gunakan harga yang sama untuk semua varian</span>
|
@remove="removePrice(price.client_id)"
|
||||||
</label>
|
@apply-price-to-all="applyPriceToAllVariants(price.client_id)"
|
||||||
|
@update:variant="setPriceField(price.client_id, 'variant', $event)"
|
||||||
<Field v-if="useSamePrice && prices[0]">
|
@update:stock="setPriceField(price.client_id, 'stock', $event)"
|
||||||
<FieldLabel :for="`shared_price_${prices[0].client_id}`" required>
|
@update:price="setPriceValue(price.client_id, $event)"
|
||||||
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>
|
|
||||||
|
|
||||||
<FieldError :errors="formErrors(form, 'prices')" />
|
<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">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { DataTableEmpty } from '@/components/data-table';
|
import { DataTableEmpty } from '@/components/data-table';
|
||||||
import DataTableToolbar from '@/components/data-table/DataTableToolbar.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 MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -14,7 +13,8 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { formatRupiah } from '@/lib/rupiah';
|
import { usePaginationSummary } from '@/composables/usePaginationSummary';
|
||||||
|
import { groupedTableRowNumber } from '@/lib/grouped-table';
|
||||||
import type {
|
import type {
|
||||||
DataTableFilterDef,
|
DataTableFilterDef,
|
||||||
DataTablePagination,
|
DataTablePagination,
|
||||||
@ -41,23 +41,10 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showingCount = computed(() => props.materials.length);
|
const showingCount = computed(() => props.materials.length);
|
||||||
|
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'bahan baku');
|
||||||
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}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
function rowNumber(index: number): number {
|
function rowNumber(index: number): number {
|
||||||
return (props.firstItem ?? 1) + index;
|
return groupedTableRowNumber(props.firstItem, index);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -88,18 +75,13 @@ function rowNumber(index: number): number {
|
|||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||||
<span>
|
<span>
|
||||||
Total stok <strong class="text-primary"> {{material.prices.reduce((acc, price) =>
|
Total stok <strong class="text-primary">
|
||||||
acc +
|
{{ material.total_stock_formatted }}
|
||||||
Number(price.stock), 0)
|
|
||||||
}}
|
|
||||||
{{ material.unit_abbreviation }}
|
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Total harga <strong class="text-primary"> Rp
|
Total harga <strong class="text-primary">
|
||||||
{{formatRupiah(material.prices.reduce((acc, price) => acc +
|
{{ material.total_inventory_value_formatted }}
|
||||||
Number(price.stock) * Number(price.price), 0))
|
|
||||||
}}
|
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -148,21 +130,10 @@ function rowNumber(index: number): number {
|
|||||||
|
|
||||||
<DataTableEmpty v-else />
|
<DataTableEmpty v-else />
|
||||||
|
|
||||||
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<GroupedTableFooter
|
||||||
<p class="text-muted-foreground text-sm">
|
:summary="paginationSummary"
|
||||||
{{ paginationSummary }}
|
:pagination="pagination"
|
||||||
</p>
|
:pagination-links="paginationLinks"
|
||||||
|
/>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
import type { Paginated, SelectOption } from '@/types/common';
|
||||||
|
|
||||||
|
export type { SelectOption } from '@/types/common';
|
||||||
|
|
||||||
export type ActivityLogChange = {
|
export type ActivityLogChange = {
|
||||||
field: string;
|
field: string;
|
||||||
old: unknown;
|
old: unknown;
|
||||||
@ -26,20 +30,4 @@ export type ActivityLogFilters = {
|
|||||||
subject_type?: string;
|
subject_type?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginatedActivityLogs = {
|
export type PaginatedActivityLogs = Paginated<ActivityLogListItem>;
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { MediaItem } from '@/types/media';
|
import type { MediaItem } from '@/types/media';
|
||||||
|
import type { Paginated } from '@/types/common';
|
||||||
|
|
||||||
export type CashAccount = {
|
export type CashAccount = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -31,15 +32,4 @@ export type CashTransactionFormData = {
|
|||||||
remove_media_ids: number[];
|
remove_media_ids: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginatedCashTransactions = {
|
export type PaginatedCashTransactions = Paginated<CashTransactionListItem>;
|
||||||
data: CashTransactionListItem[];
|
|
||||||
current_page: number;
|
|
||||||
last_page: number;
|
|
||||||
per_page: number;
|
|
||||||
total: number;
|
|
||||||
links: Array<{
|
|
||||||
url: string | null;
|
|
||||||
label: string;
|
|
||||||
active: boolean;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { Paginated } from '@/types/common';
|
||||||
|
|
||||||
export type CategoryListItem = {
|
export type CategoryListItem = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -14,15 +16,4 @@ export type CategoryFilters = {
|
|||||||
direction?: 'asc' | 'desc' | null;
|
direction?: 'asc' | 'desc' | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginatedCategories = {
|
export type PaginatedCategories = Paginated<CategoryListItem>;
|
||||||
data: CategoryListItem[];
|
|
||||||
current_page: number;
|
|
||||||
last_page: number;
|
|
||||||
per_page: number;
|
|
||||||
total: number;
|
|
||||||
links: Array<{
|
|
||||||
url: string | null;
|
|
||||||
label: string;
|
|
||||||
active: boolean;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|||||||
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 = {
|
export type CustomerListItem = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -17,15 +19,4 @@ export type CustomerFilters = {
|
|||||||
direction?: 'asc' | 'desc' | null;
|
direction?: 'asc' | 'desc' | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginatedCustomers = {
|
export type PaginatedCustomers = Paginated<CustomerListItem>;
|
||||||
data: CustomerListItem[];
|
|
||||||
current_page: number;
|
|
||||||
last_page: number;
|
|
||||||
per_page: number;
|
|
||||||
total: number;
|
|
||||||
links: Array<{
|
|
||||||
url: string | null;
|
|
||||||
label: string;
|
|
||||||
active: boolean;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { MediaItem } from '@/types/media';
|
import type { MediaItem } from '@/types/media';
|
||||||
|
import type { Paginated } from '@/types/common';
|
||||||
import type { ProductListItem } from '@/types/product';
|
import type { ProductListItem } from '@/types/product';
|
||||||
import type { RawMaterialListItem } from '@/types/raw-material';
|
import type { RawMaterialListItem } from '@/types/raw-material';
|
||||||
|
|
||||||
@ -70,6 +71,7 @@ export type CuttingListItem = {
|
|||||||
total_production_cost_formatted?: string;
|
total_production_cost_formatted?: string;
|
||||||
total_result_pieces?: number;
|
total_result_pieces?: number;
|
||||||
total_material_usage?: number;
|
total_material_usage?: number;
|
||||||
|
total_material_usage_summary_formatted?: string;
|
||||||
estimated_cost_per_unit?: number;
|
estimated_cost_per_unit?: number;
|
||||||
estimated_cost_per_unit_formatted?: string;
|
estimated_cost_per_unit_formatted?: string;
|
||||||
created_by?: {
|
created_by?: {
|
||||||
@ -149,18 +151,7 @@ export type CuttingEditItem = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginatedCuttings = {
|
export type PaginatedCuttings = Paginated<CuttingListItem>;
|
||||||
data: CuttingListItem[];
|
|
||||||
current_page: number;
|
|
||||||
last_page: number;
|
|
||||||
per_page: number;
|
|
||||||
total: number;
|
|
||||||
links: Array<{
|
|
||||||
url: string | null;
|
|
||||||
label: string;
|
|
||||||
active: boolean;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CuttingRawMaterialCatalogItem = RawMaterialListItem;
|
export type CuttingRawMaterialCatalogItem = RawMaterialListItem;
|
||||||
export type CuttingProductCatalogItem = ProductListItem;
|
export type CuttingProductCatalogItem = ProductListItem;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { Paginated } from '@/types/common';
|
||||||
|
|
||||||
export type EmployeeAdvancePayment = {
|
export type EmployeeAdvancePayment = {
|
||||||
id: number;
|
id: number;
|
||||||
employee_advance_id: number;
|
employee_advance_id: number;
|
||||||
@ -39,18 +41,7 @@ export type EmployeeAdvanceFormData = {
|
|||||||
due_date: string;
|
due_date: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginatedEmployeeAdvances = {
|
export type PaginatedEmployeeAdvances = Paginated<EmployeeAdvanceListItem>;
|
||||||
data: EmployeeAdvanceListItem[];
|
|
||||||
current_page: number;
|
|
||||||
last_page: number;
|
|
||||||
per_page: number;
|
|
||||||
total: number;
|
|
||||||
links: Array<{
|
|
||||||
url: string | null;
|
|
||||||
label: string;
|
|
||||||
active: boolean;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EmployeeAdvanceSummary = {
|
export type EmployeeAdvanceSummary = {
|
||||||
outstanding_amount: number;
|
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