475 lines
23 KiB
HTML
475 lines
23 KiB
HTML
{% extends "base.html" %}
|
|
{% load static %}
|
|
{% load i18n %}
|
|
{% load l10n %}
|
|
|
|
{% block head %}
|
|
<style>
|
|
.products-table {
|
|
margin-bottom: 2rem;
|
|
}
|
|
.category-section {
|
|
margin-bottom: 3rem;
|
|
}
|
|
.category-header {
|
|
background-color: #f8f9fa;
|
|
padding: 0.75rem 1rem;
|
|
border-left: 4px solid #0d6efd;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.alert-threshold {
|
|
background-color: #fff3cd;
|
|
}
|
|
.filter-section {
|
|
background-color: #f8f9fa;
|
|
padding: 1rem;
|
|
border-radius: 0.25rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid mt-3">
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<!-- En-tête de l'emplacement -->
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h3 class="h3 d-flex align-items-center">
|
|
<i class="bi bi-geo-alt"></i>
|
|
<span class="mx-2">{{ location.warehouse.name }}</span>
|
|
<span class="mx-2">-</span>
|
|
<span class="mx-2">{{ location.name }}</span>
|
|
</h3>
|
|
<div>
|
|
{% if is_stock_manager %}
|
|
<a href="{% url 'stock:product_create_at_location' location.id %}" class="btn btn-primary me-2">
|
|
<i class="bi bi-plus-circle"></i> {% translate "Ajouter un produit" %}
|
|
</a>
|
|
{% endif %}
|
|
{% if can_administer %}
|
|
<a href="{% url 'stock:warehouse_location_edit' location.id %}" class="btn btn-outline-primary me-2">
|
|
<i class="bi bi-pencil"></i> {% translate "Modifier" %}
|
|
</a>
|
|
<button type="button" class="btn btn-outline-danger me-2"
|
|
data-bs-toggle="modal" data-bs-target="#deleteLocationModal">
|
|
<i class="bi bi-trash"></i> {% translate "Supprimer" %}
|
|
</button>
|
|
{% endif %}
|
|
<a href="{% url 'stock:warehouse_detail' location.warehouse.id %}" class="btn btn-light me-2">
|
|
<i class="bi bi-buildings"></i> {% translate "Voir l'entrepôt" %}
|
|
</a>
|
|
<a href="{% url 'stock:warehouses' %}" class="btn btn-light">
|
|
<i class="bi bi-arrow-left"></i> {% translate "Retour" %}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<hr>
|
|
|
|
<!-- Informations principales -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-md-3">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-subtitle mb-2 text-muted">{% translate "Code" %}</h6>
|
|
<p class="card-text fs-5">{{ location.code }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-subtitle mb-2 text-muted">{% translate "Entrepôt" %}</h6>
|
|
<p class="card-text fs-5">{{ location.warehouse.name }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-subtitle mb-2 text-muted">{% translate "Gestionnaire" %}</h6>
|
|
<p class="card-text fs-5">{{ location.warehouse.manager }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% if location.qr_code %}
|
|
<div class="col-md-3">
|
|
<div class="card">
|
|
<div class="card-body text-center">
|
|
<h6 class="card-subtitle mb-2 text-muted">{% translate "QR Code" %}</h6>
|
|
<img src="{{ location.qr_code.url }}" alt="QR Code {{ location.code }}"
|
|
style="width:100px;height:100px;cursor:pointer;"
|
|
class="img-fluid qr-code-thumb"
|
|
data-bs-toggle="modal" data-bs-target="#qrCodeModal"
|
|
data-qr-url="{{ location.qr_code.url }}"
|
|
data-qr-label="{{ location.name }} ({{ location.code }})">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
{% if location.description %}
|
|
<div class="card mb-4">
|
|
<div class="card-body">
|
|
<h5 class="card-title">{% translate "Description" %}</h5>
|
|
<p class="card-text">{{ location.description }}</p>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Onglets -->
|
|
<ul class="nav nav-tabs" id="locationTab" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active" id="stocks-tab" data-bs-toggle="tab" data-bs-target="#stocks" type="button" role="tab">
|
|
<i class="bi bi-box-seam"></i> {% translate "Produits en stock" %}
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" id="movements-tab" data-bs-toggle="tab" data-bs-target="#movements" type="button" role="tab">
|
|
<i class="bi bi-arrow-left-right"></i> {% translate "Mouvements récents" %}
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
|
|
<div class="tab-content mt-3" id="locationTabContent">
|
|
<!-- Onglet Produits -->
|
|
<div class="tab-pane fade show active" id="stocks" role="tabpanel">
|
|
<!-- Filtres -->
|
|
<div class="filter-section">
|
|
<h5 class="h6 mb-3">
|
|
<i class="bi bi-funnel"></i> {% translate "Filtres" %}
|
|
</h5>
|
|
<div class="row g-2">
|
|
<div class="col-md-6">
|
|
<input type="text"
|
|
id="product-search"
|
|
class="form-control form-control-sm"
|
|
placeholder="{% translate 'Rechercher un produit par nom ou code...' %}">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="form-check">
|
|
<input class="form-check-input"
|
|
type="checkbox"
|
|
id="show-alerts-only">
|
|
<label class="form-check-label" for="show-alerts-only">
|
|
<i class="bi bi-exclamation-triangle text-warning"></i>
|
|
{% translate "Alertes seuil uniquement" %}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="form-check">
|
|
<input class="form-check-input"
|
|
type="checkbox"
|
|
id="show-stock-only">
|
|
<label class="form-check-label" for="show-stock-only">
|
|
<i class="bi bi-box-seam text-primary"></i>
|
|
{% translate "Stock > 0 uniquement" %}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Produits par catégorie -->
|
|
<div id="products-container">
|
|
{% for category, products in products_by_category.items %}
|
|
<div class="category-section" data-category="{{ category }}">
|
|
<h5 class="category-header">
|
|
{{ category }}
|
|
<span class="badge bg-secondary ms-2">{{ products|length }}</span>
|
|
</h5>
|
|
<div class="table-responsive">
|
|
<table class="table table-hover products-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{% translate "Code" %}</th>
|
|
<th>{% translate "Produit" %}</th>
|
|
<th class="text-end">{% translate "Stock" %}</th>
|
|
<th class="text-end">{% translate "Seuil" %}</th>
|
|
<th class="text-center">{% translate "Statut" %}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for product in products %}
|
|
<tr class="product-row {% if product.is_below_threshold %}alert-threshold{% endif %}"
|
|
data-product-name="{{ product.name|lower }}"
|
|
data-product-code="{{ product.code|lower }}"
|
|
data-is-alert="{{ product.is_below_threshold|yesno:'true,false' }}"
|
|
data-stock="{{ product.stock }}">
|
|
<td>
|
|
<a href="{% url 'stock:product_detail' product.id %}" class="text-decoration-none">
|
|
{{ product.code }}
|
|
</a>
|
|
</td>
|
|
<td>{{ product.name }}</td>
|
|
<td class="text-end">
|
|
<strong>{{ product.stock }}</strong>
|
|
</td>
|
|
<td class="text-end {% if product.is_below_threshold %}text-danger fw-bold{% endif %}">
|
|
{{ product.threshold }}
|
|
</td>
|
|
<td class="text-center">
|
|
{% if product.is_below_threshold %}
|
|
<span class="badge bg-warning text-dark">
|
|
<i class="bi bi-exclamation-triangle"></i> {% translate "Alerte" %}
|
|
</span>
|
|
{% else %}
|
|
<span class="badge bg-success">
|
|
<i class="bi bi-check-circle"></i> {% translate "OK" %}
|
|
</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{% empty %}
|
|
<p class="text-muted">{% translate "Aucun produit dans cet emplacement." %}</p>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<div id="no-results" class="alert alert-info d-none">
|
|
<i class="bi bi-info-circle"></i> {% translate "Aucun produit ne correspond aux filtres." %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Onglet Mouvements -->
|
|
<div class="tab-pane fade" id="movements" role="tabpanel">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>{% translate "Date" %}</th>
|
|
<th>{% translate "Produit" %}</th>
|
|
<th>{% translate "Type" %}</th>
|
|
<th>{% translate "Origine" %}</th>
|
|
<th>{% translate "Destination" %}</th>
|
|
<th class="text-end">{% translate "Quantité" %}</th>
|
|
<th>{% translate "Raison" %}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for movement in recent_movements %}
|
|
<tr>
|
|
<td>{{ movement.date|date:"d/m/Y H:i" }}</td>
|
|
<td>
|
|
<a href="{% url 'stock:product_detail' movement.product.id %}">
|
|
{{ movement.product.name }}
|
|
</a>
|
|
</td>
|
|
<td>
|
|
{% if movement.source_location and movement.destination_location %}
|
|
<span class="badge bg-info">{{ movement.movement_type }}</span>
|
|
{% elif movement.destination_location %}
|
|
<span class="badge bg-success">{{ movement.movement_type }}</span>
|
|
{% else %}
|
|
<span class="badge bg-danger">{{ movement.movement_type }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if movement.source_location %}
|
|
{{ movement.source_location.warehouse.name }} - {{ movement.source_location.name }}
|
|
{% else %}
|
|
-
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if movement.destination_location %}
|
|
{{ movement.destination_location.warehouse.name }} - {{ movement.destination_location.name }}
|
|
{% else %}
|
|
-
|
|
{% endif %}
|
|
</td>
|
|
<td class="text-end">
|
|
{% if movement.source_location and movement.destination_location %}
|
|
{{ movement.quantity }}
|
|
{% elif movement.destination_location %}
|
|
<span class="text-success">+{{ movement.quantity }}</span>
|
|
{% else %}
|
|
<span class="text-danger">{% if movement.quantity >= 0 %}-{% endif %}{{ movement.quantity }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>{{ movement.reason|default:"-" }}</td>
|
|
</tr>
|
|
{% empty %}
|
|
<tr>
|
|
<td colspan="7" class="text-center text-muted">
|
|
{% translate "Aucun mouvement enregistré." %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Filtrage des produits
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const searchInput = document.getElementById('product-search');
|
|
const alertsOnlyCheckbox = document.getElementById('show-alerts-only');
|
|
const stockOnlyCheckbox = document.getElementById('show-stock-only');
|
|
const noResultsDiv = document.getElementById('no-results');
|
|
|
|
if (!searchInput || !alertsOnlyCheckbox || !stockOnlyCheckbox) return;
|
|
|
|
function filterProducts() {
|
|
const searchTerm = searchInput.value.toLowerCase().trim();
|
|
const showAlertsOnly = alertsOnlyCheckbox.checked;
|
|
const showStockOnly = stockOnlyCheckbox.checked;
|
|
|
|
const allRows = document.querySelectorAll('.product-row');
|
|
const categorySections = document.querySelectorAll('.category-section');
|
|
|
|
let visibleCount = 0;
|
|
|
|
allRows.forEach(row => {
|
|
const productName = row.getAttribute('data-product-name') || '';
|
|
const productCode = row.getAttribute('data-product-code') || '';
|
|
const isAlert = row.getAttribute('data-is-alert') === 'true';
|
|
const stock = parseInt(row.getAttribute('data-stock') || '0', 10);
|
|
|
|
const matchesSearch = !searchTerm ||
|
|
productName.includes(searchTerm) ||
|
|
productCode.includes(searchTerm);
|
|
const matchesAlert = !showAlertsOnly || isAlert;
|
|
const matchesStock = !showStockOnly || stock > 0;
|
|
|
|
if (matchesSearch && matchesAlert && matchesStock) {
|
|
row.style.display = '';
|
|
visibleCount++;
|
|
} else {
|
|
row.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
categorySections.forEach(section => {
|
|
const visibleRows = section.querySelectorAll('.product-row:not([style*="display: none"])');
|
|
if (visibleRows.length === 0) {
|
|
section.style.display = 'none';
|
|
} else {
|
|
section.style.display = '';
|
|
const badge = section.querySelector('.category-header .badge');
|
|
if (badge) {
|
|
badge.textContent = visibleRows.length;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (visibleCount === 0) {
|
|
noResultsDiv.classList.remove('d-none');
|
|
} else {
|
|
noResultsDiv.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
searchInput.addEventListener('input', filterProducts);
|
|
alertsOnlyCheckbox.addEventListener('change', filterProducts);
|
|
stockOnlyCheckbox.addEventListener('change', filterProducts);
|
|
|
|
filterProducts();
|
|
});
|
|
</script>
|
|
|
|
{% if can_administer %}
|
|
<!-- Modal de confirmation de suppression -->
|
|
<div class="modal fade" id="deleteLocationModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title text-danger">
|
|
<i class="bi bi-exclamation-triangle"></i>
|
|
{% translate "Confirmer la suppression" %}
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
{% blocktranslate with name=location.name %}
|
|
Êtes-vous sûr de vouloir supprimer l'emplacement <strong>{{ name }}</strong> ?
|
|
Cette action est irréversible.
|
|
{% endblocktranslate %}
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
|
{% translate "Annuler" %}
|
|
</button>
|
|
<form method="post" action="{% url 'stock:warehouse_location_delete' location.id %}">
|
|
{% csrf_token %}
|
|
<button type="submit" class="btn btn-danger">
|
|
<i class="bi bi-trash"></i> {% translate "Supprimer" %}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Modal QR Code -->
|
|
<div class="modal fade" id="qrCodeModal" tabindex="-1" aria-labelledby="qrCodeModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="qrCodeModalLabel">
|
|
<i class="bi bi-qr-code me-2"></i>
|
|
{% translate "QR Code" %} — <span id="qr-modal-label"></span>
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
|
</div>
|
|
<div class="modal-body text-center py-4">
|
|
<img id="qr-modal-img" src="" alt="QR Code" class="img-fluid" style="max-width:320px;">
|
|
</div>
|
|
<div class="modal-footer justify-content-center gap-2">
|
|
<a id="qr-modal-download" href="" download class="btn btn-primary">
|
|
<i class="bi bi-download"></i> {% translate "Télécharger" %}
|
|
</a>
|
|
<button type="button" class="btn btn-secondary" onclick="printQrCode()">
|
|
<i class="bi bi-printer"></i> {% translate "Imprimer" %}
|
|
</button>
|
|
<button type="button" class="btn btn-light" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
const qrCodeModal = document.getElementById('qrCodeModal');
|
|
if (qrCodeModal) {
|
|
qrCodeModal.addEventListener('show.bs.modal', function (e) {
|
|
const trigger = e.relatedTarget;
|
|
if (!trigger) return;
|
|
const url = trigger.dataset.qrUrl;
|
|
const label = trigger.dataset.qrLabel || '';
|
|
document.getElementById('qr-modal-img').src = url;
|
|
document.getElementById('qr-modal-download').href = url;
|
|
document.getElementById('qr-modal-label').textContent = label;
|
|
});
|
|
}
|
|
});
|
|
|
|
function printQrCode() {
|
|
const img = document.getElementById('qr-modal-img');
|
|
const label = document.getElementById('qr-modal-label').textContent;
|
|
const win = window.open('', '_blank');
|
|
win.document.write(
|
|
'<html><head><title>QR Code — ' + label + '</title>'
|
|
+ '<style>body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;font-family:sans-serif;}'
|
|
+ 'img{max-width:300px;}p{margin-top:1rem;font-size:1.1rem;}</style></head>'
|
|
+ '<body><img src="' + img.src + '" alt="QR Code"><p>' + label + '</p></body></html>'
|
|
);
|
|
win.document.close();
|
|
win.focus();
|
|
win.print();
|
|
}
|
|
</script>
|
|
{% endblock content %}
|