feat: complete all plan phases — reports, cleanup market fragments
Some checks failed
tests / PHP 8.2 (swoole-5.1.6) (push) Has been cancelled
tests / PHP 8.3 (swoole-5.1.6) (push) Has been cancelled
tests / PHP 8.4 (swoole-6.0) (push) Has been cancelled

- Add Reports page with population/household/document/blotter/budget/project views
- Add ReportsController with year-filtered queries for all report types
- Add /reports module to config/modules.php
- Register /barangay/reports in VueRouteMap and web.php
- Remove unused market Home fragments (HomeCoopMember, HomeStoreOwner, etc.)
- Remove leftover market Components/Market/ directory
- Add Reports card to Home.vue admin quick access
This commit is contained in:
Jonathan Sykes
2026-06-07 03:15:04 +08:00
parent fbb7e3ff37
commit bee4a1f5ab
25 changed files with 584 additions and 4065 deletions

View File

@@ -1,259 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
session: {
type: Object,
required: true
}
});
const isExpanded = ref(false);
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
const transactions = computed(() => {
return props.session.transactions || [];
});
const formattedDate = computed(() => {
if (!props.session.created_at) return 'N/A';
const date = new Date(props.session.created_at);
return date.toLocaleString('en-PH', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
}).replace(',', ' •');
});
const statusClass = computed(() => {
switch (props.session.status) {
case 'completed': return 'badge-soft-success';
case 'active': return 'badge-soft-primary';
case 'voided': return 'badge-soft-danger';
default: return 'badge-soft-secondary';
}
});
const paymentIcon = computed(() => {
switch (props.session.payment_method?.toLowerCase()) {
case 'cash': return 'fas fa-money-bill-wave';
case 'credit': return 'fas fa-credit-card';
case 'online': return 'fas fa-mobile-alt';
default: return 'fas fa-receipt';
}
});
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-PH', {
style: 'currency',
currency: 'PHP'
}).format(amount);
};
const getProductInfo = (transaction) => {
return {
name: transaction.product?.name || 'Unknown Product',
quantity: transaction.quantity || 0,
unitPrice: transaction.price_at_sale || 0,
totalPrice: transaction.total_price || 0
};
};
</script>
<template>
<div class="card mb-3 border-0 shadow-sm rounded-4 overflow-hidden pos-history-card">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span :class="['badge rounded-pill px-3 py-2 text-uppercase fw-bold', statusClass]">
{{ session.status }}
</span>
<h6 class="mb-0 mt-2 text-primary fw-bold">
{{ session.customer_name || 'Walk-in Customer' }}
</h6>
<small class="text-muted d-block mt-1">
<i class="far fa-clock me-1"></i> {{ formattedDate }}
</small>
</div>
<div class="text-end">
<h5 class="mb-0 fw-black text-dark">
{{ formatCurrency(session.total_amount) }}
</h5>
<small class="text-muted">
{{ session.items_count }} {{ session.items_count === 1 ? 'item' : 'items' }}
</small>
</div>
</div>
<div class="d-flex align-items-center justify-content-between mt-3 pt-3 border-top border-light">
<div class="d-flex align-items-center">
<div class="payment-icon-wrapper bg-light rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
<i :class="[paymentIcon, 'text-muted sm']"></i>
</div>
<span class="text-muted small text-capitalize">{{ session.payment_method || 'N/A' }}</span>
</div>
<div v-if="session.hashkey" class="text-muted small">
<span class="badge bg-light text-muted fw-normal rounded-pill">#{{ session.hashkey.substring(0, 8) }}</span>
</div>
</div>
<!-- Expandable Items Section -->
<div v-if="isExpanded && transactions.length > 0" class="items-section mt-3 pt-3 border-top border-light">
<div class="items-header d-flex align-items-center mb-2">
<i class="fas fa-box-open text-muted me-2"></i>
<span class="text-muted small fw-bold">Transaction Items</span>
</div>
<div class="items-list">
<div v-for="item in transactions" :key="item.id" class="item-row d-flex align-items-center justify-content-between py-2 border-bottom border-light">
<div class="item-info flex-grow-1">
<div class="item-name text-dark fw-semibold small">
{{ getProductInfo(item).name }}
</div>
<div class="item-qty text-muted small">
{{ getProductInfo(item).quantity }} × {{ formatCurrency(getProductInfo(item).unitPrice) }}
</div>
</div>
<div class="item-total text-end">
<span class="fw-bold text-dark small">
{{ formatCurrency(getProductInfo(item).totalPrice) }}
</span>
</div>
</div>
</div>
</div>
<!-- Footer with Toggle Button -->
<div
v-if="transactions.length > 0"
class="card-footer-toggle d-flex align-items-center justify-content-center mt-3 pt-3 border-top border-light cursor-pointer"
@click="toggleExpand"
>
<span class="text-muted small fw-bold me-2">
{{ isExpanded ? 'Hide Items' : 'View Items' }}
</span>
<i :class="['fas text-muted small', isExpanded ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
</div>
</div>
</div>
</template>
<style scoped>
.pos-history-card {
transition: transform 0.2s, box-shadow 0.2s;
background: var(--bg-card);
}
.pos-history-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0,0,0,0.05) !important;
}
:global(.dark-mode) .pos-history-card {
background: rgba(var(--bg-card-rgb), 0.7);
backdrop-filter: blur(15px);
}
.badge-soft-success {
background-color: rgba(40, 167, 69, 0.1);
color: #28a745;
}
.badge-soft-primary {
background-color: rgba(0, 123, 255, 0.1);
color: #007bff;
}
.badge-soft-danger {
background-color: rgba(220, 53, 69, 0.1);
color: #dc3545;
}
.badge-soft-secondary {
background-color: rgba(108, 117, 125, 0.1);
color: #6c757d;
}
:global(.dark-mode) .badge-soft-success { background-color: rgba(40, 167, 69, 0.2); }
:global(.dark-mode) .badge-soft-primary { background-color: rgba(0, 123, 255, 0.2); }
:global(.dark-mode) .badge-soft-danger { background-color: rgba(220, 53, 69, 0.2); }
:global(.dark-mode) .border-light { border-color: rgba(255,255,255,0.05) !important; }
/* Font Awesome standard sizes for visual hierarchy as per dictionary */
.sm { font-size: 0.875rem; }
/* Items section styling */
.items-section {
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 500px;
}
}
.item-row:last-child {
border-bottom: none !important;
}
.item-row {
transition: background-color 0.2s;
}
.item-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
:global(.dark-mode) .item-row:hover {
background-color: rgba(255, 255, 255, 0.02);
}
:global(.dark-mode) .item-name,
:global(.dark-mode) .item-total span {
color: var(--text-primary) !important;
}
/* Toggle button styling */
.card-footer-toggle {
transition: background-color 0.2s;
}
.cursor-pointer {
cursor: pointer;
}
.card-footer-toggle:hover {
background-color: rgba(0, 0, 0, 0.02);
}
:global(.dark-mode) .card-footer-toggle:hover {
background-color: rgba(255, 255, 255, 0.02);
}
/* Mobile responsiveness */
@media (max-width: 576px) {
.item-name {
font-size: 0.75rem;
}
.item-qty {
font-size: 0.7rem;
}
.item-total span {
font-size: 0.75rem;
}
}
</style>

View File

@@ -1,93 +0,0 @@
<script setup>
import { onMounted, ref } from 'vue';
import { usePosStore } from '../../stores/pos';
import PosHistoryCard from './PosHistoryCard.vue';
const props = defineProps({
storeHash: {
type: String,
required: true
}
});
const posStore = usePosStore();
const isLoadingMore = ref(false);
onMounted(async () => {
// Only fetch if empty or if needed (could always fetch for simplicity on mount)
await posStore.fetchPosSessions(props.storeHash, 1);
});
const loadMore = async () => {
if (posStore.posSessions.length < posStore.posSessionsCount && !posStore.loading) {
isLoadingMore.value = true;
await posStore.fetchPosSessions(props.storeHash, posStore.posSessionsPage + 1);
isLoadingMore.value = false;
}
};
</script>
<template>
<div class="pos-history-list position-relative mt-2">
<div v-if="posStore.loading && posStore.posSessions.length === 0" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted small">Loading POS history...</p>
</div>
<div v-else-if="posStore.posSessions.length === 0" class="text-center py-5 bg-light rounded-4 border border-dashed">
<div class="empty-state-icon mb-3 opacity-2">
<i class="fad fa-receipt fa-4x text-muted"></i>
</div>
<p class="text-muted small">No POS history found for this store.</p>
</div>
<div v-else>
<div class="history-items">
<div v-for="session in posStore.posSessions" :key="session.hashkey">
<PosHistoryCard :session="session" />
</div>
</div>
<div v-if="posStore.posSessions.length < posStore.posSessionsCount" class="text-center mt-4">
<button
@click="loadMore"
class="btn btn-outline-primary btn-sm rounded-pill px-4 py-2 d-inline-flex align-items-center fw-bold"
:disabled="isLoadingMore || posStore.loading"
>
<span v-if="isLoadingMore || posStore.loading" class="spinner-border spinner-border-sm me-2" role="status"></span>
<i v-else class="fas fa-plus me-2"></i>
{{ isLoadingMore || posStore.loading ? 'LOADING...' : 'LOAD MORE' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.empty-state-icon {
transition: opacity 0.3s ease;
}
.opacity-2 {
opacity: 0.2;
}
.pos-history-list {
min-height: 100px;
}
.bg-light {
background-color: var(--bg-secondary) !important;
}
.border-dashed {
border-style: dashed !important;
border-width: 2px !important;
}
:global(.dark-mode) .bg-light {
background-color: rgba(0,0,0,0.2) !important;
}
</style>

View File

@@ -1,108 +0,0 @@
<template>
<div class="pos-today-stats mb-4">
<div class="d-flex align-items-center justify-content-between mb-3 mt-1">
<div class="d-flex align-items-center">
<div class="icon-avatar me-3 shadow-sm">
<i class="fas fa-chart-line text-primary"></i>
</div>
<div>
<h5 class="fw_7 mb-0">Today's Performance</h5>
<span class="text-muted small">Daily sales summary</span>
</div>
</div>
<div v-if="!loading" class="date-badge px-3 py-1 text-primary small fw_6 rounded-pill border">
Today
</div>
</div>
<div v-if="loading" class="text-center py-4 glass-card p-3 p-md-4 rounded-xl">
<div class="spinner-border text-primary spinner-border-sm" role="status">
<span class="sr-only">Loading...</span>
</div>
<p class="small text-muted mt-2 mb-0">Fetching stats...</p>
</div>
<div v-else class="glass-card p-3 p-md-4 rounded-xl">
<div class="row text-center mt-2">
<div class="col-6 border-right">
<p class="small text-muted mb-1 text-uppercase ls_1">Transactions</p>
<h3 class="mb-0 fw_7">{{ todayStats.count || 0 }}</h3>
</div>
<div class="col-6">
<p class="small text-muted mb-1 text-uppercase ls_1">Total Sales</p>
<h3 class="mb-0 fw_7">₱{{ formatAmount(todayStats.total) }}</h3>
</div>
</div>
<div v-if="!loading && todayStats.store_name" class="mt-3 text-center border-top-dashed pt-3">
<p class="small text-muted italic mb-0">
Terminal: <span class="fw_6 text-primary">{{ todayStats.store_name }}</span>
</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { usePosStore } from '../../stores/pos'
const props = defineProps({
loading: { type: Boolean, default: false }
})
const posStore = usePosStore()
const todayStats = computed(() => posStore.todayStats)
const formatAmount = (val) => {
if (!val) return '0.00'
return parseFloat(val).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
</script>
<style scoped>
.pos-today-stats {
transition: all 0.3s ease;
}
.ls_1 {
letter-spacing: 1px;
}
.border-right {
border-right: 1px solid var(--border-color);
}
.border-top-dashed {
border-top: 1px dashed var(--border-color);
}
.icon-avatar {
width: 44px;
height: 44px;
background: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
}
.date-badge {
background: rgba(var(--primary-rgb), 0.05) !important;
}
.rounded-xl {
border-radius: 20px !important;
}
:global(.dark-mode) .icon-avatar {
background: #2d3138;
}
:global(.dark-mode) .icon-avatar i {
color: #10b981 !important;
}
:global(.dark-mode) .date-badge {
background: rgba(16, 185, 129, 0.1) !important;
color: #10b981 !important;
}
:global(.dark-mode) .text-primary {
color: #10b981 !important;
}
</style>

View File

@@ -1,138 +0,0 @@
<template>
<div class="product-card" @click="$emit('click')">
<div class="product-image-wrapper">
<FileImage :src="image" :alt="name" class="product-image" fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin" />
<div v-if="price" class="product-price-badge">
{{ price }}
</div>
</div>
<div class="product-info">
<h5 class="product-name">{{ name }}</h5>
<p v-if="unit" class="product-unit">per {{ unit }}</p>
<p v-if="description" class="product-description text-truncate-2">
{{ description }}
</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import FileImage from '../Core/FileImage.vue'
const props = defineProps({
name: { type: String, required: true },
price: { type: [String, Number], default: '' },
unit: { type: String, default: '' },
description: { type: String, default: '' },
image: { type: String, default: '' }
})
defineEmits(['click'])
</script>
<style scoped>
.product-card {
background: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
height: 100%;
display: flex;
flex-direction: column;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.product-image-wrapper {
position: relative;
width: 100%;
padding-top: 100%;
/* 1:1 Aspect Ratio */
background: #f8f9fa;
}
.product-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.product-price-badge {
position: absolute;
bottom: 0;
right: 0;
background: rgba(66, 185, 131, 0.9);
color: white;
padding: 6px 12px;
border-top-left-radius: 12px;
font-weight: 700;
backdrop-filter: blur(4px);
font-size: 0.9rem;
}
.product-info {
padding: 12px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.product-name {
font-size: 1rem;
font-weight: 600;
margin-bottom: 4px;
color: #2c3e50;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.product-unit {
font-size: 0.75rem;
color: #7f8c8d;
margin-bottom: 8px;
font-style: italic;
}
.product-description {
font-size: 0.85rem;
color: #636e72;
margin-bottom: 0;
line-height: 1.4;
}
.text-truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Dark mode support (if applicable) */
:global(.dark-mode) .product-card {
background: #24272c;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
:global(.dark-mode) .product-name {
color: #e0e0e0;
}
:global(.dark-mode) .product-description {
color: #b0b0b0;
}
:global(.dark-mode) .product-image-wrapper {
background: #1a1c20;
}
</style>

View File

@@ -1,128 +0,0 @@
<template>
<div class="store-card" @click="$emit('click')">
<div class="store-image-wrapper">
<img :src="resolvedImage" :alt="name" class="store-image" @error="handleImageError" />
<div v-if="category" class="store-category-badge">
{{ category }}
</div>
</div>
<div class="store-info">
<h5 class="store-name">{{ name }}</h5>
<p v-if="subcategory" class="store-subcategory">{{ subcategory }}</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
name: { type: String, required: true },
category: { type: String, default: '' },
subcategory: { type: String, default: '' },
image: { type: String, default: '' }
})
defineEmits(['click'])
const resolvedImage = computed(() => {
if (!props.image) return 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin';
// Return blob URLs directly
if (props.image.startsWith('blob:')) {
return props.image;
}
// Check for http, https, or data URIs
if (props.image.startsWith('http') || props.image.startsWith('/') || props.image.startsWith('data:')) {
return props.image;
}
// If it's a hash (long string without slashes), resolve it
return `/RequestData/File/${props.image}`;
});
const handleImageError = (event) => {
event.target.src = 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin';
};
</script>
<style scoped>
.store-card {
background: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
height: 100%;
display: flex;
flex-direction: column;
}
.store-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.store-image-wrapper {
position: relative;
width: 100%;
padding-top: 60%;
/* 16:9 approx */
background: #f8f9fa;
}
.store-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.store-category-badge {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 123, 255, 0.85);
color: white;
padding: 4px 10px;
border-radius: 20px;
font-weight: 600;
backdrop-filter: blur(4px);
font-size: 0.75rem;
}
.store-info {
padding: 12px;
flex-grow: 1;
}
.store-name {
font-size: 1rem;
font-weight: 600;
margin-bottom: 2px;
color: #2c3e50;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.store-subcategory {
font-size: 0.8rem;
color: #7f8c8d;
margin-bottom: 0;
}
:global(.dark-mode) .store-card {
background: #24272c;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
:global(.dark-mode) .store-name {
color: #e0e0e0;
}
:global(.dark-mode) .store-image-wrapper {
background: #1a1c20;
}
</style>

View File

@@ -1,378 +0,0 @@
<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>