Compare commits
No commits in common. "eea4a6d6c569510b45f83dd52d41791d7c9b0e9d" and "e4c36c8759fa31c3c5899ffa05672e7b82447e89" have entirely different histories.
eea4a6d6c5
...
e4c36c8759
@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { CalendarDays, Clock, ShoppingBag, ShoppingCart } from '@lucide/vue';
|
||||
import { computed } from 'vue';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import admin from '@/routes/admin';
|
||||
|
||||
@ -44,10 +43,6 @@ const navItems: NavItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
return navItems.filter((item) => !item.permission || can(item.permission));
|
||||
});
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return page.url.startsWith(href);
|
||||
}
|
||||
@ -62,11 +57,11 @@ function badgeCount(item: NavItem): number {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="visibleItems.length > 0" class="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex justify-center pb-4 lg:hidden">
|
||||
<nav class="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex justify-center pb-4 lg:hidden">
|
||||
<div
|
||||
class="pointer-events-auto flex items-center gap-1 rounded-2xl border bg-background/95 px-2 py-2 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<template v-for="item in visibleItems" :key="item.title">
|
||||
<Link :href="item.href"
|
||||
<template v-for="item in navItems" :key="item.title">
|
||||
<Link v-if="!item.permission || can(item.permission)" :href="item.href"
|
||||
class="relative flex flex-col items-center gap-0.5 rounded-xl px-3.5 py-1.5 text-[11px] font-medium text-muted-foreground transition-all duration-200"
|
||||
:class="{
|
||||
'text-primary scale-110 drop-shadow-md': isActive(item.href),
|
||||
|
||||
@ -23,6 +23,7 @@ const props = defineProps<{
|
||||
|
||||
const initialData = computed(() => ({
|
||||
description: props.cutting.description ?? '',
|
||||
images: props.cutting.images ?? [],
|
||||
sewing_cost: String(props.cutting.sewing_cost ?? 0),
|
||||
other_cost: String(props.cutting.other_cost ?? 0),
|
||||
materials: props.cutting.materials.map((item) => ({
|
||||
|
||||
@ -143,7 +143,15 @@ const groupedResults = computed<GroupedResults[]>(() => {
|
||||
<p v-if="cutting.description" class="mt-3 text-sm">
|
||||
{{ cutting.description }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="cutting.images && cutting.images.length > 0"
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
>
|
||||
<MediaThumbnailCell
|
||||
:items="cutting.images"
|
||||
:max-visible="10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
|
||||
@ -9,7 +9,12 @@ import type {
|
||||
CuttingRawMaterialCatalogItem,
|
||||
CuttingResultCartItem,
|
||||
} from '@/types/cutting';
|
||||
import {
|
||||
appendRootPhotosToFormData,
|
||||
createMediaUploadState,
|
||||
} from '@/types/media';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import CuttingPosCartDetailDialog from './CuttingPosCartDetailDialog.vue';
|
||||
@ -41,7 +46,8 @@ const props = defineProps<{
|
||||
const isCreateMode = computed(() => props.method === 'post');
|
||||
const cartDetailOpen = ref(false);
|
||||
|
||||
// No image upload state needed
|
||||
const imageState = ref<MediaUploadState>(createMediaUploadState());
|
||||
const isUploading = computed(() => imageState.value.pendingUploads > 0);
|
||||
|
||||
// Make catalogs mutable so we can add new items from quick-create dialogs
|
||||
const rawMaterialCatalogState = ref<CuttingRawMaterialCatalogItem[]>([
|
||||
@ -103,12 +109,15 @@ const {
|
||||
|
||||
function populateForm() {
|
||||
if (!props.initialData) {
|
||||
imageState.value = createMediaUploadState();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
form.description = props.initialData.description;
|
||||
form.sewing_cost = props.initialData.sewing_cost ?? '0';
|
||||
form.other_cost = props.initialData.other_cost ?? '0';
|
||||
imageState.value = createMediaUploadState(props.initialData.images ?? []);
|
||||
setCarts(props.initialData.materials, props.initialData.results);
|
||||
}
|
||||
|
||||
@ -132,7 +141,7 @@ function buildFormData(): FormData {
|
||||
String(Number.parseInt(parseRupiah(form.other_cost), 10) || 0),
|
||||
);
|
||||
|
||||
// No images to append
|
||||
appendRootPhotosToFormData(formData, imageState.value);
|
||||
|
||||
if (props.method === 'put') {
|
||||
// Build combination results map
|
||||
@ -277,9 +286,9 @@ function onProductCreated(product: CuttingProductCatalogItem) {
|
||||
:categories="categories" :material-cart="materialCart" @decrease-result-qty="decreaseResultQty" @product-created="onProductCreated" />
|
||||
</div>
|
||||
|
||||
<CuttingPosSummaryPanel :form="form" :material-cart="materialCart"
|
||||
<CuttingPosSummaryPanel v-model:image-state="imageState" :form="form" :material-cart="materialCart"
|
||||
:result-cart="resultCart" :total-result-pieces="totalResultPieces" :submit-label="submitLabel"
|
||||
@submit="submit" :is-create-mode="isCreateMode"
|
||||
@submit="submit" :is-create-mode="isCreateMode" :is-uploading="isUploading"
|
||||
@open-detail="cartDetailOpen = true" @remove-material="removeMaterial"
|
||||
@sync-material-field="syncMaterialField" @remove-result="removeResult"
|
||||
@sync-result-totals="syncResultTotals" @sync-result-field="syncResultField"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Save, Scissors } from '@lucide/vue';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -19,6 +20,7 @@ import type {
|
||||
CuttingMaterialCartItem,
|
||||
CuttingResultCartItem,
|
||||
} from '@/types/cutting';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
import CuttingPosMaterialSummaryItems from './CuttingPosMaterialSummaryItems.vue';
|
||||
import CuttingPosResultSummaryItems from './CuttingPosResultSummaryItems.vue';
|
||||
|
||||
@ -29,6 +31,7 @@ defineProps<{
|
||||
totalResultPieces: number;
|
||||
submitLabel: string;
|
||||
isCreateMode: boolean;
|
||||
isUploading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -42,6 +45,10 @@ const emit = defineEmits<{
|
||||
'remove-combination': [combinationId: number];
|
||||
'sync-combination-result': [combinationId: number, result: number | null];
|
||||
}>();
|
||||
|
||||
const imageState = defineModel<MediaUploadState>('imageState', {
|
||||
required: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -76,6 +83,9 @@ const emit = defineEmits<{
|
||||
<FieldError :errors="formErrors(form, 'description')" />
|
||||
</Field>
|
||||
|
||||
<MediaDropzone id="cutting-images" v-model="imageState" label="Foto Cutting" :max-files="10"
|
||||
:errors="formErrors(form, 'images')" />
|
||||
|
||||
<CuttingPosMaterialSummaryItems :form="form" :material-cart="materialCart"
|
||||
@remove="emit('remove-material', $event)" @sync-field="emit('sync-material-field', $event)"
|
||||
@remove-combination="emit('remove-combination', $event)"
|
||||
@ -89,11 +99,18 @@ const emit = defineEmits<{
|
||||
@sync-field="emit('sync-result-field', $event)" />
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="form.processing ||
|
||||
isUploading ||
|
||||
materialCart.length === 0 ||
|
||||
resultCart.length === 0
|
||||
">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
{{
|
||||
isUploading
|
||||
? 'Mengunggah...'
|
||||
: form.processing
|
||||
? 'Menyimpan...'
|
||||
: submitLabel
|
||||
}}
|
||||
</Button>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
|
||||
@ -182,7 +182,11 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
||||
<p v-if="cutting.description" class="text-sm text-muted-foreground">
|
||||
{{ cutting.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="
|
||||
cutting.images && cutting.images.length > 0
|
||||
" class="flex flex-wrap gap-2 pt-1">
|
||||
<MediaThumbnailCell :items="cutting.images" :max-visible="10" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
||||
<span>Total Hasil Cutting
|
||||
<strong class="text-primary">{{
|
||||
|
||||
@ -687,119 +687,6 @@ function setupDraftItems(User $user): array
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Quick Create Product ─────────────────────────────────
|
||||
|
||||
describe('Quick Create Product', function () {
|
||||
test('authenticated user with permission can quick create product without prices', function () {
|
||||
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/admin/manage/cuttings/quick-create-product', [
|
||||
'name' => 'Test Product',
|
||||
'description' => 'Test Description',
|
||||
'variants' => [
|
||||
[
|
||||
'name' => 'Varian 1',
|
||||
'stock' => 10,
|
||||
'retail_stock' => 5,
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonStructure(['product' => ['id', 'name']]);
|
||||
|
||||
$this->assertDatabaseHas('products', ['name' => 'Test Product']);
|
||||
$this->assertDatabaseHas('product_variants', ['name' => 'Varian 1', 'stock' => 10]);
|
||||
});
|
||||
|
||||
test('quick create product without images is allowed', function () {
|
||||
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/admin/manage/cuttings/quick-create-product', [
|
||||
'name' => 'Product No Image',
|
||||
'variants' => [
|
||||
[
|
||||
'name' => 'Varian 1',
|
||||
'stock' => 5,
|
||||
'retail_stock' => 2,
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('products', ['name' => 'Product No Image']);
|
||||
});
|
||||
|
||||
test('quick create product requires name', function () {
|
||||
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/admin/manage/cuttings/quick-create-product', [
|
||||
'variants' => [
|
||||
[
|
||||
'name' => 'Varian 1',
|
||||
'stock' => 5,
|
||||
'retail_stock' => 2,
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('quick create product requires at least one variant', function () {
|
||||
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/admin/manage/cuttings/quick-create-product', [
|
||||
'name' => 'Test Product',
|
||||
'variants' => [],
|
||||
])
|
||||
->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('quick create product with categories', function () {
|
||||
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
|
||||
|
||||
$category = \App\Models\Category::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/admin/manage/cuttings/quick-create-product', [
|
||||
'name' => 'Product With Category',
|
||||
'category_ids' => [$category->id],
|
||||
'variants' => [
|
||||
[
|
||||
'name' => 'Varian 1',
|
||||
'stock' => 5,
|
||||
'retail_stock' => 2,
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$product = \App\Models\Product::where('name', 'Product With Category')->first();
|
||||
expect($product)->not->toBeNull();
|
||||
expect($product->categories)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('user without permission cannot quick create product', function () {
|
||||
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/admin/manage/cuttings/quick-create-product', [
|
||||
'name' => 'Test Product',
|
||||
'variants' => [
|
||||
[
|
||||
'name' => 'Varian 1',
|
||||
'stock' => 5,
|
||||
'retail_stock' => 2,
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Store with Combination ─────────────────────────────────
|
||||
|
||||
describe('Cutting Store with Combination', function () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user