initial: bootstrap from BukidBountyApp base

This commit is contained in:
Jonathan Sykes
2026-06-06 18:43:00 +08:00
commit eb4a5731fb
5674 changed files with 160857 additions and 0 deletions

View File

@@ -0,0 +1,378 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import axios from 'axios';
import LoadingSpinner from '../LoadingSpinner.vue';
import Dropzone from '../Core/Dropzone.vue';
import { useFileUpload } from '../../composables/useFileUpload.js';
const props = defineProps({
productHash: { type: String, required: true },
storeHash: { type: String, default: null },
onSaved: { type: Function, default: null },
onClose: { type: Function, default: null }
});
const { uploadFile, removeHash, setInitialHashes, isUploading: isFileUploading } = useFileUpload({
category: 'ProductMarket',
maxSizeMB: 10
});
// Form state
const productName = ref('');
const productDescription = ref('');
const productCategory = ref('');
const productSubcategory = ref('');
const productPrice = ref(0);
const productUnitName = ref('');
const productAvailable = ref(0);
const productBarcode = ref('');
// Data lists
const categoryList = ref([]);
const subcategoryList = ref([]);
// Loading state
const isLoading = ref(false);
const isSaving = ref(false);
const showSuccessState = ref(false);
const successMessage = ref('');
const error = ref(null);
// Dropzone handling
const dropzoneRef = ref(null);
const dropzoneFiles = ref([]);
onMounted(async () => {
await loadCategories();
await loadProductData();
});
const loadCategories = async () => {
try {
const response = await axios.post('/Products/New/Category/Datalist', {});
const data = response.data.categories || response.data;
if (data && Array.isArray(data)) {
categoryList.value = data.map(item => ({
value: typeof item === 'string' ? item : item[0],
label: typeof item === 'string' ? item : (item[1] || item[0])
}));
}
} catch (err) {
console.error('Error loading categories:', err);
}
};
const loadSubcategories = async () => {
if (!productCategory.value) {
subcategoryList.value = [];
return;
}
try {
const response = await axios.post('/Products/New/SubCategory/Datalist', {
category: productCategory.value
});
const data = response.data.subcategories || response.data;
if (data && Array.isArray(data)) {
subcategoryList.value = data.map(item => ({
value: typeof item === 'string' ? item : item[0],
label: typeof item === 'string' ? item : (item[1] || item[0])
}));
}
} catch (err) {
console.error('Error loading subcategories:', err);
}
};
const loadProductData = async () => {
try {
isLoading.value = true;
const response = await axios.post('/View/Product/Details/data', {
target: props.productHash,
data: {
product_id: props.productHash,
store_hash: props.storeHash
}
});
if (response.data && response.data.success && response.data.data) {
const product = response.data.data;
productName.value = product.name || '';
productDescription.value = props.storeHash ? (product.store_description || product.description) : (product.description || '');
productCategory.value = product.category || '';
productSubcategory.value = product.subcategory || '';
productPrice.value = props.storeHash ? (product.store_price || product.price) : (product.price || 0);
productUnitName.value = product.unitname || '';
productBarcode.value = product.barcode || '';
productAvailable.value = product.available || 0;
if (productCategory.value) {
await loadSubcategories();
productSubcategory.value = product.subcategory || '';
}
if (product.photourlDropzone && Array.isArray(product.photourlDropzone)) {
dropzoneFiles.value = product.photourlDropzone.map(f => ({
file: { name: f.name || 'Image' },
hashkey: f.hashkey,
progress: 100,
uploading: false,
preview: f.url
}));
setInitialHashes(product.photourlDropzone.map(f => f.hashkey));
}
}
} catch (err) {
console.error('Error loading product data:', err);
error.value = 'Failed to load product data';
} finally {
isLoading.value = false;
}
};
watch(() => dropzoneFiles.value, async (newFiles) => {
const filesToUpload = newFiles.filter(f => !f.uploading && !f.hashkey && !f.error);
for (const fileObj of filesToUpload) {
const index = newFiles.indexOf(fileObj);
if (index === -1) continue;
dropzoneRef.value.setFileStatus(index, { uploading: true, progress: 30 });
const result = await uploadFile(fileObj.file);
if (result && result.hashkey) {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 100,
hashkey: result.hashkey
});
} else {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 0,
error: 'Upload failed'
});
}
}
}, { deep: true });
const handlePhotoRemoved = (hashkey) => {
removeHash(hashkey);
};
const handleCategoryChange = () => {
loadSubcategories();
};
const handleSubmit = async () => {
if (!props.storeHash && (!productName.value || !productCategory.value)) {
error.value = 'Name and Category are required';
return;
}
try {
isSaving.value = true;
error.value = null;
const response = await axios.post('/Products/Admin/Edit/', {
target: props.productHash,
data: {
store_hash: props.storeHash
},
EditProductName: productName.value,
EditProductDescription: productDescription.value,
EditProductCategory: productCategory.value,
EditProductSubCategory: productSubcategory.value,
EditProductPrice: parseFloat(productPrice.value),
EditProductUnitName: productUnitName.value,
EditProductAvailable: parseInt(productAvailable.value),
EditProductBarcode: productBarcode.value,
status: true,
photourl: dropzoneFiles.value
.filter(f => f.hashkey)
.map(f => f.hashkey)
});
if (response.data && response.data.success) {
showSuccessState.value = true;
successMessage.value = 'Product updated successfully!';
setTimeout(() => {
if (props.onSaved) props.onSaved();
}, 1500);
} else {
error.value = response.data?.message || 'Failed to update product';
}
} catch (err) {
error.value = err.response?.data?.message || 'Failed to update product';
} finally {
isSaving.value = false;
}
};
</script>
<template>
<div class="update-product-modal p-1">
<div v-if="isLoading" class="text-center py-5">
<LoadingSpinner />
<p class="mt-2 text-muted">Loading product details...</p>
</div>
<template v-else>
<div v-if="successMessage" class="alert alert-success rounded-xl mb-3 animate-fade-in">
<i class="fas fa-check-circle me-2"></i> {{ successMessage }}
</div>
<div v-if="error" class="alert alert-danger rounded-xl mb-3 animate-shake">
<i class="fas fa-exclamation-triangle me-2"></i> {{ error }}
</div>
<div class="form-scroll-area custom-scrollbar pe-2" style="max-height: 70vh; overflow-y: auto;">
<div class="row g-3">
<div v-if="!storeHash" class="col-12">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Product Name</label>
<input v-model="productName" type="text" class="form-control rounded-pill border-0 shadow-sm px-4" placeholder="Enter product name">
</div>
</div>
<div class="col-12">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Description</label>
<textarea v-model="productDescription" class="form-control rounded-2xl border-0 shadow-sm px-4 py-3" rows="3" placeholder="Enter description"></textarea>
</div>
</div>
<div v-if="!storeHash" class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Category</label>
<select v-model="productCategory" @change="handleCategoryChange" class="form-select rounded-pill border-0 shadow-sm px-4">
<option value="" disabled>Select Category</option>
<option v-for="cat in categoryList" :key="cat.value" :value="cat.value">{{ cat.label }}</option>
</select>
</div>
</div>
<div v-if="!storeHash" class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Subcategory</label>
<select v-model="productSubcategory" :disabled="!subcategoryList.length" class="form-select rounded-pill border-0 shadow-sm px-4">
<option value="" disabled>Select Subcategory</option>
<option v-for="sub in subcategoryList" :key="sub.value" :value="sub.value">{{ sub.label }}</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">{{ storeHash ? 'Store Price' : 'Global Price' }} (PHP)</label>
<input v-model="productPrice" type="number" step="0.01" class="form-control rounded-pill border-0 shadow-sm px-4">
</div>
</div>
<div v-if="!storeHash" class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Unit</label>
<input v-model="productUnitName" type="text" class="form-control rounded-pill border-0 shadow-sm px-4" placeholder="e.g. 25kg">
</div>
</div>
<div class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Available Stock</label>
<input v-model="productAvailable" type="number" class="form-control rounded-pill border-0 shadow-sm px-4">
</div>
</div>
<div v-if="!storeHash" class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Barcode</label>
<input v-model="productBarcode" type="text" class="form-control rounded-pill border-0 shadow-sm px-4" maxlength="12">
</div>
</div>
<div v-if="!storeHash" class="col-12 mt-2">
<label class="form-label fw_7 small text-muted text-uppercase mb-2">Product Photos</label>
<Dropzone ref="dropzoneRef" v-model:files="dropzoneFiles" @removed="handlePhotoRemoved" />
</div>
</div>
</div>
<div class="modal-footer-actions d-flex gap-3 mt-4 pt-3 border-top">
<button type="button" class="btn btn-light rounded-pill px-4 flex-fill fw_6" @click="onClose" :disabled="isSaving">Cancel</button>
<AnimatedButton
type="button"
btnClass="btn btn-primary rounded-pill px-5 flex-fill fw_6 shadow-sm"
@click="handleSubmit"
:loading="isSaving"
:success="showSuccessState"
:disabled="isFileUploading"
>
Update Product
</AnimatedButton>
</div>
</template>
</div>
</template>
<style scoped>
.form-label {
margin-bottom: 0.4rem;
letter-spacing: 0.05em;
}
.form-control, .form-select {
background: #f8f9fa;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
background: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.05) !important;
}
.rounded-2xl { border-radius: 1.2rem; }
.rounded-xl { border-radius: 1rem; }
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #ccc;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
:global(.dark-mode) .form-control,
:global(.dark-mode) .form-select {
background: #24272c;
color: #eee;
}
:global(.dark-mode) .form-control:focus,
:global(.dark-mode) .form-select:focus {
background: #2c3036;
}
</style>