add product detail pages
This commit is contained in:
parent
edb9c87c1e
commit
cfdbc11b1a
88
th
Normal file
88
th
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div :class="['stock-badge', stockClass]">
|
||||
<div class="stock-info">
|
||||
<span class="stock-label">{{ label }}</span>
|
||||
<span class="stock-value">{{ value }} {{ unit }}</span>
|
||||
</div>
|
||||
<div v-if="showThreshold && threshold" class="stock-threshold">
|
||||
<small>Min: {{ threshold }} {{ unit }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
unit: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
threshold: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
showThreshold: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const stockClass = computed(() => {
|
||||
if (props.threshold && props.value <= props.threshold) {
|
||||
return 'stock-low';
|
||||
}
|
||||
return 'stock-normal';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stock-badge {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.stock-low {
|
||||
background-color: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.stock-normal {
|
||||
background-color: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.stock-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stock-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stock-value {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stock-threshold {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
64
thanasoft
Normal file
64
thanasoft
Normal file
@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="price-display">
|
||||
<span class="price-amount">{{ formattedPrice }}</span>
|
||||
<span v-if="currency" class="price-currency">{{ currency }}</span>
|
||||
<span v-if="unit" class="price-unit">/{{ unit }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
price: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: '€'
|
||||
},
|
||||
unit: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showDecimals: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
const formattedPrice = computed(() => {
|
||||
const decimals = props.showDecimals ? 2 : 0;
|
||||
return props.price.toLocaleString('fr-FR', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.price-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.price-amount {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.price-currency {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.price-unit {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,609 @@
|
||||
<template>
|
||||
<div class="product-details-organism">
|
||||
<div class="single-product-card">
|
||||
<!-- Card Header -->
|
||||
<div class="card-header">
|
||||
<div class="product-header-main">
|
||||
<div class="product-image-container">
|
||||
<product-image
|
||||
:image-url="productData.media?.photo_url"
|
||||
:alt-text="`Image de ${productData.nom}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="product-title-area">
|
||||
<product-title
|
||||
:title="productData.nom"
|
||||
:subtitle="productData.reference"
|
||||
/>
|
||||
<div class="product-status-indicators">
|
||||
<div
|
||||
v-if="productData.is_low_stock"
|
||||
class="status-indicator low-stock"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span>Stock faible</span>
|
||||
</div>
|
||||
<div v-if="isExpired" class="status-indicator expired">
|
||||
<i class="fas fa-times-circle"></i>
|
||||
<span>Expiré</span>
|
||||
</div>
|
||||
<div v-else-if="isExpiringSoon" class="status-indicator expiring">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>Expire bientôt</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Content -->
|
||||
<div class="card-content">
|
||||
<div class="info-sections">
|
||||
<!-- Basic Information Section -->
|
||||
<div class="info-section">
|
||||
<h3 class="section-title">Informations générales</h3>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label class="info-label">Catégorie</label>
|
||||
<span class="info-value">{{ productData.categorie }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label class="info-label">Fabricant</label>
|
||||
<span class="info-value">{{
|
||||
productData.fabricant || "Non renseigné"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label class="info-label">Numéro de lot</label>
|
||||
<span class="info-value">{{
|
||||
productData.numero_lot || "Non renseigné"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label class="info-label">Date d'expiration</label>
|
||||
<span class="info-value" :class="expirationStatusClass">{{
|
||||
formattedExpiration
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label class="info-label">Unité</label>
|
||||
<span class="info-value">{{ productData.unite }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stock & Pricing Section -->
|
||||
<div class="info-section">
|
||||
<h3 class="section-title">Stock et tarification</h3>
|
||||
<div class="stock-pricing-grid">
|
||||
<div class="stock-info">
|
||||
<label class="info-label">Stock actuel</label>
|
||||
<div class="stock-display">
|
||||
<span class="stock-quantity">{{
|
||||
productData.stock_actuel
|
||||
}}</span>
|
||||
<span class="stock-unit">{{ productData.unite }}</span>
|
||||
<span v-if="productData.stock_minimum" class="stock-min">
|
||||
(Min: {{ productData.stock_minimum }}
|
||||
{{ productData.unite }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasPricing" class="price-info">
|
||||
<label class="info-label">Prix unitaire</label>
|
||||
<price-display
|
||||
:price="productData.prix_unitaire"
|
||||
:currency="'€'"
|
||||
:unit="productData.unite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conditioning Section -->
|
||||
<div v-if="hasConditioning" class="info-section">
|
||||
<h3 class="section-title">Conditionnement</h3>
|
||||
<div class="conditioning-grid">
|
||||
<div class="info-item">
|
||||
<label class="info-label">Nom du conditionnement</label>
|
||||
<span class="info-value">{{
|
||||
productData.conditionnement?.nom || "Non spécifié"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label class="info-label">Quantité par conditionnement</label>
|
||||
<span class="info-value">
|
||||
{{ productData.conditionnement?.quantite || "0" }}
|
||||
{{ productData.conditionnement?.unite || "" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Supplier Section -->
|
||||
<div class="info-section">
|
||||
<h3 class="section-title">Fournisseur</h3>
|
||||
<div class="supplier-info">
|
||||
<div v-if="productData.fournisseur" class="supplier-details">
|
||||
<div class="supplier-main">
|
||||
<span class="supplier-name">{{
|
||||
productData.fournisseur.name
|
||||
}}</span>
|
||||
<span
|
||||
v-if="productData.fournisseur.email"
|
||||
class="supplier-email"
|
||||
>
|
||||
{{ productData.fournisseur.email }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click="handleViewSupplier(productData.fournisseur)"
|
||||
class="supplier-action-btn"
|
||||
title="Voir le fournisseur"
|
||||
>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="no-supplier">
|
||||
<span class="no-supplier-text">Aucun fournisseur associé</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documents Section -->
|
||||
<div v-if="hasDocuments" class="info-section">
|
||||
<h3 class="section-title">Documents</h3>
|
||||
<div class="documents-list">
|
||||
<div
|
||||
v-if="productData.media?.fiche_technique_url"
|
||||
class="document-item"
|
||||
>
|
||||
<i class="fas fa-file-pdf document-icon"></i>
|
||||
<div class="document-details">
|
||||
<span class="document-name">Fiche technique</span>
|
||||
<a
|
||||
:href="productData.media.fiche_technique_url"
|
||||
target="_blank"
|
||||
class="document-link"
|
||||
>
|
||||
<i class="fas fa-download"></i>
|
||||
Télécharger
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, computed, defineEmits } from "vue";
|
||||
import ProductImage from "@/components/atoms/Product/ProductImage.vue";
|
||||
import ProductTitle from "@/components/atoms/Product/ProductTitle.vue";
|
||||
import PriceDisplay from "@/components/atoms/Product/PriceDisplay.vue";
|
||||
import ProductService from "@/services/product";
|
||||
|
||||
const props = defineProps({
|
||||
productData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["viewSupplier"]);
|
||||
|
||||
// Computed properties
|
||||
const isExpired = computed(() => {
|
||||
return ProductService.isExpired(props.productData);
|
||||
});
|
||||
|
||||
const isExpiringSoon = computed(() => {
|
||||
return ProductService.isExpiringSoon(props.productData, 30);
|
||||
});
|
||||
|
||||
const hasDocuments = computed(() => {
|
||||
return props.productData.media?.fiche_technique_url;
|
||||
});
|
||||
|
||||
const hasPricing = computed(() => {
|
||||
return (
|
||||
props.productData.prix_unitaire !== null &&
|
||||
props.productData.prix_unitaire !== undefined
|
||||
);
|
||||
});
|
||||
|
||||
const hasConditioning = computed(() => {
|
||||
return (
|
||||
props.productData.conditionnement?.nom ||
|
||||
(props.productData.conditionnement?.quantite &&
|
||||
props.productData.conditionnement?.unite)
|
||||
);
|
||||
});
|
||||
|
||||
const formattedExpiration = computed(() => {
|
||||
if (!props.productData.date_expiration) return "Non renseignée";
|
||||
return new Date(props.productData.date_expiration).toLocaleDateString(
|
||||
"fr-FR"
|
||||
);
|
||||
});
|
||||
|
||||
const expirationStatusClass = computed(() => {
|
||||
if (!props.productData.date_expiration) return "";
|
||||
|
||||
const today = new Date();
|
||||
const expiration = new Date(props.productData.date_expiration);
|
||||
const daysDiff = Math.ceil((expiration - today) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDiff < 0) return "expired";
|
||||
if (daysDiff <= 30) return "expiring-soon";
|
||||
return "";
|
||||
});
|
||||
|
||||
const handleViewSupplier = (supplier) => {
|
||||
emit("viewSupplier", supplier);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-details-organism {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.single-product-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.single-product-card:hover {
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.product-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.product-image-container {
|
||||
flex-shrink: 0;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.product-title-area {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-status-indicators {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 25px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.status-indicator.low-stock {
|
||||
background: rgba(254, 243, 199, 0.9);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-indicator.expired {
|
||||
background: rgba(254, 226, 226, 0.9);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.status-indicator.expiring {
|
||||
background: rgba(219, 234, 254, 0.9);
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 2.5rem;
|
||||
}
|
||||
|
||||
.info-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info-section::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -1.25rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #e5e7eb, transparent);
|
||||
}
|
||||
|
||||
.info-section:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0 0 1.5rem 0;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid #667eea;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.info-grid,
|
||||
.stock-pricing-grid,
|
||||
.conditioning-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.info-value:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.info-value.expired {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.info-value.expiring-soon {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border-color: #fed7aa;
|
||||
}
|
||||
|
||||
.stock-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stock-quantity {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.stock-unit {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stock-min {
|
||||
font-size: 0.875rem;
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.price-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.supplier-info {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.supplier-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.supplier-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.supplier-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.supplier-email {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.supplier-action-btn {
|
||||
padding: 0.75rem;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.supplier-action-btn:hover {
|
||||
background: #5a67d8;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.no-supplier {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.no-supplier-text {
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.documents-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.document-icon {
|
||||
font-size: 1.5rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.document-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.document-name {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.document-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #eff6ff;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.document-link:hover {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.product-details-organism {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card-header,
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.product-header-main {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.product-image-container {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.info-grid,
|
||||
.stock-pricing-grid,
|
||||
.conditioning-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.supplier-details {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-details {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -52,7 +52,13 @@ const goToProduct = () => {
|
||||
};
|
||||
|
||||
const goToDetails = (product) => {
|
||||
emit("pushDetails", product);
|
||||
console.log(product);
|
||||
router.push({
|
||||
name: "Product details",
|
||||
params: {
|
||||
id: product,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const goToEdit = (product) => {
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="price-display">
|
||||
<span class="price-amount">{{ formattedPrice }}</span>
|
||||
<span v-if="currency" class="price-currency">{{ currency }}</span>
|
||||
<span v-if="unit" class="price-unit">/{{ unit }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
price: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: "€",
|
||||
},
|
||||
unit: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
showDecimals: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const formattedPrice = computed(() => {
|
||||
const decimals = props.showDecimals ? 2 : 0;
|
||||
return props.price.toLocaleString("fr-FR", {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.price-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.price-amount {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.price-currency {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.price-unit {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="product-image-container">
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
:alt="altText"
|
||||
class="product-image"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<div v-else class="product-image-placeholder">
|
||||
<i class="fas fa-image fa-3x text-gray-400"></i>
|
||||
<p class="text-gray-500 mt-2">Aucune image disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
imageUrl: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
altText: {
|
||||
type: String,
|
||||
default: "Image du produit",
|
||||
},
|
||||
});
|
||||
|
||||
const onImageError = (event) => {
|
||||
event.target.style.display = "none";
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-image-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
height: 300px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.product-image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8f9fa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="product-title">
|
||||
<h1 class="product-title-text">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="product-subtitle-text">{{ subtitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-title {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.product-title-text {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.product-subtitle-text {
|
||||
font-size: 1rem;
|
||||
color: #6b7280;
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.product-title-text {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
88
thanasoft-front/src/components/atoms/Product/StockBadge.vue
Normal file
88
thanasoft-front/src/components/atoms/Product/StockBadge.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div :class="['stock-badge', stockClass]">
|
||||
<div class="stock-info">
|
||||
<span class="stock-label">{{ label }}</span>
|
||||
<span class="stock-value">{{ value }} {{ unit }}</span>
|
||||
</div>
|
||||
<div v-if="showThreshold && threshold" class="stock-threshold">
|
||||
<small>Min: {{ threshold }} {{ unit }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
unit: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
threshold: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
showThreshold: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const stockClass = computed(() => {
|
||||
if (props.threshold && props.value <= props.threshold) {
|
||||
return "stock-low";
|
||||
}
|
||||
return "stock-normal";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stock-badge {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.stock-low {
|
||||
background-color: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.stock-normal {
|
||||
background-color: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.stock-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stock-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stock-value {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stock-threshold {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="product-info-grid">
|
||||
<div class="info-item">
|
||||
<label class="info-label">Référence</label>
|
||||
<span class="info-value">{{ reference || "Non définie" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label class="info-label">Catégorie</label>
|
||||
<span class="info-value badge">{{ category }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label class="info-label">Fabricant</label>
|
||||
<span class="info-value">{{ manufacturer || "Non renseigné" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label class="info-label">Lot</label>
|
||||
<span class="info-value">{{ batchNumber || "Non renseigné" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label class="info-label">Date d'expiration</label>
|
||||
<span class="info-value" :class="expirationClass">{{
|
||||
formattedExpiration
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label class="info-label">Unité</label>
|
||||
<span class="info-value">{{ unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
reference: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
manufacturer: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
batchNumber: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
expirationDate: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
unit: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const formattedExpiration = computed(() => {
|
||||
if (!props.expirationDate) return "Non renseignée";
|
||||
return new Date(props.expirationDate).toLocaleDateString("fr-FR");
|
||||
});
|
||||
|
||||
const expirationClass = computed(() => {
|
||||
if (!props.expirationDate) return "";
|
||||
|
||||
const today = new Date();
|
||||
const expiration = new Date(props.expirationDate);
|
||||
const daysDiff = Math.ceil((expiration - today) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDiff < 0) return "expired";
|
||||
if (daysDiff <= 30) return "expiring-soon";
|
||||
return "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.75rem;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.info-value.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.info-value.expired {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-value.expiring-soon {
|
||||
color: #d97706;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="stock-pricing-section">
|
||||
<stock-badge
|
||||
:label="'Stock actuel'"
|
||||
:value="currentStock"
|
||||
:unit="unit"
|
||||
:threshold="minimumStock"
|
||||
:show-threshold="true"
|
||||
/>
|
||||
|
||||
<div v-if="hasPricing" class="pricing-card">
|
||||
<div class="pricing-header">
|
||||
<h3 class="pricing-title">Prix</h3>
|
||||
</div>
|
||||
<div class="pricing-content">
|
||||
<price-display :price="unitPrice" :currency="'€'" :unit="unit" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasConditioning" class="conditioning-card">
|
||||
<div class="conditioning-header">
|
||||
<h3 class="conditioning-title">Conditionnement</h3>
|
||||
</div>
|
||||
<div class="conditioning-content">
|
||||
<div class="conditioning-item">
|
||||
<span class="conditioning-label">Nom</span>
|
||||
<span class="conditioning-value">{{
|
||||
conditioningName || "Non spécifié"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="conditioning-item">
|
||||
<span class="conditioning-label">Quantité</span>
|
||||
<span class="conditioning-value"
|
||||
>{{ conditioningQuantity }} {{ conditioningUnit }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, computed } from "vue";
|
||||
import StockBadge from "@/components/atoms/Product/StockBadge.vue";
|
||||
import PriceDisplay from "@/components/atoms/Product/PriceDisplay.vue";
|
||||
|
||||
const props = defineProps({
|
||||
currentStock: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
minimumStock: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
unit: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
unitPrice: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
conditioningName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
conditioningQuantity: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
conditioningUnit: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const hasPricing = computed(() => {
|
||||
return props.unitPrice !== null && props.unitPrice !== undefined;
|
||||
});
|
||||
|
||||
const hasConditioning = computed(() => {
|
||||
return (
|
||||
props.conditioningName ||
|
||||
(props.conditioningQuantity && props.conditioningUnit)
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stock-pricing-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.pricing-card,
|
||||
.conditioning-card {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pricing-header,
|
||||
.conditioning-header {
|
||||
background-color: #f9fafb;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.pricing-title,
|
||||
.conditioning-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pricing-content,
|
||||
.conditioning-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.conditioning-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.conditioning-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.conditioning-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conditioning-value {
|
||||
font-size: 1rem;
|
||||
color: #1f2937;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="supplier-info">
|
||||
<div class="supplier-header">
|
||||
<h3 class="supplier-title">Fournisseur</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="supplier" class="supplier-details">
|
||||
<div class="supplier-item">
|
||||
<label class="supplier-label">Nom</label>
|
||||
<span class="supplier-value">{{ supplier.name }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="supplier.email" class="supplier-item">
|
||||
<label class="supplier-label">Email</label>
|
||||
<a :href="`mailto:${supplier.email}`" class="supplier-link">
|
||||
{{ supplier.email }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="supplier-actions">
|
||||
<button @click="viewSupplier" class="btn btn-outline btn-sm">
|
||||
Voir le fournisseur
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="supplier-empty">
|
||||
<p class="text-muted">Aucun fournisseur associé</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
supplier: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["viewSupplier"]);
|
||||
|
||||
const viewSupplier = () => {
|
||||
if (props.supplier) {
|
||||
emit("viewSupplier", props.supplier);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.supplier-info {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.supplier-header {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.supplier-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.supplier-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.supplier-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.supplier-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.supplier-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.supplier-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.supplier-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.supplier-actions {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid #d1d5db;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.supplier-empty {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
@ -513,6 +513,11 @@ const routes = [
|
||||
name: "Creation produit",
|
||||
component: () => import("@/views/pages/Stock/AddProduct.vue"),
|
||||
},
|
||||
{
|
||||
path: "/stock/produits/details/:id",
|
||||
name: "Product details",
|
||||
component: () => import("@/views/pages/Stock/ProductDetails.vue"),
|
||||
},
|
||||
// Employés
|
||||
{
|
||||
path: "/employes",
|
||||
|
||||
300
thanasoft-front/src/views/pages/Stock/ProductDetails.vue
Normal file
300
thanasoft-front/src/views/pages/Stock/ProductDetails.vue
Normal file
@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="product-details-page">
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="page-header">
|
||||
<nav aria-label="breadcrumb" class="breadcrumbs-nav">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<router-link to="/stock">Stock</router-link>
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
<router-link to="/stock/produits">Produits</router-link>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">
|
||||
{{ productData?.nom || "Chargement..." }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Actions header -->
|
||||
<div class="page-actions">
|
||||
<soft-button type="outline" color="secondary" size="sm" @click="goBack">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Retour
|
||||
</soft-button>
|
||||
|
||||
<soft-button
|
||||
type="outline"
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="editProduct"
|
||||
>
|
||||
<i class="fas fa-edit me-2"></i>
|
||||
Modifier
|
||||
</soft-button>
|
||||
|
||||
<soft-button
|
||||
type="outline"
|
||||
color="danger"
|
||||
size="sm"
|
||||
@click="deleteProduct"
|
||||
:disabled="loading"
|
||||
>
|
||||
<i class="fas fa-trash me-2"></i>
|
||||
Supprimer
|
||||
</soft-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i>
|
||||
<p>Chargement des informations du produit...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-container">
|
||||
<div class="error-message">
|
||||
<i class="fas fa-exclamation-triangle fa-2x text-danger mb-3"></i>
|
||||
<h5>Erreur de chargement</h5>
|
||||
<p>{{ error }}</p>
|
||||
<soft-button
|
||||
type="outline"
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="loadProduct"
|
||||
>
|
||||
Réessayer
|
||||
</soft-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product details -->
|
||||
<div v-else-if="productData" class="product-details-content">
|
||||
<product-details-section
|
||||
:product-data="productData"
|
||||
@view-supplier="handleViewSupplier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="empty-container">
|
||||
<div class="empty-message">
|
||||
<i class="fas fa-box fa-3x text-muted mb-3"></i>
|
||||
<h5>Produit non trouvé</h5>
|
||||
<p>Le produit demandé n'existe pas ou a été supprimé.</p>
|
||||
<soft-button type="outline" color="primary" size="sm" @click="goBack">
|
||||
Retour à la liste
|
||||
</soft-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import ProductDetailsSection from "@/components/Organism/Product/ProductDetailsSection.vue";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
import ProductService from "@/services/product";
|
||||
import { useProductStore } from "@/stores/productStore";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const productStore = useProductStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const productData = ref(null);
|
||||
|
||||
const productId = computed(() => {
|
||||
return parseInt(route.params.id);
|
||||
});
|
||||
|
||||
const loadProduct = async () => {
|
||||
if (!productId.value) {
|
||||
error.value = "ID de produit invalide";
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await productStore.fetchProduct(productId.value);
|
||||
productData.value = response;
|
||||
} catch (err) {
|
||||
console.error("Error loading product:", err);
|
||||
error.value =
|
||||
err.response?.data?.message || "Erreur lors du chargement du produit";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.push("/stock/produits");
|
||||
};
|
||||
|
||||
const editProduct = () => {
|
||||
router.push({
|
||||
name: "Modification produit",
|
||||
params: {
|
||||
id: productId.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const deleteProduct = async () => {
|
||||
if (!confirm("Êtes-vous sûr de vouloir supprimer ce produit ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ProductService.deleteProduct(productId.value);
|
||||
// Show success message
|
||||
router.push("/stock/produits");
|
||||
} catch (err) {
|
||||
console.error("Error deleting product:", err);
|
||||
// Show error message
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewSupplier = (supplier) => {
|
||||
if (supplier && supplier.id) {
|
||||
router.push({
|
||||
name: "Fournisseur details",
|
||||
params: {
|
||||
id: supplier.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadProduct();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-details-page {
|
||||
padding: 1.5rem;
|
||||
background-color: #f8fafc;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumbs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item:not(:last-child)::after {
|
||||
content: "/";
|
||||
margin-left: 0.5rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.breadcrumb-item a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb-item.active {
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.product-details-content {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.loading-spinner,
|
||||
.error-message,
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.loading-spinner i {
|
||||
color: #3b82f6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message i {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-message i {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message h5,
|
||||
.empty-message h5 {
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-message p,
|
||||
.empty-message p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
@ -2,7 +2,6 @@
|
||||
<product-presentation
|
||||
:product-data="productStore.products"
|
||||
:loading-data="productStore.loading"
|
||||
@push-details="goDetails"
|
||||
@delete-product="handleDeleteProduct"
|
||||
/>
|
||||
</template>
|
||||
@ -11,10 +10,8 @@
|
||||
import ProductPresentation from "@/components/Organism/Stock/ProductPresentation.vue";
|
||||
import { useProductStore } from "@/stores/productStore";
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const productStore = useProductStore();
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(async () => {
|
||||
await productStore.fetchProducts();
|
||||
@ -25,15 +22,6 @@ onUnmounted(() => {
|
||||
productStore.resetState();
|
||||
});
|
||||
|
||||
const goDetails = (product) => {
|
||||
router.push({
|
||||
name: "Product details",
|
||||
params: {
|
||||
id: product.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteProduct = async (product) => {
|
||||
try {
|
||||
await productStore.deleteProduct(product.id);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user