683 lines
21 KiB
Vue
683 lines
21 KiB
Vue
<template>
|
|
<div class="py-4 container-fluid">
|
|
<div class="row">
|
|
<div class="col-lg-6">
|
|
<h4>
|
|
{{ isEditMode ? "Modifier l'entrepôt" : "Détails de l'entrepôt" }}
|
|
</h4>
|
|
<p>
|
|
{{
|
|
isEditMode
|
|
? "Modifiez les informations de l'entrepôt ci-dessous."
|
|
: "Informations détaillées, produits stockés et mouvements."
|
|
}}
|
|
</p>
|
|
</div>
|
|
<div
|
|
class="text-right col-lg-6 d-flex flex-column justify-content-center"
|
|
>
|
|
<div class="w-100 mt-lg-0">
|
|
<div v-if="!isEditMode" class="d-flex align-items-center gap-2 w-100">
|
|
<div class="flex-grow-1 d-flex justify-content-center">
|
|
<button
|
|
type="button"
|
|
class="btn btn-outline-secondary btn-sm"
|
|
@click="handleBack"
|
|
>
|
|
<i class="fas fa-arrow-left me-2"></i>
|
|
Retour à la liste
|
|
</button>
|
|
</div>
|
|
|
|
<div class="d-flex gap-2 ms-auto">
|
|
<button
|
|
type="button"
|
|
class="mt-2 mb-0 btn bg-gradient-info"
|
|
@click="toggleEditMode"
|
|
>
|
|
<i class="fas fa-edit me-2"></i>
|
|
Modifier
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="mt-2 mb-0 btn bg-gradient-danger"
|
|
:disabled="deleting"
|
|
@click="handleDelete"
|
|
>
|
|
<i class="fas fa-trash me-2"></i>
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="d-flex gap-2 justify-content-end">
|
|
<button
|
|
type="button"
|
|
class="mt-2 mb-0 btn bg-gradient-secondary"
|
|
@click="cancelEdit"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="mt-2 mb-0 btn bg-gradient-success"
|
|
:disabled="saving"
|
|
@click="saveWarehouse"
|
|
>
|
|
<i v-if="saving" class="fas fa-spinner fa-spin me-2"></i>
|
|
<i v-else class="fas fa-save me-2"></i>
|
|
Sauvegarder
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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 de l'entrepôt...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<button
|
|
type="button"
|
|
class="btn btn-outline-primary btn-sm"
|
|
@click="loadWarehouseData"
|
|
>
|
|
Réessayer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="warehouse" class="warehouse-details-content mt-3">
|
|
<div class="row g-4">
|
|
<div class="col-xl-3 col-lg-4">
|
|
<div class="card warehouse-side-nav-card">
|
|
<div class="card-body pb-2">
|
|
<div class="warehouse-sidebar-profile">
|
|
<div class="warehouse-sidebar-icon">
|
|
<i class="fas fa-warehouse"></i>
|
|
</div>
|
|
<h6 class="warehouse-sidebar-title text-center mb-1">
|
|
{{ warehouse.name }}
|
|
</h6>
|
|
<p class="warehouse-sidebar-reference text-center mb-0">
|
|
{{ warehouse.city || "Ville non renseignée" }}
|
|
</p>
|
|
<div class="warehouse-sidebar-badges mt-3">
|
|
<span class="badge bg-primary">{{
|
|
warehouse.country_code || "--"
|
|
}}</span>
|
|
<span class="badge bg-info text-dark"
|
|
>{{ warehouseProducts.length }} produits</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr class="horizontal dark my-2 mx-3" />
|
|
|
|
<div class="card-body pt-2">
|
|
<ul class="nav nav-pills flex-column warehouse-nav">
|
|
<li class="nav-item">
|
|
<a
|
|
class="nav-link"
|
|
:class="{ active: activeTab === 'details' }"
|
|
href="javascript:;"
|
|
@click="activeTab = 'details'"
|
|
>
|
|
<i class="fas fa-info-circle me-2"></i>
|
|
<span class="text-sm">Informations</span>
|
|
</a>
|
|
</li>
|
|
<li class="nav-item pt-2">
|
|
<a
|
|
class="nav-link"
|
|
:class="{ active: activeTab === 'products' }"
|
|
href="javascript:;"
|
|
@click="activeTab = 'products'"
|
|
>
|
|
<i class="fas fa-boxes me-2"></i>
|
|
<span class="text-sm">Produits</span>
|
|
</a>
|
|
</li>
|
|
<li class="nav-item pt-2">
|
|
<a
|
|
class="nav-link"
|
|
:class="{ active: activeTab === 'movements' }"
|
|
href="javascript:;"
|
|
@click="activeTab = 'movements'"
|
|
>
|
|
<i class="fas fa-exchange-alt me-2"></i>
|
|
<span class="text-sm">Mouvements</span>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-xl-9 col-lg-8">
|
|
<div v-show="activeTab === 'details'">
|
|
<div class="card">
|
|
<div class="card-header bg-gradient-primary text-white">
|
|
<h5 class="mb-0">Informations de l'entrepôt</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<label>Nom de l'entrepôt *</label>
|
|
<input
|
|
v-if="isEditMode"
|
|
v-model="formData.name"
|
|
class="form-control"
|
|
type="text"
|
|
placeholder="Nom de l'entrepôt"
|
|
/>
|
|
<p v-else class="form-control-static">
|
|
{{ warehouse.name }}
|
|
</p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label>Pays</label>
|
|
<input
|
|
v-if="isEditMode"
|
|
v-model="formData.country_code"
|
|
class="form-control"
|
|
type="text"
|
|
maxlength="2"
|
|
placeholder="FR"
|
|
/>
|
|
<p v-else class="form-control-static">
|
|
{{ warehouse.country_code || "-" }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mt-3">
|
|
<div class="col-md-6">
|
|
<label>Adresse 1</label>
|
|
<input
|
|
v-if="isEditMode"
|
|
v-model="formData.address_line1"
|
|
class="form-control"
|
|
type="text"
|
|
placeholder="Adresse ligne 1"
|
|
/>
|
|
<p v-else class="form-control-static">
|
|
{{ warehouse.address_line1 || "-" }}
|
|
</p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label>Adresse 2</label>
|
|
<input
|
|
v-if="isEditMode"
|
|
v-model="formData.address_line2"
|
|
class="form-control"
|
|
type="text"
|
|
placeholder="Adresse ligne 2"
|
|
/>
|
|
<p v-else class="form-control-static">
|
|
{{ warehouse.address_line2 || "-" }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mt-3">
|
|
<div class="col-md-6">
|
|
<label>Code postal</label>
|
|
<input
|
|
v-if="isEditMode"
|
|
v-model="formData.postal_code"
|
|
class="form-control"
|
|
type="text"
|
|
placeholder="Code postal"
|
|
/>
|
|
<p v-else class="form-control-static">
|
|
{{ warehouse.postal_code || "-" }}
|
|
</p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label>Ville</label>
|
|
<input
|
|
v-if="isEditMode"
|
|
v-model="formData.city"
|
|
class="form-control"
|
|
type="text"
|
|
placeholder="Ville"
|
|
/>
|
|
<p v-else class="form-control-static">
|
|
{{ warehouse.city || "-" }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-show="activeTab === 'products'">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="font-weight-bolder mb-4">
|
|
Produits dans l'entrepôt
|
|
</h5>
|
|
|
|
<div v-if="warehouseProducts.length === 0" class="text-muted">
|
|
Aucun produit trouvé dans cet entrepôt.
|
|
</div>
|
|
|
|
<div v-else class="table-responsive">
|
|
<table class="table align-items-center mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th
|
|
class="text-uppercase text-secondary text-xxs font-weight-bolder"
|
|
>
|
|
Produit
|
|
</th>
|
|
<th
|
|
class="text-uppercase text-secondary text-xxs font-weight-bolder"
|
|
>
|
|
Quantité
|
|
</th>
|
|
<th
|
|
class="text-uppercase text-secondary text-xxs font-weight-bolder"
|
|
>
|
|
Stock de sécurité
|
|
</th>
|
|
<th
|
|
class="text-uppercase text-secondary text-xxs font-weight-bolder text-end"
|
|
>
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="item in warehouseProducts" :key="item.id">
|
|
<td>
|
|
<div class="d-flex flex-column">
|
|
<span class="text-sm fw-bold">{{
|
|
item.product?.nom || `Produit #${item.product_id}`
|
|
}}</span>
|
|
<span class="text-xs text-secondary"
|
|
>Réf: {{ item.product?.reference || "-" }}</span
|
|
>
|
|
</div>
|
|
</td>
|
|
<td class="text-sm">{{ item.qty_on_hand_base }}</td>
|
|
<td class="text-sm">{{ item.safety_stock_base }}</td>
|
|
<td class="text-end">
|
|
<button
|
|
type="button"
|
|
class="btn btn-link text-info btn-sm mb-0"
|
|
@click="goToProduct(item.product_id)"
|
|
>
|
|
Voir produit
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-show="activeTab === 'movements'">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div
|
|
class="d-flex justify-content-between align-items-center mb-4"
|
|
>
|
|
<h5 class="font-weight-bolder mb-0">
|
|
Mouvements de stock de l'entrepôt
|
|
</h5>
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm bg-gradient-primary mb-0"
|
|
@click="goToStockPage"
|
|
>
|
|
<i class="fas fa-external-link-alt me-1"></i>
|
|
Menu stock
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="warehouseMovements.length === 0" class="text-muted">
|
|
Aucun mouvement pour cet entrepôt.
|
|
</div>
|
|
|
|
<div v-else class="table-responsive">
|
|
<table class="table align-items-center mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th
|
|
class="text-uppercase text-secondary text-xxs font-weight-bolder"
|
|
>
|
|
Date
|
|
</th>
|
|
<th
|
|
class="text-uppercase text-secondary text-xxs font-weight-bolder"
|
|
>
|
|
Type
|
|
</th>
|
|
<th
|
|
class="text-uppercase text-secondary text-xxs font-weight-bolder"
|
|
>
|
|
Produit
|
|
</th>
|
|
<th
|
|
class="text-uppercase text-secondary text-xxs font-weight-bolder"
|
|
>
|
|
Entrée / Sortie
|
|
</th>
|
|
<th
|
|
class="text-uppercase text-secondary text-xxs font-weight-bolder"
|
|
>
|
|
Quantité
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="movement in warehouseMovements"
|
|
:key="movement.id"
|
|
>
|
|
<td class="text-sm">
|
|
{{
|
|
formatDate(movement.moved_at || movement.created_at)
|
|
}}
|
|
</td>
|
|
<td class="text-sm">{{ movement.move_type || "-" }}</td>
|
|
<td class="text-sm">
|
|
{{
|
|
movement.product?.nom ||
|
|
`Produit #${movement.product_id}`
|
|
}}
|
|
</td>
|
|
<td class="text-sm">
|
|
<span
|
|
class="badge"
|
|
:class="
|
|
isIncoming(movement)
|
|
? 'bg-success'
|
|
: 'bg-warning text-dark'
|
|
"
|
|
>
|
|
{{ isIncoming(movement) ? "Entrée" : "Sortie" }}
|
|
</span>
|
|
</td>
|
|
<td class="text-sm">{{ movement.qty_base }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, defineProps, onMounted, ref } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { useStockStore } from "@/stores/stockStore";
|
|
import { useWarehouseStore } from "@/stores/warehouseStore";
|
|
|
|
const props = defineProps({
|
|
warehouseId: {
|
|
type: [String, Number],
|
|
required: true,
|
|
},
|
|
});
|
|
|
|
const router = useRouter();
|
|
const warehouseStore = useWarehouseStore();
|
|
const stockStore = useStockStore();
|
|
|
|
const warehouse = ref(null);
|
|
const loading = ref(true);
|
|
const saving = ref(false);
|
|
const deleting = ref(false);
|
|
const error = ref(null);
|
|
const activeTab = ref("details");
|
|
const isEditMode = ref(false);
|
|
|
|
const formData = ref({
|
|
name: "",
|
|
address_line1: "",
|
|
address_line2: "",
|
|
postal_code: "",
|
|
city: "",
|
|
country_code: "FR",
|
|
});
|
|
|
|
const numericWarehouseId = computed(() => Number(props.warehouseId));
|
|
|
|
const warehouseProducts = computed(() => {
|
|
return stockStore.stockItems.filter(
|
|
(item) => Number(item.warehouse_id) === numericWarehouseId.value
|
|
);
|
|
});
|
|
|
|
const warehouseMovements = computed(() => {
|
|
return stockStore.stockMoves.filter(
|
|
(move) =>
|
|
Number(move.from_warehouse_id) === numericWarehouseId.value ||
|
|
Number(move.to_warehouse_id) === numericWarehouseId.value
|
|
);
|
|
});
|
|
|
|
const initializeFormData = (value) => {
|
|
formData.value = {
|
|
name: value?.name || "",
|
|
address_line1: value?.address_line1 || "",
|
|
address_line2: value?.address_line2 || "",
|
|
postal_code: value?.postal_code || "",
|
|
city: value?.city || "",
|
|
country_code: value?.country_code || "FR",
|
|
};
|
|
};
|
|
|
|
const loadWarehouseData = async () => {
|
|
loading.value = true;
|
|
error.value = null;
|
|
try {
|
|
const [fetchedWarehouse] = await Promise.all([
|
|
warehouseStore.fetchWarehouse(numericWarehouseId.value),
|
|
stockStore.fetchStockItems(),
|
|
stockStore.fetchStockMoves(),
|
|
]);
|
|
|
|
warehouse.value = fetchedWarehouse;
|
|
initializeFormData(fetchedWarehouse);
|
|
} catch (e) {
|
|
error.value = "Impossible de charger les informations de l'entrepôt.";
|
|
console.error(e);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const toggleEditMode = () => {
|
|
isEditMode.value = true;
|
|
initializeFormData(warehouse.value);
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
isEditMode.value = false;
|
|
initializeFormData(warehouse.value);
|
|
};
|
|
|
|
const saveWarehouse = async () => {
|
|
saving.value = true;
|
|
try {
|
|
const updated = await warehouseStore.updateWarehouse(
|
|
numericWarehouseId.value,
|
|
{
|
|
...formData.value,
|
|
country_code: (formData.value.country_code || "FR").toUpperCase(),
|
|
}
|
|
);
|
|
initializeFormData(updated);
|
|
isEditMode.value = false;
|
|
await loadWarehouseData();
|
|
} catch (e) {
|
|
console.error("Failed to update warehouse", e);
|
|
error.value =
|
|
e?.response?.data?.message ||
|
|
"Erreur lors de la mise à jour de l'entrepôt.";
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
};
|
|
|
|
const handleBack = () => {
|
|
router.push("/stock/warehouses");
|
|
};
|
|
|
|
const goToProduct = (productId) => {
|
|
router.push(`/stock/produits/details/${productId}`);
|
|
};
|
|
|
|
const goToStockPage = () => {
|
|
router.push("/stock");
|
|
};
|
|
|
|
const formatDate = (value) => {
|
|
if (!value) return "-";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleDateString("fr-FR", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
};
|
|
|
|
const isIncoming = (movement) => {
|
|
return Number(movement.to_warehouse_id) === numericWarehouseId.value;
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!confirm("Êtes-vous sûr de vouloir supprimer cet entrepôt ?")) {
|
|
return;
|
|
}
|
|
|
|
deleting.value = true;
|
|
try {
|
|
await warehouseStore.deleteWarehouse(numericWarehouseId.value);
|
|
router.push("/stock/warehouses");
|
|
} catch (e) {
|
|
console.error("Failed to delete warehouse", e);
|
|
error.value =
|
|
e?.response?.data?.message || "Erreur lors de la suppression.";
|
|
} finally {
|
|
deleting.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
loadWarehouseData();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.loading-container,
|
|
.error-container {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 300px;
|
|
background-color: #ffffff;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
margin: 2rem 0;
|
|
}
|
|
|
|
.loading-spinner,
|
|
.error-message {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.bg-gradient-primary {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
}
|
|
|
|
.form-control-static {
|
|
padding: 0.375rem 0;
|
|
margin-bottom: 0;
|
|
border: none;
|
|
background-color: transparent;
|
|
color: #495057;
|
|
}
|
|
|
|
.warehouse-side-nav-card {
|
|
position: sticky;
|
|
top: 1rem;
|
|
border: 0;
|
|
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
|
|
}
|
|
|
|
.warehouse-sidebar-profile {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
|
|
.warehouse-sidebar-icon {
|
|
width: 96px;
|
|
height: 96px;
|
|
border-radius: 12px;
|
|
border: 1px solid #e5e7eb;
|
|
margin-bottom: 0.75rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #667eea;
|
|
font-size: 2rem;
|
|
background: #f8f9ff;
|
|
}
|
|
|
|
.warehouse-sidebar-title {
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
color: #1f2937;
|
|
}
|
|
|
|
.warehouse-sidebar-reference {
|
|
color: #6b7280;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.warehouse-sidebar-badges {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.warehouse-nav .nav-link {
|
|
color: #6b7280;
|
|
border-radius: 0.5rem;
|
|
}
|
|
|
|
.warehouse-nav .nav-link.active {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: #fff;
|
|
box-shadow: 0 0.25rem 0.75rem rgba(102, 126, 234, 0.35);
|
|
}
|
|
</style>
|