add product detail pages

This commit is contained in:
Nyavokevin 2025-11-03 13:02:11 +03:00
parent edb9c87c1e
commit cfdbc11b1a
14 changed files with 1766 additions and 13 deletions

88
th Normal file
View 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
View 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>

View File

@ -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>

View File

@ -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) => {

View 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>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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",

View 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>

View File

@ -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);