209 lines
7.2 KiB
Markdown
209 lines
7.2 KiB
Markdown
# Plan: Batch Add Products — Photo Upload + Category Dropdown Fix
|
||
|
||
## Goal
|
||
Two fixes for `resources/js/Pages/BatchAddProducts.vue`:
|
||
1. **Optional photo upload per product leaf** (new products only) — tap an area to pick a photo, upload immediately via `useFileUpload`, and include the returned hashkey in the submit payload.
|
||
2. **Category field converts to a `<select>` dropdown** — currently uses `<input type="text" list="leaf-categories">` which behaves as plain text on mobile. Replace with a `<select>` populated from the `categories` ref (same data already loaded from `/Products/New/Category/Datalist`). Keep "None / other" as the first empty option so the field stays optional.
|
||
|
||
---
|
||
|
||
## Context
|
||
|
||
### Key files
|
||
- **Frontend**: `resources/js/Pages/BatchAddProducts.vue` — all changes happen here
|
||
- **Composable**: `resources/js/composables/useFileUpload.js` — `uploadFile(file)` → POST `/File/Upload/ProductMarket` → returns `{ hashkey }`. Already used in `CreateProductStoreOwner.vue`.
|
||
- **Backend**: `app/Http/Controllers/Market/BatchController.php` → `batchCreateProducts()` — needs to accept `photourl` and pass it to `Product::create()`.
|
||
- **Product model**: `app/Models/Market/Product.php` — `photourl` is already a cast array field (line 27/59).
|
||
|
||
### Category data
|
||
`fetchCategories()` already calls `POST /Products/New/Category/Datalist` → `{ success: true, categories: ['Vegetables', 'Fruits', ...] }`. The raw `categories` ref holds a string array. The `<datalist>` approach already uses it; replacing with `<select>` just maps the same array to `<option>` elements.
|
||
|
||
### Photo upload flow (existing pattern from CreateProductStoreOwner.vue)
|
||
```js
|
||
import { useFileUpload } from '../composables/useFileUpload.js'
|
||
const { uploadFile, uploadError } = useFileUpload({ category: 'ProductMarket' })
|
||
|
||
// on file input change:
|
||
const result = await uploadFile(file)
|
||
if (result?.hashkey) leaf.photoHash = result.hashkey
|
||
```
|
||
|
||
### Leaf state shape (current makeLeaf)
|
||
```js
|
||
const makeLeaf = () => ({
|
||
source: 'new',
|
||
product_hash: '',
|
||
linked: null,
|
||
name: '',
|
||
price: 0,
|
||
available: 0,
|
||
unitname: 'pcs',
|
||
description: '',
|
||
category: '',
|
||
subcategory: '',
|
||
barcode: '',
|
||
})
|
||
```
|
||
Add `photoHash: ''` and `photoUploading: false` to this shape.
|
||
|
||
### Backend payload change
|
||
In `saveProducts()`, for `source === 'new'` leaves add:
|
||
```js
|
||
photourl: p.photoHash ? [p.photoHash] : [],
|
||
```
|
||
In `BatchController::batchCreateProducts()`, add to the validator for `source === 'new'`:
|
||
```php
|
||
'photourl' => 'nullable|array',
|
||
'photourl.*' => 'nullable|string',
|
||
```
|
||
And in `Product::create([...])` add:
|
||
```php
|
||
'photourl' => $productData['photourl'] ?? [],
|
||
```
|
||
|
||
---
|
||
|
||
## Step-by-step implementation
|
||
|
||
### Step 1 — Add `photoHash` + `photoUploading` to leaf state
|
||
In `makeLeaf()`:
|
||
```js
|
||
const makeLeaf = () => ({
|
||
// ...existing fields...
|
||
photoHash: '',
|
||
photoUploading: false,
|
||
})
|
||
```
|
||
|
||
### Step 2 — Import `useFileUpload` and wire a single uploader instance
|
||
Because each leaf uploads independently, instantiate `useFileUpload` once and use a helper:
|
||
```js
|
||
import { useFileUpload } from '../composables/useFileUpload.js'
|
||
const { uploadFile } = useFileUpload({ category: 'ProductMarket' })
|
||
|
||
const handleLeafPhoto = async (index, event) => {
|
||
const file = event.target.files?.[0]
|
||
if (!file) return
|
||
products.value[index].photoUploading = true
|
||
const result = await uploadFile(file)
|
||
products.value[index].photoUploading = false
|
||
if (result?.hashkey) products.value[index].photoHash = result.hashkey
|
||
}
|
||
|
||
const removeLeafPhoto = (index) => {
|
||
products.value[index].photoHash = ''
|
||
}
|
||
```
|
||
|
||
### Step 3 — Add photo upload UI inside the `source === 'new'` template block
|
||
Place above the description field, after the Barcode row:
|
||
|
||
```html
|
||
<!-- Photo (optional) -->
|
||
<label class="form-label small fw-bold text-muted mb-1 mt-1">Photo</label>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<label
|
||
v-if="!product.photoHash"
|
||
class="btn btn-outline-secondary btn-sm rounded-pill flex-grow-1"
|
||
:class="{ disabled: product.photoUploading }"
|
||
:for="`photo-input-${index}`"
|
||
style="cursor:pointer;"
|
||
>
|
||
<span v-if="product.photoUploading">
|
||
<LoadingSpinner size="small" class="me-1" /> Uploading…
|
||
</span>
|
||
<span v-else>
|
||
<i class="fas fa-camera me-1"></i> Add Photo
|
||
</span>
|
||
</label>
|
||
<div v-else class="d-flex align-items-center gap-2 flex-grow-1">
|
||
<img
|
||
:src="`/RequestData/File/${product.photoHash}`"
|
||
class="rounded-2 border"
|
||
style="width:48px;height:48px;object-fit:cover;"
|
||
alt="Product photo"
|
||
/>
|
||
<button
|
||
class="btn btn-link btn-sm text-danger p-0"
|
||
@click="removeLeafPhoto(index)"
|
||
title="Remove photo"
|
||
>
|
||
<i class="fas fa-times-circle"></i>
|
||
</button>
|
||
</div>
|
||
<input
|
||
:id="`photo-input-${index}`"
|
||
type="file"
|
||
accept="image/*"
|
||
class="d-none"
|
||
@change="(e) => handleLeafPhoto(index, e)"
|
||
/>
|
||
</div>
|
||
```
|
||
|
||
Use `/RequestData/File/${hashkey}` for the preview (same path used by `FileList::resolvedUrl()` fallback).
|
||
|
||
### Step 4 — Replace category `<input list>` with `<select>`
|
||
Replace:
|
||
```html
|
||
<input
|
||
v-model="product.category"
|
||
type="text"
|
||
list="leaf-categories"
|
||
class="form-control form-control-sm"
|
||
placeholder="e.g. Vegetables"
|
||
>
|
||
```
|
||
With:
|
||
```html
|
||
<select v-model="product.category" class="form-select form-select-sm">
|
||
<option value="">— Category —</option>
|
||
<option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
|
||
</select>
|
||
```
|
||
Also remove the `<datalist id="leaf-categories">` element at the bottom of the template since it is no longer needed.
|
||
|
||
### Step 5 — Include `photourl` in the save payload
|
||
In `saveProducts()`, update the `source === 'new'` branch of the payload map:
|
||
```js
|
||
{
|
||
source: 'new',
|
||
name: p.name,
|
||
price: p.price,
|
||
available: p.available,
|
||
unitname: p.unitname,
|
||
description: p.description,
|
||
category: p.category,
|
||
subcategory: p.subcategory,
|
||
barcode: p.barcode,
|
||
photourl: p.photoHash ? [p.photoHash] : [],
|
||
}
|
||
```
|
||
|
||
### Step 6 — Update `BatchController::batchCreateProducts()` to persist photourl
|
||
In `app/Http/Controllers/Market/BatchController.php`, in the `source === 'new'` validator rules, add:
|
||
```php
|
||
'photourl' => 'nullable|array',
|
||
'photourl.*' => 'nullable|string',
|
||
```
|
||
In `Product::create([...])`, add:
|
||
```php
|
||
'photourl' => $productData['photourl'] ?? [],
|
||
```
|
||
|
||
---
|
||
|
||
## Definition of Done checklist
|
||
- [ ] New leaf cards show an "Add Photo" button that opens a file picker
|
||
- [ ] Selecting a photo uploads it and shows a thumbnail preview with a remove ×
|
||
- [ ] Loading spinner shown during upload; button disabled while uploading
|
||
- [ ] `photoHash` is included in the save payload as `photourl: [hash]`
|
||
- [ ] Backend accepts and stores `photourl` on the new product
|
||
- [ ] Category field is a `<select>` dropdown populated with categories from the API
|
||
- [ ] Selecting a category from the dropdown sets `product.category` correctly
|
||
- [ ] `<datalist id="leaf-categories">` removed from template
|
||
- [ ] Existing fields (subcategory, barcode, description) unchanged
|
||
- [ ] Dark-mode styles still apply (form-select already covered by existing dark-mode scoped rule)
|
||
- [ ] `source === 'existing'` leaves are unaffected (no photo upload shown, category not editable)
|
||
- [ ] Build passes (`npm run build`)
|