Compare commits
9 Commits
15e2b7ec5b
...
0d77d6a4fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d77d6a4fd | ||
|
|
18ccd7a3c0 | ||
|
|
5a5d2c6f78 | ||
|
|
dbfd7ce48e | ||
|
|
3a8a6982ec | ||
|
|
150a969086 | ||
|
|
352599bf00 | ||
|
|
02fb2045d2 | ||
|
|
27f55ef18e |
@ -25,6 +25,7 @@ public function rules(): array
|
|||||||
'notes' => ['nullable', 'string', 'max:1000'],
|
'notes' => ['nullable', 'string', 'max:1000'],
|
||||||
'items' => ['nullable', 'array'],
|
'items' => ['nullable', 'array'],
|
||||||
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
|
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
|
||||||
|
'items.*.stock_quality' => ['required', \Illuminate\Validation\Rule::enum(\App\Enums\ProductStockQuality::class)],
|
||||||
'items.*.physical_stock' => ['nullable', 'integer', 'min:0'],
|
'items.*.physical_stock' => ['nullable', 'integer', 'min:0'],
|
||||||
'items.*.notes' => ['nullable', 'string', 'max:500'],
|
'items.*.notes' => ['nullable', 'string', 'max:500'],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -23,6 +23,7 @@ public function rules(): array
|
|||||||
'notes' => ['nullable', 'string', 'max:1000'],
|
'notes' => ['nullable', 'string', 'max:1000'],
|
||||||
'items' => ['required', 'array', 'min:1'],
|
'items' => ['required', 'array', 'min:1'],
|
||||||
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
|
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
|
||||||
|
'items.*.stock_quality' => ['required', \Illuminate\Validation\Rule::enum(\App\Enums\ProductStockQuality::class)],
|
||||||
'items.*.physical_stock' => ['required', 'integer', 'min:0'],
|
'items.*.physical_stock' => ['required', 'integer', 'min:0'],
|
||||||
'items.*.notes' => ['nullable', 'string', 'max:500'],
|
'items.*.notes' => ['nullable', 'string', 'max:500'],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -84,8 +84,8 @@ private function sendWebPush(): void
|
|||||||
$expiredEndpoints = [];
|
$expiredEndpoints = [];
|
||||||
|
|
||||||
foreach ($webPush->flush() as $report) {
|
foreach ($webPush->flush() as $report) {
|
||||||
if (! $report->isSuccess()) {
|
if ($report->isSubscriptionExpired()) {
|
||||||
$expiredEndpoints[] = (string) $report->getRequest()->getUri();
|
$expiredEndpoints[] = $report->getEndpoint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Models\Concerns\InteractsWithActivityLog;
|
use App\Models\Concerns\InteractsWithActivityLog;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@ -21,9 +22,23 @@ protected function casts(): array
|
|||||||
'difference' => 'integer',
|
'difference' => 'integer',
|
||||||
'physical_stock' => 'integer',
|
'physical_stock' => 'integer',
|
||||||
'system_stock' => 'integer',
|
'system_stock' => 'integer',
|
||||||
|
'stock_quality' => ProductStockQuality::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected $appends = ['product_name', 'variant_name'];
|
||||||
|
|
||||||
|
public function getProductNameAttribute(): string
|
||||||
|
{
|
||||||
|
return $this->productVariant->product->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVariantNameAttribute(): string
|
||||||
|
{
|
||||||
|
$qualityLabel = $this->stock_quality ? ' (' . $this->stock_quality->label() . ')' : '';
|
||||||
|
return $this->productVariant->name . $qualityLabel;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Relation
|
// 3. Relation
|
||||||
public function productVariant(): BelongsTo
|
public function productVariant(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@ -599,7 +599,7 @@ function () use ($cutting, $validated): void {
|
|||||||
foreach ($materials as $materialData) {
|
foreach ($materials as $materialData) {
|
||||||
$combinationId = $materialData['combination_id'];
|
$combinationId = $materialData['combination_id'];
|
||||||
if ($combinationId !== null) {
|
if ($combinationId !== null) {
|
||||||
if (!isset($combinationGroups[$combinationId])) {
|
if (! isset($combinationGroups[$combinationId])) {
|
||||||
$combinationGroups[$combinationId] = [
|
$combinationGroups[$combinationId] = [
|
||||||
'materials' => [],
|
'materials' => [],
|
||||||
'material_result' => $materialData['combination_material_result'] ?? null,
|
'material_result' => $materialData['combination_material_result'] ?? null,
|
||||||
|
|||||||
@ -393,8 +393,10 @@ public function create(array $validated, User $user): Order
|
|||||||
if ($user->hasRole('cashier')) {
|
if ($user->hasRole('cashier')) {
|
||||||
$validated['channel'] = 'store';
|
$validated['channel'] = 'store';
|
||||||
$validated['price_type'] = 'retail';
|
$validated['price_type'] = 'retail';
|
||||||
$validated['payment_type'] = 'cash';
|
$validated['payment_type'] = $validated['payment_type'] ?? 'cash';
|
||||||
|
$validated['status'] = OrderStatus::COMPLETED->value;
|
||||||
unset($validated['customer_id']);
|
unset($validated['customer_id']);
|
||||||
|
unset($validated['marketing_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$order = $this->runInTransaction(
|
$order = $this->runInTransaction(
|
||||||
|
|||||||
@ -31,6 +31,7 @@ public function getPendingVerificationCuttings(User $user): Collection
|
|||||||
'rejection.rejectedBy.profile',
|
'rejection.rejectedBy.profile',
|
||||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||||
'materials.rawMaterialPrice.media',
|
'materials.rawMaterialPrice.media',
|
||||||
|
'materials.combination',
|
||||||
'results.productVariant.product:id,name',
|
'results.productVariant.product:id,name',
|
||||||
'results.productVariant.media',
|
'results.productVariant.media',
|
||||||
])
|
])
|
||||||
@ -53,6 +54,7 @@ public function getPendingApprovalCuttings(User $user): Collection
|
|||||||
'rejection.rejectedBy.profile',
|
'rejection.rejectedBy.profile',
|
||||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||||
'materials.rawMaterialPrice.media',
|
'materials.rawMaterialPrice.media',
|
||||||
|
'materials.combination',
|
||||||
'results.productVariant.product:id,name',
|
'results.productVariant.product:id,name',
|
||||||
'results.productVariant.media',
|
'results.productVariant.media',
|
||||||
'resultPrices.productVariant:id,product_id,name',
|
'resultPrices.productVariant:id,product_id,name',
|
||||||
@ -236,19 +238,34 @@ private function breakMaterialCircularReference(CuttingMaterial $material): void
|
|||||||
{
|
{
|
||||||
$price = $material->rawMaterialPrice;
|
$price = $material->rawMaterialPrice;
|
||||||
|
|
||||||
|
// Always set these attributes regardless of price
|
||||||
|
$material->setAttribute('material_result', $material->material_result);
|
||||||
|
$material->setAttribute('material_result_input', $material->material_result);
|
||||||
|
$material->setAttribute('combination_id', $material->combination_id);
|
||||||
|
$material->setAttribute('combination_material_result', $material->combination?->material_result);
|
||||||
|
|
||||||
if ($price) {
|
if ($price) {
|
||||||
$rawMaterial = $price->rawMaterial;
|
$rawMaterial = $price->rawMaterial;
|
||||||
|
|
||||||
|
$material->setAttribute('variant', $price->variant);
|
||||||
|
$material->setAttribute('stock_input', $price->stock_input);
|
||||||
|
$material->setAttribute('images', $price->getAttribute('images') ?? []);
|
||||||
|
|
||||||
if ($rawMaterial) {
|
if ($rawMaterial) {
|
||||||
$unitAbbreviation = $rawMaterial->unit->abbreviation();
|
$unitAbbreviation = $rawMaterial->unit->abbreviation();
|
||||||
$price->setAttribute('unit_abbreviation', $unitAbbreviation);
|
$price->setAttribute('unit_abbreviation', $unitAbbreviation);
|
||||||
$material->setAttribute('unit_abbreviation', $unitAbbreviation);
|
$material->setAttribute('unit_abbreviation', $unitAbbreviation);
|
||||||
|
$material->setAttribute('unit', $rawMaterial->unit->value);
|
||||||
|
$material->setAttribute('raw_material_id', $rawMaterial->id);
|
||||||
|
$material->setAttribute('raw_material_name', $rawMaterial->name);
|
||||||
|
$material->setAttribute('raw_material_unit_label', $rawMaterial->unit->label());
|
||||||
}
|
}
|
||||||
|
|
||||||
$price->unsetRelation('rawMaterial');
|
$price->unsetRelation('rawMaterial');
|
||||||
}
|
}
|
||||||
|
|
||||||
$material->unsetRelation('rawMaterialPrice');
|
$material->unsetRelation('rawMaterialPrice');
|
||||||
|
$material->unsetRelation('combination');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyProductStockOnVerify(Cutting $cutting): void
|
private function applyProductStockOnVerify(Cutting $cutting): void
|
||||||
|
|||||||
@ -54,7 +54,7 @@ public function catalogItems(): array
|
|||||||
->active()
|
->active()
|
||||||
->with([
|
->with([
|
||||||
'variants' => fn ($query) => $query
|
'variants' => fn ($query) => $query
|
||||||
->select('id', 'product_id', 'name', 'stock')
|
->select('id', 'product_id', 'name', 'stock', 'retail_stock', 'reject_stock')
|
||||||
->orderBy('created_at'),
|
->orderBy('created_at'),
|
||||||
])
|
])
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
@ -67,6 +67,10 @@ public function catalogItems(): array
|
|||||||
'name' => $variant->name,
|
'name' => $variant->name,
|
||||||
'stock' => $variant->stock,
|
'stock' => $variant->stock,
|
||||||
'stock_formatted' => $variant->stock_formatted,
|
'stock_formatted' => $variant->stock_formatted,
|
||||||
|
'retail_stock' => $variant->retail_stock,
|
||||||
|
'retail_stock_formatted' => $variant->retail_stock_formatted,
|
||||||
|
'reject_stock' => $variant->reject_stock,
|
||||||
|
'reject_stock_formatted' => $variant->reject_stock_formatted,
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->toArray();
|
->toArray();
|
||||||
@ -90,6 +94,7 @@ public function findForEdit(StokOpname $stokOpname): array
|
|||||||
'product_variant_id' => $item->product_variant_id,
|
'product_variant_id' => $item->product_variant_id,
|
||||||
'variant_name' => $item->productVariant->name,
|
'variant_name' => $item->productVariant->name,
|
||||||
'product_name' => $item->productVariant->product->name,
|
'product_name' => $item->productVariant->product->name,
|
||||||
|
'stock_quality' => $item->stock_quality->value,
|
||||||
'system_stock' => $item->system_stock,
|
'system_stock' => $item->system_stock,
|
||||||
'physical_stock' => $item->physical_stock,
|
'physical_stock' => $item->physical_stock,
|
||||||
'difference' => $item->difference,
|
'difference' => $item->difference,
|
||||||
@ -235,8 +240,13 @@ public function verify(StokOpname $stokOpname, User $user, ?string $verification
|
|||||||
DB::transaction(function () use ($stokOpname, $user, $verificationNotes): void {
|
DB::transaction(function () use ($stokOpname, $user, $verificationNotes): void {
|
||||||
foreach ($stokOpname->items as $item) {
|
foreach ($stokOpname->items as $item) {
|
||||||
if ($item->difference !== 0) {
|
if ($item->difference !== 0) {
|
||||||
|
$column = match ($item->stock_quality) {
|
||||||
|
\App\Enums\ProductStockQuality::GOOD => 'stock',
|
||||||
|
\App\Enums\ProductStockQuality::RETAIL => 'retail_stock',
|
||||||
|
\App\Enums\ProductStockQuality::REJECT => 'reject_stock',
|
||||||
|
};
|
||||||
$item->productVariant()->update([
|
$item->productVariant()->update([
|
||||||
'stock' => $item->physical_stock,
|
$column => $item->physical_stock,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -346,10 +356,23 @@ private function syncItems(StokOpname $stokOpname, array $items): void
|
|||||||
$stokOpname->items()->delete();
|
$stokOpname->items()->delete();
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$systemStock = ProductVariant::find($item['product_variant_id'])?->stock ?? 0;
|
$variant = ProductVariant::find($item['product_variant_id']);
|
||||||
|
if (!$variant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$quality = \App\Enums\ProductStockQuality::from($item['stock_quality']);
|
||||||
|
$column = match ($quality) {
|
||||||
|
\App\Enums\ProductStockQuality::GOOD => 'stock',
|
||||||
|
\App\Enums\ProductStockQuality::RETAIL => 'retail_stock',
|
||||||
|
\App\Enums\ProductStockQuality::REJECT => 'reject_stock',
|
||||||
|
};
|
||||||
|
|
||||||
|
$systemStock = $variant->$column ?? 0;
|
||||||
|
|
||||||
$stokOpname->items()->create([
|
$stokOpname->items()->create([
|
||||||
'product_variant_id' => $item['product_variant_id'],
|
'product_variant_id' => $item['product_variant_id'],
|
||||||
|
'stock_quality' => $quality->value,
|
||||||
'system_stock' => $systemStock,
|
'system_stock' => $systemStock,
|
||||||
'physical_stock' => $item['physical_stock'],
|
'physical_stock' => $item['physical_stock'],
|
||||||
'difference' => $item['physical_stock'] - $systemStock,
|
'difference' => $item['physical_stock'] - $systemStock,
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Enums\EmployeeAdvanceStatus;
|
use App\Enums\EmployeeAdvanceStatus;
|
||||||
use App\Enums\OrderStatus;
|
use App\Enums\OrderStatus;
|
||||||
use App\Enums\PriceType;
|
use App\Enums\PriceType;
|
||||||
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Models\Attendance;
|
use App\Models\Attendance;
|
||||||
use App\Models\CashAccount;
|
use App\Models\CashAccount;
|
||||||
@ -285,7 +286,7 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
|
|||||||
->groupBy('month_key')
|
->groupBy('month_key')
|
||||||
->map(fn ($orders) => $orders->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0)));
|
->map(fn ($orders) => $orders->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0)));
|
||||||
|
|
||||||
$monthlyHpp = OrderItem::query()
|
$monthlyItemsData = OrderItem::query()
|
||||||
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||||
->leftJoin('product_prices', function ($join) {
|
->leftJoin('product_prices', function ($join) {
|
||||||
$join->on('order_items.product_variant_id', '=', 'product_prices.variant_id')
|
$join->on('order_items.product_variant_id', '=', 'product_prices.variant_id')
|
||||||
@ -297,11 +298,13 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
|
|||||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
|
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
|
||||||
->selectRaw("
|
->selectRaw("
|
||||||
DATE_FORMAT(orders.created_at, '%Y-%m') as month_key,
|
DATE_FORMAT(orders.created_at, '%Y-%m') as month_key,
|
||||||
|
order_items.stock_quality,
|
||||||
|
SUM(order_items.subtotal) as subtotal,
|
||||||
COALESCE(SUM(order_items.quantity * product_prices.price), 0) as hpp
|
COALESCE(SUM(order_items.quantity * product_prices.price), 0) as hpp
|
||||||
")
|
")
|
||||||
->groupBy('month_key')
|
->groupBy('month_key', 'order_items.stock_quality')
|
||||||
->get()
|
->get()
|
||||||
->pluck('hpp', 'month_key');
|
->groupBy('month_key');
|
||||||
|
|
||||||
if ($monthlyData->isEmpty()) {
|
if ($monthlyData->isEmpty()) {
|
||||||
return [];
|
return [];
|
||||||
@ -319,13 +322,42 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
|
|||||||
$revenue = $monthlyData->firstWhere('month_key', $key);
|
$revenue = $monthlyData->firstWhere('month_key', $key);
|
||||||
$fees = $monthlyFees->get($key, 0);
|
$fees = $monthlyFees->get($key, 0);
|
||||||
$discount = (int) ($revenue->total_discount ?? 0);
|
$discount = (int) ($revenue->total_discount ?? 0);
|
||||||
$hpp = (int) ($monthlyHpp->get($key, 0));
|
|
||||||
$deduction = $discount + $fees;
|
$deduction = $discount + $fees;
|
||||||
|
|
||||||
|
$monthItems = $monthlyItemsData->get($key, collect());
|
||||||
|
$warehouseItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'good'
|
||||||
|
);
|
||||||
|
$retailItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'retail'
|
||||||
|
);
|
||||||
|
$rejectItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'reject'
|
||||||
|
);
|
||||||
|
|
||||||
|
$warehouseSubtotal = (int) ($warehouseItem?->subtotal ?? 0);
|
||||||
|
$warehouseHpp = (int) ($warehouseItem?->hpp ?? 0);
|
||||||
|
$retailSubtotal = (int) ($retailItem?->subtotal ?? 0);
|
||||||
|
$retailHpp = (int) ($retailItem?->hpp ?? 0);
|
||||||
|
$rejectSubtotal = (int) ($rejectItem?->subtotal ?? 0);
|
||||||
|
$rejectHpp = (int) ($rejectItem?->hpp ?? 0);
|
||||||
|
|
||||||
|
$totalItemsSubtotal = $warehouseSubtotal + $retailSubtotal + $rejectSubtotal;
|
||||||
|
$hpp = $warehouseHpp + $retailHpp + $rejectHpp;
|
||||||
|
|
||||||
|
$warehouseDeduction = 0;
|
||||||
|
$retailDeduction = 0;
|
||||||
|
if ($totalItemsSubtotal > 0) {
|
||||||
|
$warehouseDeduction = ($warehouseSubtotal / $totalItemsSubtotal) * $deduction;
|
||||||
|
$retailDeduction = ($retailSubtotal / $totalItemsSubtotal) * $deduction;
|
||||||
|
}
|
||||||
|
|
||||||
|
$netWarehouse = $warehouseSubtotal - $warehouseDeduction - $warehouseHpp;
|
||||||
|
$netRetail = $retailSubtotal - $retailDeduction - $retailHpp;
|
||||||
|
|
||||||
$result[] = [
|
$result[] = [
|
||||||
'month' => $monthLabel,
|
'month' => $monthLabel,
|
||||||
'total' => (int) ($revenue->total_revenue ?? 0),
|
'total' => (int) ($revenue->total_revenue ?? 0),
|
||||||
'net' => (int) ($revenue->total_revenue ?? 0) - $deduction - $hpp,
|
'net' => (int) ($revenue->total_revenue ?? 0) - $deduction - $hpp,
|
||||||
|
'net_warehouse' => (int) round($netWarehouse),
|
||||||
|
'net_retail' => (int) round($netRetail),
|
||||||
'deduction' => $deduction,
|
'deduction' => $deduction,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -10,10 +10,10 @@ public function updateOrCreateSubscription(array $validated, int $userId): void
|
|||||||
{
|
{
|
||||||
PushSubscription::updateOrCreate(
|
PushSubscription::updateOrCreate(
|
||||||
[
|
[
|
||||||
'user_id' => $userId,
|
|
||||||
'endpoint' => $validated['endpoint'],
|
'endpoint' => $validated['endpoint'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
'user_id' => $userId,
|
||||||
'public_key' => $validated['publicKey'],
|
'public_key' => $validated['publicKey'],
|
||||||
'auth_token' => $validated['authToken'],
|
'auth_token' => $validated['authToken'],
|
||||||
]
|
]
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\ProductStockQuality;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('stok_opname_items', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['stok_opname_id']);
|
||||||
|
$table->dropForeign(['product_variant_id']);
|
||||||
|
$table->dropUnique('stok_opname_items_stok_opname_id_product_variant_id_unique');
|
||||||
|
|
||||||
|
$table->enum('stock_quality', ProductStockQuality::values())->default(ProductStockQuality::GOOD->value)->after('product_variant_id');
|
||||||
|
$table->unique(['stok_opname_id', 'product_variant_id', 'stock_quality'], 'stok_opname_items_opname_variant_quality_unique');
|
||||||
|
|
||||||
|
$table->foreign('stok_opname_id')->references('id')->on('stok_opnames')->cascadeOnDelete();
|
||||||
|
$table->foreign('product_variant_id')->references('id')->on('product_variants')->cascadeOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('stok_opname_items', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['stok_opname_id']);
|
||||||
|
$table->dropForeign(['product_variant_id']);
|
||||||
|
$table->dropUnique('stok_opname_items_opname_variant_quality_unique');
|
||||||
|
|
||||||
|
$table->dropColumn('stock_quality');
|
||||||
|
$table->unique(['stok_opname_id', 'product_variant_id']);
|
||||||
|
|
||||||
|
$table->foreign('stok_opname_id')->references('id')->on('stok_opnames')->cascadeOnDelete();
|
||||||
|
$table->foreign('product_variant_id')->references('id')->on('product_variants')->cascadeOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -97,6 +97,8 @@ const props = defineProps<{
|
|||||||
month: string;
|
month: string;
|
||||||
total: number;
|
total: number;
|
||||||
net: number;
|
net: number;
|
||||||
|
net_warehouse: number;
|
||||||
|
net_retail: number;
|
||||||
deduction: number;
|
deduction: number;
|
||||||
}>;
|
}>;
|
||||||
expenseSummary: {
|
expenseSummary: {
|
||||||
@ -198,6 +200,8 @@ type MonthlyRevenueData = {
|
|||||||
month: string;
|
month: string;
|
||||||
total: number;
|
total: number;
|
||||||
net: number;
|
net: number;
|
||||||
|
net_warehouse: number;
|
||||||
|
net_retail: number;
|
||||||
deduction: number;
|
deduction: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -210,20 +214,57 @@ const revenueChartConfig = {
|
|||||||
label: 'Bersih',
|
label: 'Bersih',
|
||||||
color: '#22c55e',
|
color: '#22c55e',
|
||||||
},
|
},
|
||||||
|
net_warehouse: {
|
||||||
|
label: 'Total Gudang',
|
||||||
|
color: '#10b981',
|
||||||
|
},
|
||||||
|
net_retail: {
|
||||||
|
label: 'Total Ecer',
|
||||||
|
color: '#06b6d4',
|
||||||
|
},
|
||||||
deduction: {
|
deduction: {
|
||||||
label: 'Potongan',
|
label: 'Potongan',
|
||||||
color: '#f97316',
|
color: '#f97316',
|
||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
const revenueTotals = computed(() => ({
|
const revenueTotals = computed(() => {
|
||||||
total: props.revenueSummary.total_revenue,
|
const net_warehouse = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_warehouse ?? 0), 0);
|
||||||
net:
|
const net_retail = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_retail ?? 0), 0);
|
||||||
props.revenueSummary.total_revenue -
|
return {
|
||||||
props.revenueSummary.total_deduction -
|
total: props.revenueSummary.total_revenue,
|
||||||
props.profitMetrics.hpp,
|
net:
|
||||||
deduction: props.revenueSummary.total_deduction,
|
props.revenueSummary.total_revenue -
|
||||||
}));
|
props.revenueSummary.total_deduction -
|
||||||
|
props.profitMetrics.hpp,
|
||||||
|
net_warehouse,
|
||||||
|
net_retail,
|
||||||
|
deduction: props.revenueSummary.total_deduction,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleRevenueCharts = computed(() => {
|
||||||
|
const isOwner = hasAnyRole(['owner', 'developer']);
|
||||||
|
const isCashier = hasRole('cashier');
|
||||||
|
|
||||||
|
if (isOwner) {
|
||||||
|
return ['total', 'net', 'net_warehouse', 'net_retail', 'deduction'] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCashier) {
|
||||||
|
return ['total', 'deduction'] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['total', 'net', 'deduction'] as const;
|
||||||
|
});
|
||||||
|
|
||||||
|
const revenueYAccessors = computed(() => {
|
||||||
|
return visibleRevenueCharts.value.map((chart) => (d: MonthlyRevenueData) => d[chart]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const revenueColors = computed(() => {
|
||||||
|
return visibleRevenueCharts.value.map((chart) => revenueChartConfig[chart].color);
|
||||||
|
});
|
||||||
|
|
||||||
// Expense Chart
|
// Expense Chart
|
||||||
type MonthlyExpenseData = {
|
type MonthlyExpenseData = {
|
||||||
@ -356,6 +397,8 @@ const barSelector = GroupedBar.selectors.bar;
|
|||||||
|
|
||||||
function revenueTooltip(d: any) {
|
function revenueTooltip(d: any) {
|
||||||
const item = d as MonthlyRevenueData;
|
const item = d as MonthlyRevenueData;
|
||||||
|
const isOwner = hasAnyRole(['owner', 'developer']);
|
||||||
|
const isCashier = hasRole('cashier');
|
||||||
|
|
||||||
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.6">
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.6">
|
||||||
<p style="margin:0;font-weight:500">${item.month}</p>
|
<p style="margin:0;font-weight:500">${item.month}</p>
|
||||||
@ -364,11 +407,25 @@ function revenueTooltip(d: any) {
|
|||||||
<span class="text-muted-foreground">${revenueChartConfig.total.label}</span>
|
<span class="text-muted-foreground">${revenueChartConfig.total.label}</span>
|
||||||
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.total)}</span>
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.total)}</span>
|
||||||
</p>
|
</p>
|
||||||
|
${!isCashier ? `
|
||||||
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
||||||
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net.color}"></span>
|
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net.color}"></span>
|
||||||
<span class="text-muted-foreground">${revenueChartConfig.net.label}</span>
|
<span class="text-muted-foreground">${revenueChartConfig.net.label}</span>
|
||||||
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net)}</span>
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net)}</span>
|
||||||
</p>
|
</p>
|
||||||
|
` : ''}
|
||||||
|
${isOwner ? `
|
||||||
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
||||||
|
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net_warehouse.color}"></span>
|
||||||
|
<span class="text-muted-foreground">${revenueChartConfig.net_warehouse.label}</span>
|
||||||
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net_warehouse)}</span>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
||||||
|
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net_retail.color}"></span>
|
||||||
|
<span class="text-muted-foreground">${revenueChartConfig.net_retail.label}</span>
|
||||||
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net_retail)}</span>
|
||||||
|
</p>
|
||||||
|
` : ''}
|
||||||
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
||||||
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.deduction.color}"></span>
|
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.deduction.color}"></span>
|
||||||
<span class="text-muted-foreground">${revenueChartConfig.deduction.label}</span>
|
<span class="text-muted-foreground">${revenueChartConfig.deduction.label}</span>
|
||||||
@ -652,11 +709,7 @@ watch([startDate, endDate], () => {
|
|||||||
<CardTitle>Pendapatan</CardTitle>
|
<CardTitle>Pendapatan</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div v-for="chart in [
|
<div v-for="chart in visibleRevenueCharts" :key="chart"
|
||||||
'total',
|
|
||||||
'net',
|
|
||||||
'deduction',
|
|
||||||
] as const" :key="chart"
|
|
||||||
class="flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
|
class="flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">
|
||||||
{{ revenueChartConfig[chart].label }}
|
{{ revenueChartConfig[chart].label }}
|
||||||
@ -670,11 +723,7 @@ watch([startDate, endDate], () => {
|
|||||||
<CardContent class="px-2 sm:p-6">
|
<CardContent class="px-2 sm:p-6">
|
||||||
<div v-if="monthlyRevenue.length > 0">
|
<div v-if="monthlyRevenue.length > 0">
|
||||||
<div class="mb-3 flex flex-wrap items-center justify-center gap-4">
|
<div class="mb-3 flex flex-wrap items-center justify-center gap-4">
|
||||||
<div v-for="chart in [
|
<div v-for="chart in visibleRevenueCharts" :key="chart" class="flex items-center gap-1.5">
|
||||||
'total',
|
|
||||||
'net',
|
|
||||||
'deduction',
|
|
||||||
] as const" :key="chart" class="flex items-center gap-1.5">
|
|
||||||
<span class="size-2.5 rounded-full" :style="{
|
<span class="size-2.5 rounded-full" :style="{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
revenueChartConfig[chart].color,
|
revenueChartConfig[chart].color,
|
||||||
@ -687,15 +736,7 @@ watch([startDate, endDate], () => {
|
|||||||
<ChartContainer :config="revenueChartConfig" class="aspect-auto h-[300px] w-full">
|
<ChartContainer :config="revenueChartConfig" class="aspect-auto h-[300px] w-full">
|
||||||
<VisXYContainer :data="monthlyRevenue" :y-domain="[0, undefined]">
|
<VisXYContainer :data="monthlyRevenue" :y-domain="[0, undefined]">
|
||||||
<VisGroupedBar :x="(_d: MonthlyRevenueData, i: number) => i
|
<VisGroupedBar :x="(_d: MonthlyRevenueData, i: number) => i
|
||||||
" :y="[
|
" :y="revenueYAccessors" :color="revenueColors" :bar-padding="0.1" :group-padding="0.2" :rounded-corners="4" />
|
||||||
(d: MonthlyRevenueData) => d.total,
|
|
||||||
(d: MonthlyRevenueData) => d.net,
|
|
||||||
(d: MonthlyRevenueData) => d.deduction,
|
|
||||||
]" :color="[
|
|
||||||
revenueChartConfig.total.color,
|
|
||||||
revenueChartConfig.net.color,
|
|
||||||
revenueChartConfig.deduction.color,
|
|
||||||
]" :bar-padding="0.1" :group-padding="0.2" :rounded-corners="4" />
|
|
||||||
<VisAxis type="x" :x="(_d: MonthlyRevenueData, i: number) => i
|
<VisAxis type="x" :x="(_d: MonthlyRevenueData, i: number) => i
|
||||||
" :tick-line="false" :domain-line="false" :grid-line="false" :tick-format="(d: number) =>
|
" :tick-line="false" :domain-line="false" :grid-line="false" :tick-format="(d: number) =>
|
||||||
monthlyRevenue[d]?.month ?? ''
|
monthlyRevenue[d]?.month ?? ''
|
||||||
@ -848,15 +889,17 @@ watch([startDate, endDate], () => {
|
|||||||
label: 'HPP',
|
label: 'HPP',
|
||||||
value: 'Rp' + formatRupiah(profitMetrics.hpp),
|
value: 'Rp' + formatRupiah(profitMetrics.hpp),
|
||||||
},
|
},
|
||||||
{
|
...(!hasRole('cashier') ? [
|
||||||
label: 'Laba Bersih',
|
{
|
||||||
value:
|
label: 'Laba Bersih',
|
||||||
'Rp' +
|
value:
|
||||||
formatRupiah(profitMetrics.net_profit) +
|
'Rp' +
|
||||||
' (' +
|
formatRupiah(profitMetrics.net_profit) +
|
||||||
profitMetrics.profit_margin +
|
' (' +
|
||||||
'%)',
|
profitMetrics.profit_margin +
|
||||||
},
|
'%)',
|
||||||
|
},
|
||||||
|
] : []),
|
||||||
]" />
|
]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -154,33 +154,77 @@ const groupedResults = computed<GroupedResults[]>(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary -->
|
<!-- Results Section -->
|
||||||
<div class="border-b p-6">
|
<div v-if="cutting.results.length" class="border-b p-6">
|
||||||
<h3 class="mb-3 text-sm font-semibold">Ringkasan</h3>
|
<h3 class="mb-3 text-sm font-semibold">Hasil Produk</h3>
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="overflow-hidden rounded-md border">
|
||||||
<div class="rounded-lg border p-3">
|
<Table>
|
||||||
<p class="text-xs text-muted-foreground">
|
<TableHeader>
|
||||||
Total Hasil Cutting
|
<TableRow>
|
||||||
</p>
|
<TableHead>Varian</TableHead>
|
||||||
<p class="mt-1 text-lg font-semibold text-primary">
|
<TableHead>Foto</TableHead>
|
||||||
{{ cutting.total_result_pieces ?? 0 }} pcs
|
<TableHead class="text-right"
|
||||||
</p>
|
>Hasil</TableHead
|
||||||
</div>
|
>
|
||||||
<div
|
<TableHead class="text-right"
|
||||||
v-if="
|
>Sample</TableHead
|
||||||
cutting.total_material_usage_summary_formatted
|
>
|
||||||
"
|
<TableHead class="text-right"
|
||||||
class="rounded-lg border p-3"
|
>Diluar Sample</TableHead
|
||||||
>
|
>
|
||||||
<p class="text-xs text-muted-foreground">
|
</TableRow>
|
||||||
Total Pemakaian Bahan
|
</TableHeader>
|
||||||
</p>
|
<TableBody>
|
||||||
<p class="mt-1 text-lg font-semibold text-primary">
|
<template
|
||||||
{{
|
v-for="group in groupedResults"
|
||||||
cutting.total_material_usage_summary_formatted
|
:key="group.key"
|
||||||
}}
|
>
|
||||||
</p>
|
<TableRow
|
||||||
</div>
|
class="bg-muted/20 hover:bg-muted/20"
|
||||||
|
>
|
||||||
|
<TableCell
|
||||||
|
colspan="5"
|
||||||
|
class="font-semibold"
|
||||||
|
>
|
||||||
|
{{ group.name }}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow
|
||||||
|
v-for="result in group.items"
|
||||||
|
:key="result.id"
|
||||||
|
>
|
||||||
|
<TableCell class="pl-6">
|
||||||
|
{{ result.product_variant?.name }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<MediaThumbnailCell
|
||||||
|
:items="
|
||||||
|
result.product_variant
|
||||||
|
?.images ?? []
|
||||||
|
"
|
||||||
|
:max-visible="1"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
class="text-right tabular-nums"
|
||||||
|
>
|
||||||
|
{{ result.cutting_result }} pcs
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
class="text-right tabular-nums"
|
||||||
|
>
|
||||||
|
{{ result.sample }} pcs
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
class="text-right tabular-nums"
|
||||||
|
>
|
||||||
|
{{ result.original_outside_sample }}
|
||||||
|
pcs
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -281,77 +325,33 @@ const groupedResults = computed<GroupedResults[]>(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Results Section -->
|
<!-- Summary -->
|
||||||
<div v-if="cutting.results.length" class="p-6">
|
<div class="p-6">
|
||||||
<h3 class="mb-3 text-sm font-semibold">Hasil Produk</h3>
|
<h3 class="mb-3 text-sm font-semibold">Ringkasan</h3>
|
||||||
<div class="overflow-hidden rounded-md border">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<Table>
|
<div class="rounded-lg border p-3">
|
||||||
<TableHeader>
|
<p class="text-xs text-muted-foreground">
|
||||||
<TableRow>
|
Total Hasil Cutting
|
||||||
<TableHead>Varian</TableHead>
|
</p>
|
||||||
<TableHead>Foto</TableHead>
|
<p class="mt-1 text-lg font-semibold text-primary">
|
||||||
<TableHead class="text-right"
|
{{ cutting.total_result_pieces ?? 0 }} pcs
|
||||||
>Hasil</TableHead
|
</p>
|
||||||
>
|
</div>
|
||||||
<TableHead class="text-right"
|
<div
|
||||||
>Sample</TableHead
|
v-if="
|
||||||
>
|
cutting.total_material_usage_summary_formatted
|
||||||
<TableHead class="text-right"
|
"
|
||||||
>Diluar Sample</TableHead
|
class="rounded-lg border p-3"
|
||||||
>
|
>
|
||||||
</TableRow>
|
<p class="text-xs text-muted-foreground">
|
||||||
</TableHeader>
|
Total Pemakaian Bahan
|
||||||
<TableBody>
|
</p>
|
||||||
<template
|
<p class="mt-1 text-lg font-semibold text-primary">
|
||||||
v-for="group in groupedResults"
|
{{
|
||||||
:key="group.key"
|
cutting.total_material_usage_summary_formatted
|
||||||
>
|
}}
|
||||||
<TableRow
|
</p>
|
||||||
class="bg-muted/20 hover:bg-muted/20"
|
</div>
|
||||||
>
|
|
||||||
<TableCell
|
|
||||||
colspan="5"
|
|
||||||
class="font-semibold"
|
|
||||||
>
|
|
||||||
{{ group.name }}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow
|
|
||||||
v-for="result in group.items"
|
|
||||||
:key="result.id"
|
|
||||||
>
|
|
||||||
<TableCell class="pl-6">
|
|
||||||
{{ result.product_variant?.name }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<MediaThumbnailCell
|
|
||||||
:items="
|
|
||||||
result.product_variant
|
|
||||||
?.images ?? []
|
|
||||||
"
|
|
||||||
:max-visible="1"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
class="text-right tabular-nums"
|
|
||||||
>
|
|
||||||
{{ result.cutting_result }} pcs
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
class="text-right tabular-nums"
|
|
||||||
>
|
|
||||||
{{ result.sample }} pcs
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
class="text-right tabular-nums"
|
|
||||||
>
|
|
||||||
{{ result.original_outside_sample }}
|
|
||||||
pcs
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</template>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -28,6 +28,7 @@ type SelectedMaterial = {
|
|||||||
unit: string;
|
unit: string;
|
||||||
unit_abbreviation: string;
|
unit_abbreviation: string;
|
||||||
stock_input: string;
|
stock_input: string;
|
||||||
|
stock_formatted?: string;
|
||||||
material_usage: string;
|
material_usage: string;
|
||||||
images?: CuttingCatalogPrice['images'];
|
images?: CuttingCatalogPrice['images'];
|
||||||
is_initial?: boolean;
|
is_initial?: boolean;
|
||||||
@ -89,6 +90,7 @@ function toggleVariant(rawMaterial: CuttingRawMaterialCatalogItem, price: Cuttin
|
|||||||
unit: rawMaterial.unit,
|
unit: rawMaterial.unit,
|
||||||
unit_abbreviation: rawMaterial.unit_abbreviation,
|
unit_abbreviation: rawMaterial.unit_abbreviation,
|
||||||
stock_input: price.stock_input,
|
stock_input: price.stock_input,
|
||||||
|
stock_formatted: price.stock_formatted,
|
||||||
material_usage: '1',
|
material_usage: '1',
|
||||||
images: price.images,
|
images: price.images,
|
||||||
});
|
});
|
||||||
@ -117,6 +119,7 @@ function initFromVariant() {
|
|||||||
unit: rawMaterial.unit,
|
unit: rawMaterial.unit,
|
||||||
unit_abbreviation: rawMaterial.unit_abbreviation,
|
unit_abbreviation: rawMaterial.unit_abbreviation,
|
||||||
stock_input: price.stock_input,
|
stock_input: price.stock_input,
|
||||||
|
stock_formatted: price.stock_formatted,
|
||||||
material_usage: '1',
|
material_usage: '1',
|
||||||
images: price.images,
|
images: price.images,
|
||||||
is_initial: true,
|
is_initial: true,
|
||||||
@ -187,8 +190,8 @@ watch(open, (value) => {
|
|||||||
<p class="truncate text-sm font-medium">
|
<p class="truncate text-sm font-medium">
|
||||||
{{ item.raw_material_name }} - {{ item.variant }}
|
{{ item.raw_material_name }} - {{ item.variant }}
|
||||||
</p>
|
</p>
|
||||||
<Badge v-if="item.is_initial" variant="secondary" class="text-xs mt-0.5">
|
<Badge variant="outline" class="text-xs mt-0.5 select-none font-normal">
|
||||||
Bahan awal
|
Stok: {{ item.stock_formatted }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1" @click.stop>
|
<div class="flex items-center gap-1" @click.stop>
|
||||||
|
|||||||
@ -58,26 +58,27 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isCreateMode" class="grid grid-cols-3 gap-2">
|
<div v-if="!isCreateMode" class="grid grid-cols-3 gap-2">
|
||||||
<Field :data-invalid="formErrors(form, `results.${index}.cutting_result`).length > 0 ? 'true' : undefined">
|
|
||||||
<FieldLabel class="text-xs">Hasil</FieldLabel>
|
|
||||||
<NumberInput v-model="item.cutting_result" class="h-8"
|
|
||||||
:aria-invalid="formErrors(form, `results.${index}.cutting_result`).length > 0"
|
|
||||||
@change="emit('sync-totals', item); emit('sync-field', index)" />
|
|
||||||
<FieldError :errors="formErrors(form, `results.${index}.cutting_result`)" class="text-[10px] mt-0.5 leading-tight" />
|
|
||||||
</Field>
|
|
||||||
<Field :data-invalid="formErrors(form, `results.${index}.sample`).length > 0 ? 'true' : undefined">
|
<Field :data-invalid="formErrors(form, `results.${index}.sample`).length > 0 ? 'true' : undefined">
|
||||||
<FieldLabel class="text-xs">Sample</FieldLabel>
|
<FieldLabel class="text-xs">Sample</FieldLabel>
|
||||||
<NumberInput v-model="item.sample" class="h-8"
|
<NumberInput v-model="item.sample" class="h-8"
|
||||||
:aria-invalid="formErrors(form, `results.${index}.sample`).length > 0"
|
:aria-invalid="formErrors(form, `results.${index}.sample`).length > 0"
|
||||||
@change="emit('sync-totals', item); emit('sync-field', index)" />
|
@change="item.cutting_result = String((Number(item.sample) || 0) + (Number(item.original_outside_sample) || 0)); emit('sync-field', index)" />
|
||||||
<FieldError :errors="formErrors(form, `results.${index}.sample`)" class="text-[10px] mt-0.5 leading-tight" />
|
<FieldError :errors="formErrors(form, `results.${index}.sample`)" class="text-[10px] mt-0.5 leading-tight" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field :data-invalid="formErrors(form, `results.${index}.original_outside_sample`).length > 0 ? 'true' : undefined">
|
<Field :data-invalid="formErrors(form, `results.${index}.original_outside_sample`).length > 0 ? 'true' : undefined">
|
||||||
<FieldLabel class="text-xs">Diluar Sample</FieldLabel>
|
<FieldLabel class="text-xs">Diluar Sample</FieldLabel>
|
||||||
<NumberInput v-model="item.original_outside_sample" class="h-8"
|
<NumberInput v-model="item.original_outside_sample" class="h-8"
|
||||||
:aria-invalid="formErrors(form, `results.${index}.original_outside_sample`).length > 0" />
|
:aria-invalid="formErrors(form, `results.${index}.original_outside_sample`).length > 0"
|
||||||
|
@change="item.cutting_result = String((Number(item.sample) || 0) + (Number(item.original_outside_sample) || 0)); emit('sync-field', index)" />
|
||||||
<FieldError :errors="formErrors(form, `results.${index}.original_outside_sample`)" class="text-[10px] mt-0.5 leading-tight" />
|
<FieldError :errors="formErrors(form, `results.${index}.original_outside_sample`)" class="text-[10px] mt-0.5 leading-tight" />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field :data-invalid="formErrors(form, `results.${index}.cutting_result`).length > 0 ? 'true' : undefined">
|
||||||
|
<FieldLabel class="text-xs">Hasil</FieldLabel>
|
||||||
|
<NumberInput v-model="item.cutting_result" class="h-8"
|
||||||
|
:aria-invalid="formErrors(form, `results.${index}.cutting_result`).length > 0"
|
||||||
|
@change="item.original_outside_sample = String(Math.max((Number(item.cutting_result) || 0) - (Number(item.sample) || 0), 0)); emit('sync-field', index)" />
|
||||||
|
<FieldError :errors="formErrors(form, `results.${index}.cutting_result`)" class="text-[10px] mt-0.5 leading-tight" />
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useForm } from '@inertiajs/vue3';
|
|||||||
import { watch } from 'vue';
|
import { watch } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||||
|
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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -194,6 +195,27 @@ function submitVerify() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cutting.materials && cutting.materials.length > 0" class="space-y-2 mt-4">
|
||||||
|
<p class="text-sm font-medium">Bahan Baku Terpakai</p>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<div v-for="material in cutting.materials" :key="material.id"
|
||||||
|
class="flex items-center gap-3 rounded-lg border p-3 bg-muted/10">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<MediaThumbnailCell :items="material.raw_material_price?.images ?? []" :max-visible="1" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm truncate">
|
||||||
|
{{ material.raw_material_name || material.raw_material_price?.raw_material?.name }} - {{ material.variant || material.raw_material_price?.variant }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Pemakaian: {{ material.material_usage_formatted }}
|
||||||
|
<span v-if="material.combination_id" class="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded ml-2 font-medium">Kombinasi</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FieldError :errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []" />
|
<FieldError :errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []" />
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
|
|||||||
@ -35,7 +35,7 @@ defineProps<{
|
|||||||
canComplete: boolean;
|
canComplete: boolean;
|
||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
cartEmpty: boolean;
|
cartEmpty: boolean;
|
||||||
submitLabel: string;
|
isCashierUser: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const printAfterSave = defineModel<boolean>('printAfterSave', { required: true });
|
const printAfterSave = defineModel<boolean>('printAfterSave', { required: true });
|
||||||
@ -70,7 +70,7 @@ const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field v-if="canComplete">
|
<Field v-if="canComplete && !isCashierUser">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Switch id="status-completed" :model-value="form.status === OrderStatus.COMPLETED"
|
<Switch id="status-completed" :model-value="form.status === OrderStatus.COMPLETED"
|
||||||
@update:model-value="form.status = $event ? OrderStatus.COMPLETED : OrderStatus.PENDING" />
|
@update:model-value="form.status = $event ? OrderStatus.COMPLETED : OrderStatus.PENDING" />
|
||||||
|
|||||||
@ -84,7 +84,7 @@ const form = useForm({
|
|||||||
discount: '',
|
discount: '',
|
||||||
nego_price: '',
|
nego_price: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
status: OrderStatus.PENDING as string,
|
status: (isCashierUser.value ? OrderStatus.COMPLETED : OrderStatus.PENDING) as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isStoreChannel = computed(() => form.channel === 'store');
|
const isStoreChannel = computed(() => form.channel === 'store');
|
||||||
@ -95,7 +95,7 @@ const showPhotoInput = computed(() =>
|
|||||||
|
|
||||||
const customerFormOpen = ref(false);
|
const customerFormOpen = ref(false);
|
||||||
const cartDetailOpen = ref(false);
|
const cartDetailOpen = ref(false);
|
||||||
const printAfterSave = ref(false);
|
const printAfterSave = ref(isCashierUser.value);
|
||||||
const selectedPaperSize = ref<'58mm' | '80mm'>(PaperSize.MM_80);
|
const selectedPaperSize = ref<'58mm' | '80mm'>(PaperSize.MM_80);
|
||||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||||
|
|
||||||
@ -203,7 +203,7 @@ function buildFormData(): FormData {
|
|||||||
if (isCashierUser.value) {
|
if (isCashierUser.value) {
|
||||||
formData.append('channel', 'store');
|
formData.append('channel', 'store');
|
||||||
formData.append('price_type', 'retail');
|
formData.append('price_type', 'retail');
|
||||||
formData.append('payment_type', OrderPaymentType.CASH);
|
formData.append('payment_type', form.payment_type);
|
||||||
} else {
|
} else {
|
||||||
formData.append('channel', form.channel);
|
formData.append('channel', form.channel);
|
||||||
formData.append('price_type', form.price_type);
|
formData.append('price_type', form.price_type);
|
||||||
@ -227,13 +227,18 @@ function buildFormData(): FormData {
|
|||||||
formData.append('discount', parseRupiah(form.discount));
|
formData.append('discount', parseRupiah(form.discount));
|
||||||
formData.append('nego_price', parseRupiah(form.nego_price));
|
formData.append('nego_price', parseRupiah(form.nego_price));
|
||||||
formData.append('notes', form.notes);
|
formData.append('notes', form.notes);
|
||||||
formData.append('status', form.status);
|
|
||||||
|
if (isCashierUser.value) {
|
||||||
|
formData.append('status', OrderStatus.COMPLETED);
|
||||||
|
} else {
|
||||||
|
formData.append('status', form.status);
|
||||||
|
}
|
||||||
|
|
||||||
if (showPhotoInput.value) {
|
if (showPhotoInput.value) {
|
||||||
appendRootPhotosToFormData(formData, photoState.value);
|
appendRootPhotosToFormData(formData, photoState.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCreateMode.value && printAfterSave.value) {
|
if (isCreateMode.value && (isCashierUser.value || printAfterSave.value)) {
|
||||||
formData.append('print', '1');
|
formData.append('print', '1');
|
||||||
formData.append('paper_size', selectedPaperSize.value);
|
formData.append('paper_size', selectedPaperSize.value);
|
||||||
}
|
}
|
||||||
@ -340,6 +345,7 @@ function submit() {
|
|||||||
:can-create="can('orders.create')"
|
:can-create="can('orders.create')"
|
||||||
:cart-empty="cart.length === 0"
|
:cart-empty="cart.length === 0"
|
||||||
:submit-label="submitLabel"
|
:submit-label="submitLabel"
|
||||||
|
:is-cashier-user="isCashierUser"
|
||||||
/>
|
/>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@ -107,7 +107,7 @@ const emit = defineEmits<{
|
|||||||
<FieldError :errors="formErrors(form, 'shopee_order_id')" />
|
<FieldError :errors="formErrors(form, 'shopee_order_id')" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field v-if="!isCashierUser">
|
<Field>
|
||||||
<FieldLabel for="payment_type" required>Tipe Pembayaran</FieldLabel>
|
<FieldLabel for="payment_type" required>Tipe Pembayaran</FieldLabel>
|
||||||
<Select v-model="form.payment_type" :disabled="isMarketplaceChannel">
|
<Select v-model="form.payment_type" :disabled="isMarketplaceChannel">
|
||||||
<SelectTrigger id="payment_type" class="w-full">
|
<SelectTrigger id="payment_type" class="w-full">
|
||||||
@ -163,7 +163,7 @@ const emit = defineEmits<{
|
|||||||
<FieldError :errors="formErrors(form, 'customer_id')" />
|
<FieldError :errors="formErrors(form, 'customer_id')" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field v-if="!isCashierUser">
|
||||||
<FieldLabel for="marketing">Marketing</FieldLabel>
|
<FieldLabel for="marketing">Marketing</FieldLabel>
|
||||||
<Select v-model="form.marketing_id" :disabled="isMarketingUser">
|
<Select v-model="form.marketing_id" :disabled="isMarketingUser">
|
||||||
<SelectTrigger id="marketing" class="w-full">
|
<SelectTrigger id="marketing" class="w-full">
|
||||||
|
|||||||
@ -12,9 +12,73 @@ import {
|
|||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import type { CuttingListItem } from '@/types/cutting';
|
import type { CuttingListItem } from '@/types/cutting';
|
||||||
|
|
||||||
|
interface GroupedCuttingMaterials {
|
||||||
|
rawMaterialId: number;
|
||||||
|
rawMaterialName: string;
|
||||||
|
unitLabel?: string;
|
||||||
|
items: any[];
|
||||||
|
isCombination?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
cuttings: CuttingListItem[];
|
cuttings: CuttingListItem[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
|
||||||
|
const combinationGroups: Record<number, any[]> = {};
|
||||||
|
const nonCombinationItems: any[] = [];
|
||||||
|
|
||||||
|
materials.forEach((item) => {
|
||||||
|
if (item.combination_id) {
|
||||||
|
if (!combinationGroups[item.combination_id]) {
|
||||||
|
combinationGroups[item.combination_id] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
combinationGroups[item.combination_id].push(item);
|
||||||
|
} else {
|
||||||
|
nonCombinationItems.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: GroupedCuttingMaterials[] = [];
|
||||||
|
|
||||||
|
Object.entries(combinationGroups).forEach(([combinationId, items]) => {
|
||||||
|
const firstItem = items[0];
|
||||||
|
const rawMaterialName = firstItem.raw_material_name || 'Bahan Baku Tidak Diketahui';
|
||||||
|
const unitLabel = firstItem.raw_material_unit_label;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
rawMaterialId: Number(combinationId) * -1,
|
||||||
|
rawMaterialName: `Kombinasi`,
|
||||||
|
unitLabel,
|
||||||
|
items,
|
||||||
|
isCombination: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups: Record<number, GroupedCuttingMaterials> = {};
|
||||||
|
|
||||||
|
nonCombinationItems.forEach((item) => {
|
||||||
|
const rawMaterialId = item.raw_material_id ?? 0;
|
||||||
|
const rawMaterialName = item.raw_material_name || 'Bahan Baku Tidak Diketahui';
|
||||||
|
const unitLabel = item.raw_material_unit_label;
|
||||||
|
|
||||||
|
if (!groups[rawMaterialId]) {
|
||||||
|
groups[rawMaterialId] = {
|
||||||
|
rawMaterialId,
|
||||||
|
rawMaterialName,
|
||||||
|
unitLabel,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[rawMaterialId].items.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
result.push(...Object.values(groups));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -46,7 +110,68 @@ defineProps<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4 space-y-4">
|
||||||
|
<!-- Bahan Baku Section -->
|
||||||
|
<div v-if="cutting.materials && cutting.materials.length" class="space-y-2">
|
||||||
|
<h4 class="text-sm font-semibold tracking-tight text-foreground">Bahan Baku</h4>
|
||||||
|
<div class="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Varian</TableHead>
|
||||||
|
<TableHead>Foto</TableHead>
|
||||||
|
<TableHead>Pemakaian</TableHead>
|
||||||
|
<TableHead>Hasil</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<template v-for="group in getGroupedMaterials(cutting.materials)" :key="group.rawMaterialId">
|
||||||
|
<TableRow class="bg-muted/20 hover:bg-muted/20">
|
||||||
|
<TableCell colspan="4" class="font-semibold text-foreground">
|
||||||
|
<template v-if="group.isCombination">
|
||||||
|
<span class="text-primary">{{ group.rawMaterialName }}</span>
|
||||||
|
<Badge variant="default" class="ml-2 font-normal">
|
||||||
|
{{ group.items.length }} bahan
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="group.items[0]?.combination_material_result !== null && group.items[0]?.combination_material_result !== undefined"
|
||||||
|
variant="secondary" class="ml-2 font-normal">
|
||||||
|
Hasil: {{ group.items[0].combination_material_result }} pcs
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ group.rawMaterialName }}
|
||||||
|
<Badge v-if="group.unitLabel" variant="secondary" class="ml-2 font-normal">
|
||||||
|
{{ group.unitLabel }}
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-for="material in group.items" :key="material.id">
|
||||||
|
<TableCell class="pl-6 font-medium">
|
||||||
|
{{ material.variant }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="tabular-nums">
|
||||||
|
{{ material.material_usage_formatted }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="tabular-nums">
|
||||||
|
<template v-if="!group.isCombination && material.material_result !== null && material.material_result !== undefined">
|
||||||
|
{{ material.material_result }} pcs
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
-
|
||||||
|
</template>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h4 class="text-sm font-semibold tracking-tight text-foreground">Hasil Verifikasi</h4>
|
<h4 class="text-sm font-semibold tracking-tight text-foreground">Hasil Verifikasi</h4>
|
||||||
<div class="rounded-md border">
|
<div class="rounded-md border">
|
||||||
|
|||||||
@ -11,9 +11,73 @@ import {
|
|||||||
import type { CuttingListItem } from '@/types/cutting';
|
import type { CuttingListItem } from '@/types/cutting';
|
||||||
import StockDataTableActions from './data-table-actions.vue';
|
import StockDataTableActions from './data-table-actions.vue';
|
||||||
|
|
||||||
|
interface GroupedCuttingMaterials {
|
||||||
|
rawMaterialId: number;
|
||||||
|
rawMaterialName: string;
|
||||||
|
unitLabel?: string;
|
||||||
|
items: any[];
|
||||||
|
isCombination?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
cuttings: CuttingListItem[];
|
cuttings: CuttingListItem[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function getGroupedMaterials(materials: any[]): GroupedCuttingMaterials[] {
|
||||||
|
const combinationGroups: Record<number, any[]> = {};
|
||||||
|
const nonCombinationItems: any[] = [];
|
||||||
|
|
||||||
|
materials.forEach((item) => {
|
||||||
|
if (item.combination_id) {
|
||||||
|
if (!combinationGroups[item.combination_id]) {
|
||||||
|
combinationGroups[item.combination_id] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
combinationGroups[item.combination_id].push(item);
|
||||||
|
} else {
|
||||||
|
nonCombinationItems.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: GroupedCuttingMaterials[] = [];
|
||||||
|
|
||||||
|
Object.entries(combinationGroups).forEach(([combinationId, items]) => {
|
||||||
|
const firstItem = items[0];
|
||||||
|
const rawMaterialName = firstItem.raw_material_name || 'Bahan Baku Tidak Diketahui';
|
||||||
|
const unitLabel = firstItem.raw_material_unit_label;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
rawMaterialId: Number(combinationId) * -1,
|
||||||
|
rawMaterialName: `Kombinasi`,
|
||||||
|
unitLabel,
|
||||||
|
items,
|
||||||
|
isCombination: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups: Record<number, GroupedCuttingMaterials> = {};
|
||||||
|
|
||||||
|
nonCombinationItems.forEach((item) => {
|
||||||
|
const rawMaterialId = item.raw_material_id ?? 0;
|
||||||
|
const rawMaterialName = item.raw_material_name || 'Bahan Baku Tidak Diketahui';
|
||||||
|
const unitLabel = item.raw_material_unit_label;
|
||||||
|
|
||||||
|
if (!groups[rawMaterialId]) {
|
||||||
|
groups[rawMaterialId] = {
|
||||||
|
rawMaterialId,
|
||||||
|
rawMaterialName,
|
||||||
|
unitLabel,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[rawMaterialId].items.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
result.push(...Object.values(groups));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -41,7 +105,7 @@ defineProps<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4 space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h4 class="text-sm font-semibold tracking-tight text-foreground">Hasil Produk</h4>
|
<h4 class="text-sm font-semibold tracking-tight text-foreground">Hasil Produk</h4>
|
||||||
<div class="rounded-md border">
|
<div class="rounded-md border">
|
||||||
@ -66,7 +130,8 @@ defineProps<{
|
|||||||
{{ result.product_variant?.product?.name }} ({{ result.product_variant?.name }})
|
{{ result.product_variant?.product?.name }} ({{ result.product_variant?.name }})
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="result.product_variant?.images ?? []" :max-visible="1" />
|
<MediaThumbnailCell :items="result.product_variant?.images ?? []"
|
||||||
|
:max-visible="1" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ result.cutting_result }} pcs
|
{{ result.cutting_result }} pcs
|
||||||
@ -82,6 +147,69 @@ defineProps<{
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cutting.materials && cutting.materials.length" class="space-y-2">
|
||||||
|
<h4 class="text-sm font-semibold tracking-tight text-foreground">Bahan Baku</h4>
|
||||||
|
<div class="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Varian</TableHead>
|
||||||
|
<TableHead>Foto</TableHead>
|
||||||
|
<TableHead>Pemakaian</TableHead>
|
||||||
|
<TableHead>Hasil</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<template v-for="group in getGroupedMaterials(cutting.materials)"
|
||||||
|
:key="group.rawMaterialId">
|
||||||
|
<TableRow class="bg-muted/20 hover:bg-muted/20">
|
||||||
|
<TableCell colspan="4" class="font-semibold text-foreground">
|
||||||
|
<template v-if="group.isCombination">
|
||||||
|
<span class="text-primary">{{ group.rawMaterialName }}</span>
|
||||||
|
<Badge variant="default" class="ml-2 font-normal">
|
||||||
|
{{ group.items.length }} bahan
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="group.items[0]?.combination_material_result !== null && group.items[0]?.combination_material_result !== undefined"
|
||||||
|
variant="secondary" class="ml-2 font-normal">
|
||||||
|
Hasil: {{ group.items[0].combination_material_result }} pcs
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ group.rawMaterialName }}
|
||||||
|
<Badge v-if="group.unitLabel" variant="secondary"
|
||||||
|
class="ml-2 font-normal">
|
||||||
|
{{ group.unitLabel }}
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-for="material in group.items" :key="material.id">
|
||||||
|
<TableCell class="pl-6 font-medium">
|
||||||
|
{{ material.variant }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<MediaThumbnailCell :items="material.images ?? []" :max-visible="1" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="tabular-nums">
|
||||||
|
{{ material.material_usage_formatted }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="tabular-nums">
|
||||||
|
<template
|
||||||
|
v-if="!group.isCombination && material.material_result !== null && material.material_result !== undefined">
|
||||||
|
{{ material.material_result }} pcs
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
-
|
||||||
|
</template>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -214,6 +214,27 @@ function submitVerify() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cutting.materials && cutting.materials.length > 0" class="space-y-2 mt-4">
|
||||||
|
<p class="text-sm font-medium">Bahan Baku Terpakai</p>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<div v-for="material in cutting.materials" :key="material.id"
|
||||||
|
class="flex items-center gap-3 rounded-lg border p-3 bg-muted/10">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<MediaThumbnailCell :items="material.raw_material_price?.images ?? []" :max-visible="1" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm truncate">
|
||||||
|
{{ material.raw_material_name || material.raw_material_price?.raw_material?.name }} - {{ material.variant || material.raw_material_price?.variant }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Pemakaian: {{ material.material_usage_formatted }}
|
||||||
|
<span v-if="material.combination_id" class="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded ml-2 font-medium">Kombinasi</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { NumberInput } from '@/components/form/number-input';
|
import { NumberInput } from '@/components/form/number-input';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { groupedTableRowNumber } from '@/lib/grouped-table';
|
import { groupedTableRowNumber } from '@/lib/grouped-table';
|
||||||
import {
|
import {
|
||||||
stokOpnameDifference,
|
stokOpnameDifference,
|
||||||
@ -39,56 +40,74 @@ const emit = defineEmits<{
|
|||||||
<div class="bg-muted/60 border-b px-4 py-2.5">
|
<div class="bg-muted/60 border-b px-4 py-2.5">
|
||||||
<span class="text-sm font-semibold">{{ group.product_name }}</span>
|
<span class="text-sm font-semibold">{{ group.product_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b">
|
<tr class="border-b bg-muted/20">
|
||||||
<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 w-12 px-4 text-center text-xs font-medium border-r">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-left text-xs font-medium border-r">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-left text-xs font-medium border-r w-28">Kualitas</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 border-r w-28">Stok Sistem</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-right text-xs font-medium border-r w-32">Stok Fisik</th>
|
||||||
|
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium border-r w-28">Selisih</th>
|
||||||
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Catatan</th>
|
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Catatan</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<template v-for="(vGroup, vIdx) in group.variants" :key="vGroup.variant_id">
|
||||||
v-for="(row, rIdx) in group.rows"
|
<tr
|
||||||
:key="row.variant_id"
|
v-for="(qRow, qIdx) in vGroup.qualities"
|
||||||
class="border-b transition-colors last:border-b-0 hover:bg-muted/30"
|
:key="`${vGroup.variant_id}_${qRow.quality_key}`"
|
||||||
>
|
class="border-b hover:bg-muted/10"
|
||||||
<td class="text-muted-foreground p-3 text-center">
|
>
|
||||||
{{ groupedTableRowNumber(group.startIndex + 1, rIdx) }}
|
<td
|
||||||
</td>
|
v-if="qIdx === 0"
|
||||||
<td class="p-3">{{ row.variant_name }}</td>
|
rowspan="3"
|
||||||
<td class="p-3 text-right tabular-nums">
|
class="text-muted-foreground p-3 text-center border-r border-b align-middle font-medium"
|
||||||
{{ row.system_stock_formatted ?? row.system_stock }}
|
|
||||||
</td>
|
|
||||||
<td class="p-3 text-right">
|
|
||||||
<NumberInput
|
|
||||||
:model-value="row.physical_stock"
|
|
||||||
class="ml-auto w-24 text-right tabular-nums"
|
|
||||||
@update:model-value="val => {
|
|
||||||
row.physical_stock = val === '' ? 0 : Number(val);
|
|
||||||
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)) }}
|
{{ groupedTableRowNumber(group.startIndex + 1, vIdx) }}
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<td
|
||||||
<td class="p-3">
|
v-if="qIdx === 0"
|
||||||
<Input
|
rowspan="3"
|
||||||
v-model="row.notes"
|
class="p-3 border-r border-b align-middle font-semibold bg-muted/5"
|
||||||
placeholder="Catatan..."
|
>
|
||||||
class="w-full min-w-[120px]"
|
{{ vGroup.variant_name }}
|
||||||
@input="emit('row-change')"
|
</td>
|
||||||
/>
|
<td class="p-3 border-r border-b text-left font-medium">
|
||||||
</td>
|
<Badge :variant="qRow.quality_key === 'good' ? 'outline' : (qRow.quality_key === 'retail' ? 'secondary' : 'destructive')" class="text-xs font-normal">
|
||||||
</tr>
|
{{ qRow.quality_label }}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-r border-b text-right tabular-nums">
|
||||||
|
{{ qRow.system_stock_formatted ?? qRow.system_stock }}
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-r border-b text-right">
|
||||||
|
<NumberInput
|
||||||
|
:model-value="qRow.row_ref.physical_stock"
|
||||||
|
class="ml-auto w-24 text-right tabular-nums h-8 text-xs"
|
||||||
|
@update:model-value="val => {
|
||||||
|
qRow.row_ref.physical_stock = val === '' ? 0 : Number(val);
|
||||||
|
emit('row-change');
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-r border-b text-right tabular-nums font-semibold">
|
||||||
|
<span
|
||||||
|
:class="stokOpnameDifferenceClass(stokOpnameDifference(qRow.row_ref.physical_stock, qRow.system_stock))"
|
||||||
|
>
|
||||||
|
{{ stokOpnameDifferenceText(stokOpnameDifference(qRow.row_ref.physical_stock, qRow.system_stock)) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b">
|
||||||
|
<Input
|
||||||
|
v-model="qRow.row_ref.notes"
|
||||||
|
placeholder="Catatan..."
|
||||||
|
class="w-full h-8 text-xs"
|
||||||
|
@input="emit('row-change')"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,30 +14,54 @@ export function buildStokOpnameVariantRows(
|
|||||||
catalog: CatalogProduct[],
|
catalog: CatalogProduct[],
|
||||||
existingItems: StokOpnameExistingItem[] = [],
|
existingItems: StokOpnameExistingItem[] = [],
|
||||||
): StokOpnameVariantRow[] {
|
): StokOpnameVariantRow[] {
|
||||||
const existingMap = new Map<number, { physical_stock: number; notes: string }>();
|
const existingMap = new Map<string, { physical_stock: number; notes: string }>();
|
||||||
|
|
||||||
for (const item of existingItems) {
|
for (const item of existingItems) {
|
||||||
existingMap.set(item.product_variant_id, {
|
const key = `${item.product_variant_id}_${item.stock_quality}`;
|
||||||
|
existingMap.set(key, {
|
||||||
physical_stock: item.physical_stock,
|
physical_stock: item.physical_stock,
|
||||||
notes: item.notes ?? '',
|
notes: item.notes ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows: StokOpnameVariantRow[] = [];
|
const rows: StokOpnameVariantRow[] = [];
|
||||||
|
const qualities = [
|
||||||
|
{ key: 'good', label: 'Bagus' },
|
||||||
|
{ key: 'retail', label: 'Eceran' },
|
||||||
|
{ key: 'reject', label: 'Reject' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
for (const product of catalog) {
|
for (const product of catalog) {
|
||||||
for (const variant of product.variants) {
|
for (const variant of product.variants) {
|
||||||
const existing = existingMap.get(variant.id);
|
for (const quality of qualities) {
|
||||||
|
const key = `${variant.id}_${quality.key}`;
|
||||||
|
const existing = existingMap.get(key);
|
||||||
|
|
||||||
rows.push({
|
let systemStock = 0;
|
||||||
product_name: product.name,
|
let systemStockFormatted = '0';
|
||||||
variant_id: variant.id,
|
if (quality.key === 'good') {
|
||||||
variant_name: variant.name,
|
systemStock = variant.stock;
|
||||||
system_stock: variant.stock,
|
systemStockFormatted = variant.stock_formatted ?? '0';
|
||||||
system_stock_formatted: variant.stock_formatted,
|
} else if (quality.key === 'retail') {
|
||||||
physical_stock: existing?.physical_stock ?? 0,
|
systemStock = variant.retail_stock ?? 0;
|
||||||
notes: existing?.notes ?? '',
|
systemStockFormatted = variant.retail_stock_formatted ?? '0';
|
||||||
});
|
} else if (quality.key === 'reject') {
|
||||||
|
systemStock = variant.reject_stock ?? 0;
|
||||||
|
systemStockFormatted = variant.reject_stock_formatted ?? '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
product_name: product.name,
|
||||||
|
variant_id: variant.id,
|
||||||
|
variant_name: `${variant.name} (${quality.label})`,
|
||||||
|
variant_name_raw: variant.name,
|
||||||
|
stock_quality: quality.key,
|
||||||
|
system_stock: systemStock,
|
||||||
|
system_stock_formatted: systemStockFormatted,
|
||||||
|
physical_stock: existing?.physical_stock ?? systemStock,
|
||||||
|
notes: existing?.notes ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,9 +90,10 @@ export function useStokOpnameForm(options: {
|
|||||||
|
|
||||||
const itemsPayload = computed(() =>
|
const itemsPayload = computed(() =>
|
||||||
variantRows.value
|
variantRows.value
|
||||||
.filter((row) => row.physical_stock > 0 || row.notes.trim() !== '')
|
.filter((row) => row.physical_stock !== row.system_stock || row.notes.trim() !== '')
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
product_variant_id: row.variant_id,
|
product_variant_id: row.variant_id,
|
||||||
|
stock_quality: row.stock_quality,
|
||||||
physical_stock: row.physical_stock,
|
physical_stock: row.physical_stock,
|
||||||
notes: row.notes || null,
|
notes: row.notes || null,
|
||||||
})),
|
})),
|
||||||
@ -76,19 +101,46 @@ export function useStokOpnameForm(options: {
|
|||||||
|
|
||||||
const groupedProducts = computed<StokOpnameProductGroup[]>(() => {
|
const groupedProducts = computed<StokOpnameProductGroup[]>(() => {
|
||||||
const groups: StokOpnameProductGroup[] = [];
|
const groups: StokOpnameProductGroup[] = [];
|
||||||
let currentProduct = '';
|
const productMap = new Map<string, Map<number, { variant_name: string; rows: StokOpnameVariantRow[] }>>();
|
||||||
let currentGroup: StokOpnameProductGroup | null = null;
|
|
||||||
let idx = 0;
|
|
||||||
|
|
||||||
for (const row of variantRows.value) {
|
for (const row of variantRows.value) {
|
||||||
if (row.product_name !== currentProduct) {
|
if (!productMap.has(row.product_name)) {
|
||||||
currentProduct = row.product_name;
|
productMap.set(row.product_name, new Map());
|
||||||
currentGroup = { product_name: row.product_name, rows: [], startIndex: idx };
|
}
|
||||||
groups.push(currentGroup);
|
const variantMap = productMap.get(row.product_name)!;
|
||||||
|
if (!variantMap.has(row.variant_id)) {
|
||||||
|
variantMap.set(row.variant_id, { variant_name: row.variant_name_raw || row.variant_name, rows: [] });
|
||||||
|
}
|
||||||
|
variantMap.get(row.variant_id)!.rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let variantIndex = 0;
|
||||||
|
for (const [productName, variantMap] of productMap.entries()) {
|
||||||
|
const productGroup: StokOpnameProductGroup = {
|
||||||
|
product_name: productName,
|
||||||
|
variants: [],
|
||||||
|
startIndex: variantIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [variantId, vData] of variantMap.entries()) {
|
||||||
|
productGroup.variants.push({
|
||||||
|
variant_id: variantId,
|
||||||
|
variant_name: vData.variant_name,
|
||||||
|
qualities: vData.rows.map((row) => {
|
||||||
|
const qualityLabel = row.stock_quality === 'good' ? 'Bagus' : (row.stock_quality === 'retail' ? 'Eceran' : 'Reject');
|
||||||
|
return {
|
||||||
|
quality_key: row.stock_quality,
|
||||||
|
quality_label: qualityLabel,
|
||||||
|
system_stock: row.system_stock,
|
||||||
|
system_stock_formatted: row.system_stock_formatted,
|
||||||
|
row_ref: row,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
variantIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentGroup!.rows.push(row);
|
groups.push(productGroup);
|
||||||
idx++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
|
|||||||
@ -54,6 +54,53 @@ const paginationSummary = usePaginationSummary(() => props.pagination, showingCo
|
|||||||
function rowNumber(index: number): number {
|
function rowNumber(index: number): number {
|
||||||
return groupedTableRowNumber(props.firstItem, index);
|
return groupedTableRowNumber(props.firstItem, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GroupedVariantItem {
|
||||||
|
variant_id: number;
|
||||||
|
variant_name: string;
|
||||||
|
product_name: string;
|
||||||
|
qualities: Array<{
|
||||||
|
id: number;
|
||||||
|
quality_label: string;
|
||||||
|
quality_key: string;
|
||||||
|
system_stock: number;
|
||||||
|
physical_stock: number;
|
||||||
|
difference: number;
|
||||||
|
notes: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupedItems(items: any[]): GroupedVariantItem[] {
|
||||||
|
const groups: Record<string, GroupedVariantItem> = {};
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const variantName = item.product_variant?.name || item.variant_name.replace(/\s*\([^)]*\)$/, '');
|
||||||
|
const key = `${item.product_variant_id}`;
|
||||||
|
|
||||||
|
if (!groups[key]) {
|
||||||
|
groups[key] = {
|
||||||
|
variant_id: item.product_variant_id,
|
||||||
|
variant_name: variantName,
|
||||||
|
product_name: item.product_name,
|
||||||
|
qualities: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = item.stock_quality === 'good' ? 'Bagus' : (item.stock_quality === 'retail' ? 'Eceran' : 'Reject');
|
||||||
|
|
||||||
|
groups[key].qualities.push({
|
||||||
|
id: item.id,
|
||||||
|
quality_key: item.stock_quality,
|
||||||
|
quality_label: label,
|
||||||
|
system_stock: item.system_stock,
|
||||||
|
physical_stock: item.physical_stock,
|
||||||
|
difference: item.difference,
|
||||||
|
notes: item.notes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(groups);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -107,47 +154,72 @@ function rowNumber(index: number): number {
|
|||||||
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow class="bg-muted/10">
|
||||||
<TableHead class="w-12 text-center">No.</TableHead>
|
<TableHead class="w-12 text-center border-r">No.</TableHead>
|
||||||
<TableHead>Produk</TableHead>
|
<TableHead class="border-r">Produk</TableHead>
|
||||||
<TableHead>Varian</TableHead>
|
<TableHead class="border-r">Varian</TableHead>
|
||||||
<TableHead class="text-right">Stok Sistem</TableHead>
|
<TableHead class="border-r w-28">Kualitas</TableHead>
|
||||||
<TableHead class="text-right">Stok Fisik</TableHead>
|
<TableHead class="text-right border-r w-28">Stok Sistem</TableHead>
|
||||||
<TableHead class="text-right">Selisih</TableHead>
|
<TableHead class="text-right border-r w-28">Stok Fisik</TableHead>
|
||||||
|
<TableHead class="text-right border-r w-28">Selisih</TableHead>
|
||||||
<TableHead>Catatan</TableHead>
|
<TableHead>Catatan</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-if="!stokOpname.items?.length" :key="`${stokOpname.id}-empty`">
|
<TableRow v-if="!stokOpname.items?.length" :key="`${stokOpname.id}-empty`">
|
||||||
<TableCell :colspan="7" class="text-muted-foreground text-center">
|
<TableCell :colspan="8" class="text-muted-foreground text-center">
|
||||||
Belum ada item
|
Belum ada item
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow v-for="(item, rIdx) in stokOpname.items" :key="item.id">
|
<template v-else v-for="(vGroup, vIdx) in getGroupedItems(stokOpname.items)" :key="vGroup.variant_id">
|
||||||
<TableCell class="text-center text-muted-foreground tabular-nums">
|
<TableRow
|
||||||
{{ rIdx + 1 }}
|
v-for="(qRow, qIdx) in vGroup.qualities"
|
||||||
</TableCell>
|
:key="qRow.id"
|
||||||
<TableCell class="font-medium">
|
class="border-b hover:bg-muted/10"
|
||||||
{{ item.product_name }}
|
>
|
||||||
</TableCell>
|
<td
|
||||||
<TableCell>
|
v-if="qIdx === 0"
|
||||||
{{ item.variant_name }}
|
:rowspan="vGroup.qualities.length"
|
||||||
</TableCell>
|
class="text-center text-muted-foreground tabular-nums border-r align-middle font-medium"
|
||||||
<TableCell class="text-right tabular-nums">
|
>
|
||||||
{{ item.system_stock }}
|
{{ vIdx + 1 }}
|
||||||
</TableCell>
|
</td>
|
||||||
<TableCell class="text-right tabular-nums">
|
<td
|
||||||
{{ item.physical_stock }}
|
v-if="qIdx === 0"
|
||||||
</TableCell>
|
:rowspan="vGroup.qualities.length"
|
||||||
<TableCell class="text-right tabular-nums">
|
class="font-semibold border-r align-middle"
|
||||||
<span :class="stokOpnameDifferenceClass(stokOpnameDifference(item.physical_stock, item.system_stock))">
|
>
|
||||||
{{ stokOpnameDifferenceText(stokOpnameDifference(item.physical_stock, item.system_stock)) }}
|
{{ vGroup.product_name }}
|
||||||
</span>
|
</td>
|
||||||
</TableCell>
|
<td
|
||||||
<TableCell class="text-muted-foreground">
|
v-if="qIdx === 0"
|
||||||
{{ item.notes || '-' }}
|
:rowspan="vGroup.qualities.length"
|
||||||
</TableCell>
|
class="font-medium border-r align-middle bg-muted/5"
|
||||||
</TableRow>
|
>
|
||||||
|
{{ vGroup.variant_name }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="p-3 border-r text-left font-medium">
|
||||||
|
<Badge :variant="qRow.quality_key === 'good' ? 'outline' : (qRow.quality_key === 'retail' ? 'secondary' : 'destructive')" class="text-xs font-normal">
|
||||||
|
{{ qRow.quality_label }}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-r text-right tabular-nums">
|
||||||
|
{{ qRow.system_stock }}
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-r text-right tabular-nums">
|
||||||
|
{{ qRow.physical_stock }}
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-r text-right tabular-nums font-semibold">
|
||||||
|
<span :class="stokOpnameDifferenceClass(qRow.difference)">
|
||||||
|
{{ stokOpnameDifferenceText(qRow.difference) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-muted-foreground">
|
||||||
|
{{ qRow.notes || '-' }}
|
||||||
|
</td>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export type StokOpnameItem = {
|
|||||||
product_variant_id: number;
|
product_variant_id: number;
|
||||||
variant_name: string;
|
variant_name: string;
|
||||||
product_name: string;
|
product_name: string;
|
||||||
|
stock_quality: 'good' | 'retail' | 'reject';
|
||||||
system_stock: number;
|
system_stock: number;
|
||||||
physical_stock: number;
|
physical_stock: number;
|
||||||
difference: number;
|
difference: number;
|
||||||
@ -45,6 +46,7 @@ export type StokOpnameFormData = {
|
|||||||
notes: string;
|
notes: string;
|
||||||
items: Array<{
|
items: Array<{
|
||||||
product_variant_id: number;
|
product_variant_id: number;
|
||||||
|
stock_quality: 'good' | 'retail' | 'reject';
|
||||||
physical_stock: number;
|
physical_stock: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
}>;
|
}>;
|
||||||
@ -55,6 +57,10 @@ export type CatalogVariant = {
|
|||||||
name: string;
|
name: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
stock_formatted?: string;
|
stock_formatted?: string;
|
||||||
|
retail_stock?: number;
|
||||||
|
retail_stock_formatted?: string;
|
||||||
|
reject_stock?: number;
|
||||||
|
reject_stock_formatted?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CatalogProduct = {
|
export type CatalogProduct = {
|
||||||
@ -69,20 +75,35 @@ export type StokOpnameVariantRow = {
|
|||||||
product_name: string;
|
product_name: string;
|
||||||
variant_id: number;
|
variant_id: number;
|
||||||
variant_name: string;
|
variant_name: string;
|
||||||
|
variant_name_raw?: string;
|
||||||
|
stock_quality: 'good' | 'retail' | 'reject';
|
||||||
system_stock: number;
|
system_stock: number;
|
||||||
system_stock_formatted?: string;
|
system_stock_formatted?: string;
|
||||||
physical_stock: number;
|
physical_stock: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StokOpnameVariantGroup = {
|
||||||
|
variant_id: number;
|
||||||
|
variant_name: string;
|
||||||
|
qualities: Array<{
|
||||||
|
quality_key: 'good' | 'retail' | 'reject';
|
||||||
|
quality_label: string;
|
||||||
|
system_stock: number;
|
||||||
|
system_stock_formatted?: string;
|
||||||
|
row_ref: StokOpnameVariantRow;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type StokOpnameProductGroup = {
|
export type StokOpnameProductGroup = {
|
||||||
product_name: string;
|
product_name: string;
|
||||||
rows: StokOpnameVariantRow[];
|
variants: StokOpnameVariantGroup[];
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StokOpnameExistingItem = {
|
export type StokOpnameExistingItem = {
|
||||||
product_variant_id: number;
|
product_variant_id: number;
|
||||||
|
stock_quality: 'good' | 'retail' | 'reject';
|
||||||
physical_stock: number;
|
physical_stock: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -248,6 +248,36 @@ function setupOrderDraftItems(User $user): ProductVariant
|
|||||||
|
|
||||||
expect($variant->fresh()->stock)->toBe($initialStock - 5);
|
expect($variant->fresh()->stock)->toBe($initialStock - 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cashier creates order automatically sets status to completed and unsets marketing and customer', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->assignRole('cashier');
|
||||||
|
$user->givePermissionTo(PermissionEnum::ORDERS_CREATE->value);
|
||||||
|
$user->forgetCachedPermissions();
|
||||||
|
|
||||||
|
setupOrderDraftItems($user);
|
||||||
|
|
||||||
|
$marketing = User::factory()->create();
|
||||||
|
$customer = Customer::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('admin.manage.orders.store'), [
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'marketing_id' => $marketing->id,
|
||||||
|
'channel' => OrderChannel::STORE->value,
|
||||||
|
'price_type' => PriceType::RETAIL->value,
|
||||||
|
'payment_type' => PaymentType::CASH->value,
|
||||||
|
'status' => OrderStatus::PENDING->value,
|
||||||
|
'discount' => 0,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('admin.manage.orders.index'));
|
||||||
|
|
||||||
|
$order = Order::query()->latest()->first();
|
||||||
|
expect($order)->not->toBeNull();
|
||||||
|
expect($order->status)->toBe(OrderStatus::COMPLETED);
|
||||||
|
expect($order->marketing_id)->toBeNull();
|
||||||
|
expect($order->customer_id)->toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Show ─────────────────────────────────────────────────
|
// ─── Show ─────────────────────────────────────────────────
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user