Files
BarangaySystem/resources/js/Pages/BatchAddProducts.vue
2026-06-06 18:43:00 +08:00

775 lines
36 KiB
Vue

<script setup>
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import { usePageTitle } from '../composables/Core/usePageTitle'
import { useAuth } from '../composables/Core/useAuth'
import BackButton from '../Components/Core/BackButton.vue'
import LoadingSpinner from '../Components/LoadingSpinner.vue'
import { useFileUpload } from '../composables/useFileUpload.js'
const { navigate } = useNavigate()
const modal = useModal()
const { isStoreOwner } = useAuth()
const { uploadFile } = useFileUpload({ category: 'ProductMarket' })
usePageTitle('Batch Add Products')
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 = ''
}
const downloadingTemplate = ref(false)
const downloadTemplate = async () => {
downloadingTemplate.value = true
try {
const response = await axios.get('/admin/batch/products/template', {
responseType: 'blob',
})
const url = URL.createObjectURL(new Blob([response.data]))
const a = document.createElement('a')
a.href = url
a.download = 'bukidbounty-batch-products-template.xlsx'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err) {
modal.open({ title: 'Error', body: 'Failed to download template. Please try again.' })
} finally {
downloadingTemplate.value = false
}
}
const makeLeaf = () => ({
source: 'new',
product_hash: '',
linked: null,
name: '',
price: 0,
available: 0,
unitname: 'pcs',
description: '',
category: '',
subcategory: '',
barcode: '',
photoHash: '',
photoUploading: false,
})
const products = ref([makeLeaf()])
const saving = ref(false)
const categories = ref([])
const selectableStores = ref([])
const targetStoreHash = ref('')
const targetStore = computed(() =>
selectableStores.value.find(s => s.hashkey === targetStoreHash.value) || null
)
const addProduct = () => { products.value.push(makeLeaf()) }
const removeProduct = (index) => {
if (products.value.length > 1) products.value.splice(index, 1)
}
const fetchCategories = async () => {
try {
const response = await axios.post('/Products/New/Category/Datalist')
if (response.data && response.data.success) {
categories.value = response.data.categories
}
} catch (err) {
console.error('Error fetching categories:', err)
}
}
const fetchSelectableStores = async () => {
try {
const response = await axios.post('/Admin/Stores/Selectable')
if (response.data && response.data.success) {
selectableStores.value = response.data.data || []
}
} catch (err) {
console.error('Error loading stores:', err)
}
// Store owners must have an existing store before importing. Without
// one the backend rejects every row, so block the page and offer to
// create a store instead of letting them fill the form for nothing.
if (isStoreOwner.value && selectableStores.value.length === 0) {
modal.yesNoModal({
title: 'No store found',
body: 'You need to create a store before importing products.',
yesText: 'Create Store',
onYes: () => navigate({ page: 'CreateStore' }),
noText: 'Cancel',
onNo: () => navigate({ page: 'Home' }),
})
}
}
// --- Fuzzy picker modal -----------------------------------------------
const showPickerModal = ref(false)
const pickerLeafIndex = ref(-1)
const pickerQuery = ref('')
const pickerResults = ref([])
const pickerSearching = ref(false)
let pickerDebounce = null
const openPicker = (index) => {
pickerLeafIndex.value = index
pickerQuery.value = products.value[index].name || ''
pickerResults.value = []
showPickerModal.value = true
if (pickerQuery.value.trim().length >= 2) runPickerSearch({ warnIfEmpty: true })
}
const closePicker = () => {
showPickerModal.value = false
pickerLeafIndex.value = -1
pickerQuery.value = ''
pickerResults.value = []
}
const onPickerQueryInput = () => {
if (pickerDebounce) clearTimeout(pickerDebounce)
pickerDebounce = setTimeout(runPickerSearch, 250)
}
const runPickerSearch = async ({ warnIfEmpty = false } = {}) => {
const q = pickerQuery.value.trim()
if (q.length < 2) { pickerResults.value = []; return }
pickerSearching.value = true
try {
const { data } = await axios.post('/Products/Admin/FuzzySearch', {
name: q,
TargetStore: targetStoreHash.value || '',
})
pickerResults.value = (data && data.success) ? (data.data || []) : []
if (warnIfEmpty && pickerResults.value.length === 0) {
closePicker()
modal.open({
title: 'Warning',
body: `No existing global products found matching "${q}". Try a different name, or fill out this card to create a new product.`,
})
}
} catch (err) {
console.error('Fuzzy search failed:', err)
pickerResults.value = []
} finally {
pickerSearching.value = false
}
}
const selectGlobalProduct = (match) => {
if (match.already_in_store) return
const i = pickerLeafIndex.value
if (i < 0) return
products.value[i] = {
source: 'existing',
product_hash: match.hashkey,
linked: {
name: match.name,
price: match.price,
unitname: match.unitname,
category: match.category,
subcategory: match.subcategory,
description: match.description,
photourl: match.photourl,
},
name: match.name,
price: 0,
available: 0,
unitname: match.unitname,
description: '',
category: match.category || '',
subcategory: match.subcategory || '',
barcode: '',
}
closePicker()
}
const unlinkLeaf = (index) => {
products.value[index] = makeLeaf()
}
// --- Save -------------------------------------------------------------
const saveProducts = async () => {
const hasExisting = products.value.some(p => p.source === 'existing')
if ((hasExisting || isStoreOwner.value) && !targetStoreHash.value) {
modal.open({
title: 'Pick a Store',
body: isStoreOwner.value
? 'Select one of your stores at the top of the page before importing.'
: 'Select a target store at the top of the page to import existing products.'
})
return
}
for (let i = 0; i < products.value.length; i++) {
const p = products.value[i]
if (p.source === 'existing') {
if (!p.product_hash) {
modal.open({ title: 'Validation Error', body: `Row ${i + 1}: existing product link missing.` })
return
}
} else {
if (!p.name || p.price < 0 || p.available < 0 || !p.unitname) {
modal.open({
title: 'Validation Error',
body: `Row ${i + 1}: fill in Name, Price, Stock, and Unit.`
})
return
}
}
}
const payload = {
target_store_hash: targetStoreHash.value || null,
products: products.value.map(p => p.source === 'existing'
? {
source: 'existing',
product_hash: p.product_hash,
price: p.price,
available: p.available,
description: p.description,
}
: {
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] : [],
}),
}
saving.value = true
try {
const response = await axios.post('/admin/batch/products', payload)
if (response.data && response.data.success) {
modal.open({
title: 'Success',
body: `Successfully added ${response.data.count} products.`,
onClose: () => navigate({ page: 'ManageProductsAdmin' })
})
}
} catch (err) {
console.error('Error saving batch products:', err)
const errorMessage = err.response?.data?.errors
? err.response.data.errors.join('<br>')
: (err.response?.data?.message || 'Failed to save products.')
modal.open({ title: 'Error', body: errorMessage })
} finally {
saving.value = false
}
}
onMounted(() => {
fetchCategories()
fetchSelectableStores()
})
</script>
<template>
<div class="batch-add-page min-vh-100 bg-light pb-5">
<header class="header-premium text-white py-4 shadow-sm position-relative overflow-hidden mb-4 bg-primary-gradient">
<div class="container position-relative z-2">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-4">
<div class="d-flex align-items-center gap-4 animate-fade-in">
<div class="display-container position-relative bg-white rounded-circle p-3 shadow">
<i class="fas fa-boxes-stacked fa-2x text-primary"></i>
</div>
<div>
<h2 class="fw-bold text-white mb-0">Batch Add Products</h2>
<p class="text-white-50 small text-uppercase ls-wide mt-1">Add multiple products each leaf is a complete product</p>
</div>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0 w-100 w-md-auto flex-wrap">
<BackButton to="ManageProductsAdmin" />
<button @click="navigate({ page: 'ManageProductsAdmin' })" class="btn btn-outline-light btn-sm fw-semibold rounded-pill shadow-sm">
<i class="fas fa-list me-2"></i> All Products
</button>
<button @click="downloadTemplate" :disabled="downloadingTemplate" class="btn btn-outline-light btn-sm fw-semibold rounded-pill shadow-sm">
<span v-if="downloadingTemplate"><LoadingSpinner size="small" class="me-1" /></span>
<span v-else><i class="fas fa-file-excel me-2"></i></span>
Template
</button>
<button @click="saveProducts" :disabled="saving" class="btn btn-light btn-sm fw-semibold shadow-sm">
<span v-if="saving"><LoadingSpinner size="small" class="me-2" /> Saving...</span>
<span v-else><i class="fas fa-save me-2"></i> Save All</span>
</button>
</div>
</div>
</div>
</header>
<div class="container">
<!-- Template download card -->
<div class="card border-0 shadow-sm rounded-4 mb-4 template-card overflow-hidden">
<div class="card-body p-0">
<div class="d-flex flex-wrap align-items-center gap-4 p-4">
<div class="d-flex align-items-center gap-4 flex-grow-1">
<div class="template-icon-wrap rounded-3 d-flex align-items-center justify-content-center flex-shrink-0">
<i class="fas fa-file-excel fa-2x text-success"></i>
</div>
<div>
<h6 class="fw-bold mb-1">Excel Template</h6>
<p class="text-muted small mb-0">
Download the pre-formatted Excel template. Fill in product data and add photos using
<strong>Insert Pictures This Device</strong> in each Photo cell.
Then enter your data here using the cards below.
</p>
</div>
</div>
<button
@click="downloadTemplate"
:disabled="downloadingTemplate"
class="btn btn-success rounded-pill fw-semibold px-4 shadow-sm w-100 w-md-auto"
>
<span v-if="downloadingTemplate">
<LoadingSpinner size="small" class="me-2" />Downloading
</span>
<span v-else>
<i class="fas fa-download me-2"></i>Download Template
</span>
</button>
</div>
<div class="template-steps d-flex gap-0 border-top">
<div class="step-item flex-fill text-center py-2 px-2 border-end">
<div class="fw-bold small text-primary"> Download</div>
<div class="smallest text-muted">Get the .xlsx template</div>
</div>
<div class="step-item flex-fill text-center py-2 px-2 border-end">
<div class="fw-bold small text-primary"> Fill in Excel</div>
<div class="smallest text-muted">Name, Price, Stock, Unit</div>
</div>
<div class="step-item flex-fill text-center py-2 px-2 border-end">
<div class="fw-bold small text-primary"> Add Photos</div>
<div class="smallest text-muted">Insert Pictures per row</div>
</div>
<div class="step-item flex-fill text-center py-2 px-2">
<div class="fw-bold small text-primary"> Enter here + 📷</div>
<div class="smallest text-muted">Use cards below + camera</div>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-lg rounded-4 bg-white overflow-hidden mb-4">
<div class="card-body p-4">
<div class="mb-4 pb-3 border-bottom">
<label class="form-label small fw-bold text-muted mb-1">Target Store (optional for new products, required to import existing)</label>
<select v-model="targetStoreHash" class="form-select">
<option value="">No store create global products only</option>
<option v-for="store in selectableStores" :key="store.hashkey" :value="store.hashkey">
{{ store.name }}<span v-if="store.role"> ({{ store.role }})</span>
</option>
</select>
<div class="form-text smallest">
When a store is picked, every leaf (new or imported) is also listed in that store with its price, stock, and description.
</div>
</div>
<div class="d-grid mb-3">
<button @click="addProduct" class="btn btn-primary fw-bold rounded-pill">
<i class="fas fa-plus-circle me-2"></i> Add Product
</button>
</div>
<div class="alert alert-info border-0 rounded-3 small mb-4">
<i class="fas fa-info-circle me-2"></i>
Each card is a product. Use <strong>Pick existing</strong> to import a global product into the target store with your own price/stock/description.
</div>
<div class="row g-4">
<div
v-for="(product, index) in products"
:key="index"
class="col-md-6 col-lg-4"
>
<div class="leaf-card p-3 bg-white rounded-3 border position-relative h-100" :class="{ 'is-imported': product.source === 'existing' }">
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary rounded-pill">#{{ index + 1 }}</span>
<span v-if="product.source === 'existing'" class="badge bg-success-subtle text-success border border-success-subtle">
<i class="fas fa-link me-1"></i>Imported
</span>
</div>
<div class="d-flex align-items-center gap-2">
<button
v-if="product.source === 'new'"
@click="openPicker(index)"
class="btn btn-link btn-sm text-primary p-0 fw-semibold text-decoration-none text-nowrap"
title="Pick an existing global product"
>
<i class="fas fa-search me-1"></i> Pick existing
</button>
<button
v-else
@click="unlinkLeaf(index)"
class="btn btn-link btn-sm text-secondary p-0 fw-semibold text-decoration-none text-nowrap"
title="Unlink and start fresh"
>
<i class="fas fa-unlink me-1"></i> Unlink
</button>
<button
@click="removeProduct(index)"
class="btn btn-link text-danger p-0 border-0"
:disabled="products.length <= 1"
title="Remove product"
>
<i class="fas fa-times-circle"></i>
</button>
</div>
</div>
<template v-if="product.source === 'existing'">
<div class="mb-2">
<div class="fw-bold">{{ product.linked.name }}</div>
<div class="text-muted smallest">
<span v-if="product.linked.category">{{ product.linked.category }}<span v-if="product.linked.subcategory"> · {{ product.linked.subcategory }}</span> · </span>
<span>Global: {{ product.linked.price }} / {{ product.linked.unitname }}</span>
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-7">
<label class="form-label small fw-bold text-muted mb-1">Store Price</label>
<div class="input-group input-group-sm">
<span class="input-group-text bg-white text-muted"></span>
<input
v-model.number="product.price"
type="number"
step="0.01"
class="form-control fw-bold"
:placeholder="`Default ${product.linked.price}`"
>
</div>
<div class="form-text smallest">Leave 0 to use global {{ product.linked.price }}.</div>
</div>
<div class="col-5">
<label class="form-label small fw-bold text-muted mb-1">Stock *</label>
<input v-model.number="product.available" type="number" class="form-control form-control-sm fw-bold" placeholder="0">
</div>
</div>
<label class="form-label small fw-bold text-muted mb-1">Store Description</label>
<input
v-model="product.description"
type="text"
class="form-control form-control-sm"
:placeholder="product.linked.description ? `Default: ${product.linked.description}` : 'Leave blank to use global default'"
>
</template>
<template v-else>
<label class="form-label small fw-bold text-muted mb-1">Product Name *</label>
<input
v-model="product.name"
type="text"
class="form-control form-control-sm fw-bold mb-2"
placeholder="e.g. Banana"
>
<div class="row g-2 mb-2">
<div class="col-7">
<label class="form-label small fw-bold text-muted mb-1">Price *</label>
<div class="input-group input-group-sm">
<span class="input-group-text bg-white text-muted"></span>
<input v-model.number="product.price" type="number" step="0.01" class="form-control fw-bold" placeholder="0.00">
</div>
</div>
<div class="col-5">
<label class="form-label small fw-bold text-muted mb-1">Stock *</label>
<input v-model.number="product.available" type="number" class="form-control form-control-sm fw-bold" placeholder="0">
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Unit *</label>
<input v-model="product.unitname" type="text" class="form-control form-control-sm" placeholder="pcs, kg...">
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Category</label>
<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>
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Subcategory</label>
<input v-model="product.subcategory" type="text" class="form-control form-control-sm" placeholder="Optional">
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Barcode</label>
<input v-model="product.barcode" type="text" class="form-control form-control-sm" placeholder="UPC / EAN">
</div>
</div>
<!-- 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 mb-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>
<label class="form-label small fw-bold text-muted mb-1">Description</label>
<input v-model="product.description" type="text" class="form-control form-control-sm" placeholder="Short description">
</template>
</div>
</div>
</div>
<div class="d-grid mt-4">
<button @click="addProduct" class="btn btn-outline-primary rounded-pill px-4 fw-semibold">
<i class="fas fa-plus-circle me-2"></i> Add Another Product
</button>
</div>
<div class="d-grid mt-3 pt-3 border-top">
<button @click="saveProducts" :disabled="saving" class="btn btn-primary rounded-pill px-4 fw-semibold">
<i class="fas fa-save me-2" :class="{ 'fa-spin': saving }"></i>
{{ saving ? 'Saving...' : 'Save All Products' }}
</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="showPickerModal" class="bb-modal-backdrop" @click.self="closePicker">
<div class="bb-modal">
<div class="bb-modal-header">
<div class="flex-grow-1 me-2">
<h4 class="fw_7 mb-1">Pick an existing global product</h4>
<p class="text-muted small mb-0">
<span v-if="targetStore">It will be imported into <strong>{{ targetStore.name }}</strong> with your store-specific price, stock, and description.</span>
<span v-else class="text-warning"><i class="fas fa-exclamation-triangle me-1"></i> Select a target store at the top of the page first.</span>
</p>
</div>
<button class="bb-modal-close" @click="closePicker" aria-label="Close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="bb-modal-body">
<input
v-model="pickerQuery"
@input="onPickerQueryInput"
type="text"
class="form-control mb-3"
placeholder="Search global products by name..."
autofocus
>
<div v-if="pickerSearching" class="text-center text-muted py-3">
<LoadingSpinner size="small" /> Searching...
</div>
<div v-else-if="pickerResults.length === 0 && pickerQuery.trim().length >= 2" class="text-muted text-center py-3">
No matching global products found.
</div>
<div v-else>
<div v-for="m in pickerResults" :key="m.hashkey" class="match-row d-flex align-items-center justify-content-between gap-2 p-2 border rounded mb-2">
<div class="flex-grow-1">
<div class="fw_6">{{ m.name }}</div>
<div class="text-muted small">
<span v-if="m.category">{{ m.category }}<span v-if="m.subcategory"> · {{ m.subcategory }}</span> · </span>
<span>{{ m.price }} / {{ m.unitname }}</span>
</div>
<div v-if="m.already_in_store" class="text-success smallest mt-1">
<i class="fas fa-check-circle me-1"></i> Already in this store
</div>
</div>
<button
class="btn btn-sm btn-primary rounded-pill flex-shrink-0"
:disabled="m.already_in_store || !targetStoreHash"
@click="selectGlobalProduct(m)"
>
<span v-if="m.already_in_store">In Store</span>
<span v-else>Use this</span>
</button>
</div>
</div>
</div>
<div class="bb-modal-footer">
<button class="btn btn-link text-muted" @click="closePicker">Cancel</button>
</div>
</div>
</div>
</template>
<style scoped>
.bg-primary-gradient {
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
}
.leaf-card {
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.leaf-card:hover {
box-shadow: 0 4px 12px rgba(13, 110, 253, 0.08);
transform: translateY(-2px);
}
.leaf-card.is-imported {
border-color: #198754 !important;
background: linear-gradient(180deg, rgba(25, 135, 84, 0.04) 0%, rgba(255, 255, 255, 0) 60%);
}
:global(.dark-mode) .bg-light {
background-color: var(--bg-tertiary) !important;
}
:global(.dark-mode) .card {
background-color: var(--bg-card);
}
:global(.dark-mode) .leaf-card {
background-color: var(--bg-secondary) !important;
border-color: var(--border-color) !important;
}
:global(.dark-mode) .form-control,
:global(.dark-mode) .form-select,
:global(.dark-mode) .input-group-text {
background-color: var(--bg-secondary) !important;
color: var(--text-primary);
border-color: var(--border-color);
}
.bb-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 16px;
}
.bb-modal {
background: #fff;
border-radius: 16px;
width: 100%;
max-width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35);
}
.bb-modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
}
.bb-modal-close {
background: transparent;
border: none;
color: #64748b;
cursor: pointer;
font-size: 1rem;
padding: 4px 8px;
}
.bb-modal-body {
padding: 16px 24px;
overflow-y: auto;
}
.bb-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
}
:global(.dark-mode) .bb-modal {
background: var(--bg-card);
color: var(--text-primary);
}
:global(.dark-mode) .bb-modal-header,
:global(.dark-mode) .bb-modal-footer {
border-color: var(--border-color);
}
/* Template download card */
.template-card {
border: 1.5px solid #d1fae5 !important;
background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
}
.template-icon-wrap {
width: 56px;
height: 56px;
background: #dcfce7;
}
.template-steps {
background: rgba(0, 0, 0, 0.02);
}
.step-item:last-child {
border-right: 0 !important;
}
.smallest {
font-size: 0.72rem;
}
:global(.dark-mode) .template-card {
background: linear-gradient(135deg, rgba(16,185,129,0.08) 0%, rgba(5,150,105,0.05) 100%);
border-color: rgba(16,185,129,0.3) !important;
}
:global(.dark-mode) .template-icon-wrap {
background: rgba(16,185,129,0.15);
}
:global(.dark-mode) .template-steps {
border-color: var(--border-color) !important;
}
:global(.dark-mode) .step-item {
border-color: var(--border-color) !important;
}
</style>