Compare commits

...

9 Commits

Author SHA1 Message Date
Yoga Pangestu
0d77d6a4fd feat: add stock quality management to StokOpname; update validation rules, database schema, and frontend components to support stock quality categorization (good, retail, reject) for improved inventory tracking and reporting
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run
2026-07-05 13:46:50 +07:00
Yoga Pangestu
18ccd7a3c0 fix: update SendPushNotificationJob to correctly handle expired subscriptions; modify PushSubscriptionService to ensure user_id is set during subscription creation 2026-07-05 13:33:18 +07:00
Yoga Pangestu
5a5d2c6f78 feat: enhance cutting materials display in stock management components; implement grouping logic for combinations and improve UI for material usage and results presentation 2026-07-05 13:27:52 +07:00
Yoga Pangestu
dbfd7ce48e feat: add stock_formatted field to CuttingPosCombinationDialog for improved stock display; update variant initialization and toggle logic to include formatted stock information 2026-07-05 13:12:00 +07:00
Yoga Pangestu
3a8a6982ec feat: enhance monthly revenue calculations in AnalysisService to include stock quality breakdown; update Analysis.vue to display net revenue for gudang and ecer categories, improving data visibility for users 2026-07-05 12:59:10 +07:00
Yoga Pangestu
150a969086 feat: update revenue tooltip and chart display logic in Analysis.vue to conditionally show net revenue and profit metrics based on user role; enhance user experience for cashiers 2026-07-05 02:38:21 +07:00
Yoga Pangestu
352599bf00 feat: update OrderService and frontend components to automatically set order status to completed for cashiers; adjust payment and marketing fields accordingly; enhance tests to verify new behavior 2026-07-05 02:24:24 +07:00
Yoga Pangestu
02fb2045d2 refactor: restructure results section in Share.vue for improved clarity; enhance display of cutting results with a new table format and computed logic for grouped results 2026-07-05 02:09:17 +07:00
Yoga Pangestu
27f55ef18e feat: enhance cutting results display in Share.vue and CuttingPosResultSummaryItems.vue; restructure results section for better clarity and add computed logic for cutting result calculations 2026-07-05 02:08:29 +07:00
27 changed files with 949 additions and 270 deletions

View File

@ -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'],
]; ];

View File

@ -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'],
]; ];

View File

@ -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();
} }
} }

View File

@ -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
{ {

View File

@ -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,
@ -622,7 +622,7 @@ function () use ($cutting, $validated): void {
$newCombinationId = $materialData['combination_id'] !== null $newCombinationId = $materialData['combination_id'] !== null
? $combinationIdMap[$materialData['combination_id']] ?? null ? $combinationIdMap[$materialData['combination_id']] ?? null
: null; : null;
$cutting->materials()->create([ $cutting->materials()->create([
'raw_material_price_id' => $materialData['raw_material_price_id'], 'raw_material_price_id' => $materialData['raw_material_price_id'],
'material_usage' => $materialData['material_usage'], 'material_usage' => $materialData['material_usage'],

View File

@ -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(

View File

@ -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

View File

@ -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,

View File

@ -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,
]; ];

View File

@ -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'],
] ]

View File

@ -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();
});
}
};

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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" />

View File

@ -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>

View File

@ -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">

View File

@ -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">

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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;
}; };

View File

@ -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 ─────────────────────────────────────────────────