loko/streetup/stock/templates/stock/inventory_detail.html
2026-07-22 14:48:40 +02:00

1232 lines
63 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% load short_name %}
{% block content %}
<!-- Select2 CSS -->
<link rel="stylesheet" href="{% static 'common/select2.min.css' %}" />
<link rel="stylesheet" href="{% static 'common/select2-bootstrap-5-theme.min.css' %}" />
<div class="container mt-5">
{# ── En-tête ─────────────────────────────────────────────── #}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3">{% translate "Inventaire" %} #{{ inventory.id }}</h1>
<div class="d-flex align-items-center gap-2">
{# ── Badge de statut (cliquable pour les gestionnaires si autorisé) ── #}
{% if can_change_status and not inventory.is_validated and inventory.status != 'done' or can_change_status and not inventory.is_validated and can_validate %}
<div class="dropdown">
<button class="btn badge fs-6 dropdown-toggle border-0 d-inline-flex align-items-center gap-1
bg-{% if inventory.status == 'in_progress' %}primary{% elif inventory.status == 'done' %}info{% else %}secondary{% endif %}"
type="button" data-bs-toggle="dropdown" aria-expanded="false"
title="{% translate 'Changer le statut' %}">
<i class="bi {% if inventory.status == 'draft' %}bi-circle{% elif inventory.status == 'in_progress' %}bi-play-fill{% else %}bi-flag-fill{% endif %}"></i>
{{ inventory.get_status_display }}
</button>
<ul class="dropdown-menu shadow-sm">
{% if inventory.status != 'draft' %}
<li>
<form method="post" action="{% url 'stock:inventory_change_status' inventory.pk %}">
{% csrf_token %}
<input type="hidden" name="status" value="draft">
<button type="submit" class="dropdown-item d-flex align-items-center gap-2">
<i class="bi bi-circle text-secondary"></i> {% translate "Brouillon" %}
</button>
</form>
</li>
{% endif %}
{% if inventory.status != 'in_progress' %}
<li>
<form method="post" action="{% url 'stock:inventory_change_status' inventory.pk %}">
{% csrf_token %}
<input type="hidden" name="status" value="in_progress">
<button type="submit" class="dropdown-item d-flex align-items-center gap-2">
<i class="bi bi-play-fill text-primary"></i> {% translate "En cours" %}
</button>
</form>
</li>
{% endif %}
{% if inventory.status != 'done' %}
<li>
<form method="post" action="{% url 'stock:inventory_change_status' inventory.pk %}">
{% csrf_token %}
<input type="hidden" name="status" value="done">
<button type="submit" class="dropdown-item d-flex align-items-center gap-2">
<i class="bi bi-flag-fill text-info"></i> {% translate "Terminé" %}
</button>
</form>
</li>
{% endif %}
</ul>
</div>
{% else %}
<span class="badge fs-6 d-inline-flex align-items-center gap-1
bg-{% if inventory.status == 'validated' %}success{% elif inventory.status == 'in_progress' %}primary{% elif inventory.status == 'done' %}info{% else %}secondary{% endif %}">
<i class="bi {% if inventory.status == 'draft' %}bi-circle{% elif inventory.status == 'in_progress' %}bi-play-fill{% elif inventory.status == 'done' %}bi-flag-fill{% else %}bi-lock-fill{% endif %}"></i>
{{ inventory.get_status_display }}
</span>
{% endif %}
{% if inventory.is_editable %}
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addProductModal">
<i class="bi bi-plus-circle"></i> {% translate "Ajouter un produit" %}
</button>
{% endif %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle d-inline-flex align-items-center gap-1"
type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-file-earmark-pdf"></i> {% translate "Export PDF" %}
</button>
<ul class="dropdown-menu shadow-sm">
<li>
<a class="dropdown-item d-flex align-items-center gap-2" href="{% url 'stock:inventory_pdf' inventory.pk %}?type=blank">
<i class="bi bi-file-earmark"></i> {% translate "Feuille de comptage vierge" %}
</a>
</li>
<li>
<a class="dropdown-item d-flex align-items-center gap-2" href="{% url 'stock:inventory_pdf' inventory.pk %}?type=complete">
<i class="bi bi-file-earmark-ruled"></i> {% translate "Tableau complet (comptages, commentaires, statut)" %}
</a>
</li>
</ul>
</div>
{% if can_validate and not inventory.is_validated %}
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#confirmValidateModal">
<i class="bi bi-check-circle"></i> {% translate "Valider l'inventaire" %}
</button>
{% endif %}
</div>
</div>
{# ── Infos générales ────────────────────────────────────── #}
<div class="row align-items-start mb-3">
<div class="col-md-9">
<dl class="row">
<dt class="col-sm-3">{% translate "Emplacement" %}</dt>
<dd class="col-sm-9">{{ inventory.warehouse_location.name }}</dd>
<dt class="col-sm-3">{% translate "Entrepôt" %}</dt>
<dd class="col-sm-9">{{ inventory.warehouse_location.warehouse.name }}</dd>
<dt class="col-sm-3">{% translate "Responsable" %}</dt>
<dd class="col-sm-9">{{ inventory.performed_by|default:_("Non renseigné") }}</dd>
<dt class="col-sm-3">{% translate "Créé le" %}</dt>
<dd class="col-sm-9">{{ inventory.created_at }}</dd>
<dt class="col-sm-3">{% translate "Validé le" %}</dt>
<dd class="col-sm-9">{{ inventory.validated_at|default:_("Non validé") }}</dd>
{% if inventory.validated_by %}
<dt class="col-sm-3">{% translate "Validé par" %}</dt>
<dd class="col-sm-9">{{ inventory.validated_by.get_full_name|default:inventory.validated_by.username }}</dd>
{% endif %}
{% if inventory.validator_comment %}
<dt class="col-sm-3">{% translate "Commentaire validateur" %}</dt>
<dd class="col-sm-9">
<span class="text-info fw-semibold">{{ inventory.validator_comment }}</span>
{% if can_validate %}
<button type="button" class="btn btn-sm btn-link py-0" data-bs-toggle="modal" data-bs-target="#editValidatorCommentModal">
<i class="bi bi-pencil"></i> {% translate "Modifier" %}
</button>
{% endif %}
</dd>
{% elif can_validate %}
<dt class="col-sm-3">{% translate "Commentaire validateur" %}</dt>
<dd class="col-sm-9">
<button type="button" class="btn btn-sm btn-outline-info py-0" data-bs-toggle="modal" data-bs-target="#editValidatorCommentModal">
<i class="bi bi-plus-circle"></i> {% translate "Ajouter un commentaire" %}
</button>
</dd>
{% endif %}
</dl>
</div>
{% if inventory.qr_code %}
<div class="col-md-3 text-center">
<p class="text-muted small mb-1">{% translate "QR Code" %}</p>
<img src="{{ inventory.qr_code.url }}" alt="QR Code Inventaire #{{ inventory.id }}"
style="width:110px;height:110px;cursor:pointer;"
class="img-fluid qr-code-thumb border rounded"
data-bs-toggle="modal" data-bs-target="#qrCodeModal"
data-qr-url="{{ inventory.qr_code.url }}"
data-qr-label="{% translate 'Inventaire' %} #{{ inventory.id }} — {{ inventory.warehouse_location.name }}">
</div>
{% endif %}
</div>
{# ── Résumé des écarts ──────────────────────────────────── #}
<div class="row mb-3">
<div class="col-md-3">
<div class="card text-center border-0 bg-light">
<div class="card-body py-2">
<div class="fs-4 fw-bold" id="summary-total">{{ items|length }}</div>
<small class="text-muted">{% translate "Articles" %}</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center border-0 bg-light">
<div class="card-body py-2">
<div class="fs-4 fw-bold text-danger" id="summary-neg">0</div>
<small class="text-muted">{% translate "Écarts négatifs" %}</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center border-0 bg-light">
<div class="card-body py-2">
<div class="fs-4 fw-bold text-success" id="summary-pos">0</div>
<small class="text-muted">{% translate "Écarts positifs" %}</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center border-0 bg-light">
<div class="card-body py-2">
<div class="fs-4 fw-bold" id="summary-ok">0</div>
<small class="text-muted">{% translate "Sans écart" %}</small>
</div>
</div>
</div>
</div>
{# ── Filtre écarts ──────────────────────────────────────── #}
<div class="card mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label mb-0 small fw-semibold">{% translate "Filtrer les écarts" %}</label>
</div>
<div class="col-auto">
<select id="filter-type" class="form-select form-select-sm" style="width:auto">
<option value="all">{% translate "Tous" %}</option>
<option value="discrepancy">{% translate "Avec écart uniquement" %}</option>
<option value="no_discrepancy">{% translate "Sans écart uniquement" %}</option>
<option value="negative">{% translate "Écarts négatifs" %}</option>
<option value="positive">{% translate "Écarts positifs" %}</option>
<option value="threshold">{% translate "Écart absolu >" %}</option>
</select>
</div>
<div class="col-auto" id="threshold-group" style="display:none">
<input type="number" id="filter-threshold" class="form-control form-control-sm" min="0" value="0" style="width:90px">
</div>
<div class="col-auto">
<select id="filter-validation" class="form-select form-select-sm" style="width:auto">
<option value="all">{% translate "Tous les statuts" %}</option>
<option value="validated">{% translate "Validés" %}</option>
<option value="not_validated">{% translate "Non validés" %}</option>
</select>
</div>
<div class="col-auto">
<input type="text" id="filter-search" class="form-control form-control-sm" placeholder="{% translate 'Rechercher un produit…' %}" style="width:200px">
</div>
<div class="col-auto ms-auto">
<span class="badge bg-secondary" id="visible-count"></span>
</div>
</div>
</div>
</div>
{# ── Tableau des articles ───────────────────────────────── #}
{# Formulaire caché pour la validation par lot des articles filtrés #}
<form method="post" action="{% url 'stock:inventory_validate_items' inventory.pk %}" id="batch-validate-form" class="d-none">
{% csrf_token %}
<div id="batch-item-ids"></div>
</form>
<div class="table-responsive mt-2">
<table class="table table-bordered align-middle" id="inventory-table">
<thead>
<tr>
<th>{% translate "Produit" %}</th>
<th>{% translate "Emplacement" %}</th>
<th class="text-end">{% translate "Qté attendue" %}</th>
<th class="text-end" style="width:140px">{% translate "Qté comptée" %}</th>
<th class="text-end" style="width:100px">{% translate "Écart" %}</th>
<th class="text-center" style="width:120px">{% translate "Statut" %}</th>
{% if can_validate and not inventory.is_validated %}
<th class="text-center" style="width:120px">
<button type="button" class="btn btn-sm btn-success" id="btn-validate-visible"
title="{% translate 'Valider tous les articles visibles dans le tableau' %}">
<i class="bi bi-check-all"></i> {% translate "Tout valider" %}
</button>
</th>
{% endif %}
{% if inventory.is_editable and can_validate %}
<th class="text-center" style="width:50px"></th>
{% endif %}
</tr>
</thead>
<tbody id="inventory-items-body">
{% for item in items %}
<tr class="{% if item.difference < 0 %}table-danger{% elif item.difference > 0 %}table-success{% endif %}"
data-diff="{{ item.difference }}"
data-validated="{{ item.is_validated|yesno:'1,0' }}"
data-product="{{ item.product.name|lower }}"
data-item-id="{{ item.pk }}">
<td>
<span class="fw-semibold">{{ item.product.name }}</span>
{% if item.comment %}
<br><small class="text-muted">
<i class="bi bi-person-fill me-1"></i>
<strong>{{ item.comment_by|short_name|default:"?" }}</strong>
— <span class="worker-comment-text">{{ item.comment }}</span>
</small>
{% endif %}
{% if item.validator_comment %}
<br><small class="text-info">
<i class="bi bi-person-badge-fill me-1"></i>
<strong>{{ item.validator_comment_by|short_name|default:"?" }}</strong>
— <span class="val-comment-text">{{ item.validator_comment }}</span>
</small>
{% endif %}
</td>
<td>{{ inventory.warehouse_location.name }}</td>
<td class="text-end td-expected">{{ item.expected_quantity }}</td>
<td class="td-counted">
<div class="d-flex align-items-center justify-content-end gap-1">
{% if inventory.is_editable and inventory.is_quantity_editable or can_validate and inventory.is_editable %}
<input type="number" class="form-control form-control-sm text-end input-counted-quantity"
value="{{ item.counted_quantity }}" min="0" style="width:90px"
data-item-id="{{ item.pk }}"
data-original-val="{{ item.counted_quantity }}"
data-update-url="{% url 'stock:inventory_item_update_api' inventory.pk item.pk %}">
{% else %}
<span class="text-end">{{ item.counted_quantity }}</span>
{% endif %}
{% if item.comment or item.validator_comment or inventory.is_editable or can_validate %}
<button type="button"
class="btn btn-sm btn-outline-secondary flex-shrink-0 btn-show-comment"
data-comment="{{ item.comment|escapejs }}"
data-comment-by-short-name="{{ item.comment_by|short_name|escapejs }}"
data-validator-comment="{{ item.validator_comment|escapejs }}"
data-validator-comment-by-short-name="{{ item.validator_comment_by|short_name|escapejs }}"
data-product="{{ item.product.name|escapejs }}"
data-item-id="{{ item.pk }}"
data-update-url="{% url 'stock:inventory_item_update_api' inventory.pk item.pk %}"
title="{% if can_validate %}{% translate 'Commentaires et validation' %}{% else %}{% translate 'Voir/ajouter un commentaire' %}{% endif %}">
<i class="bi {% if item.comment or item.validator_comment %}bi-chat-text-fill text-primary{% else %}bi-chat-text{% endif %}"></i>
</button>
{% endif %}
</div>
</td>
<td class="text-end fw-bold td-difference {% if item.difference < 0 %}text-danger{% elif item.difference > 0 %}text-success{% endif %}">
{% if item.difference > 0 %}+{% endif %}{{ item.difference }}
</td>
<td class="text-center td-status">
{% if item.is_validated %}
<span class="badge bg-success" title="{{ item.validated_by.get_full_name|default:item.validated_by.username }} – {{ item.validated_at }}">
<i class="bi bi-check-lg"></i> {% translate "Validé" %}
</span>
{% else %}
<span class="badge bg-warning text-dark">
<i class="bi bi-hourglass-split"></i> {% translate "En attente" %}
</span>
{% endif %}
</td>
{% if can_validate and not inventory.is_validated %}
<td class="text-center td-toggle-validate-cell">
<button type="button"
class="btn btn-sm btn-toggle-validate {% if item.is_validated %}btn-success{% else %}btn-outline-success{% endif %}"
data-url="{% url 'stock:inventory_validate_item' inventory.pk item.pk %}"
title="{% if item.is_validated %}{% translate 'Annuler la validation' %}{% else %}{% translate 'Valider cet article' %}{% endif %}">
<i class="bi {% if item.is_validated %}bi-check-circle-fill{% else %}bi-check{% endif %}"></i>
</button>
</td>
{% endif %}
{% if inventory.is_editable and can_validate %}
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-item"
data-url="{% url 'stock:inventory_item_delete_api' inventory.pk item.pk %}"
title="{% translate 'Supprimer cette ligne' %}">
<i class="bi bi-trash"></i>
</button>
</td>
{% endif %}
</tr>
{% empty %}
<tr id="empty-row">
<td colspan="8" class="text-center text-muted py-4">{% translate "Aucun article dans cet inventaire." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-3">
<a href="{% url 'stock:stock_movements' %}?tab=inventories" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> {% translate "Retour à la liste" %}
</a>
</div>
{# ── Modal commentaire ────────────────────────────────── #}
<div class="modal fade" id="commentModal" tabindex="-1" aria-labelledby="commentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="commentModalLabel">
<i class="bi bi-chat-text me-2"></i>
<span id="comment-modal-product"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div id="comment-modal-worker-section">
<label class="form-label fw-bold">
<i class="bi bi-person-fill me-1"></i>{% translate "Commentaire du magasinier" %}
<small class="text-muted fw-normal ms-1" id="comment-modal-worker-author"></small>
</label>
{% if inventory.is_worker_comment_editable and not can_validate %}
<textarea id="comment-modal-worker-input" class="form-control mb-2" rows="3" placeholder="{% translate 'Ajouter/modifier un commentaire...' %}"></textarea>
<div class="text-end mb-2">
<button type="button" id="btn-save-modal-worker-comment" class="btn btn-sm btn-secondary">
<i class="bi bi-floppy"></i> {% translate "Enregistrer" %}
</button>
</div>
{% else %}
<p id="comment-modal-text" class="mb-3 p-2 bg-light rounded" style="white-space:pre-wrap"></p>
{% endif %}
</div>
<hr id="comment-modal-divider">
<div id="comment-modal-validator-section">
<label class="form-label fw-bold">
<i class="bi bi-person-badge-fill me-1"></i>{% translate "Commentaire du validateur" %}
<small class="text-muted fw-normal ms-1" id="comment-modal-validator-author"></small>
</label>
{% if can_validate %}
<textarea id="comment-modal-validator-input" class="form-control mb-2" rows="3" placeholder="{% translate 'Ajouter/modifier une observation du validateur...' %}"></textarea>
<div class="text-end">
<button type="button" id="btn-save-modal-validator-comment" class="btn btn-sm btn-primary">
<i class="bi bi-floppy"></i> {% translate "Enregistrer" %}
</button>
</div>
{% else %}
<p id="comment-modal-validator-text" class="mb-0 p-2 bg-light rounded" style="white-space:pre-wrap"></p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{# ── Modal Validation Globale avec Commentaire ─────────────── #}
{% if can_validate and not inventory.is_validated %}
<div class="modal fade" id="confirmValidateModal" tabindex="-1" aria-labelledby="confirmValidateModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<form method="post" action="{% url 'stock:inventory_validate' inventory.pk %}">
{% csrf_token %}
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="confirmValidateModalLabel">
<i class="bi bi-check-circle me-2"></i>
{% translate "Valider l'inventaire" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<p>{% translate "Voulez-vous vraiment valider cet inventaire ? Les stocks seront ajustés définitivement." %}</p>
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Commentaire du validateur (optionnel)" %}</label>
<textarea class="form-control" name="validator_comment" rows="3" placeholder="{% translate 'Expliquer les écarts majeurs, remarques de validation...' %}">{{ inventory.validator_comment }}</textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-success">{% translate "Confirmer et valider" %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{# ── Modal Commentaire Général Validateur ────────────────── #}
{% if can_validate %}
<div class="modal fade" id="editValidatorCommentModal" tabindex="-1" aria-labelledby="editValidatorCommentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<form method="post" action="{% url 'stock:inventory_update_validator_comment' inventory.pk %}">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="editValidatorCommentModalLabel">
<i class="bi bi-chat-text me-2"></i>
{% translate "Commentaire de validation général" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<textarea class="form-control" name="validator_comment" rows="4" placeholder="{% translate 'Saisir un commentaire pour cette validation...' %}">{{ inventory.validator_comment }}</textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
<button type="submit" class="btn btn-primary">{% translate "Enregistrer" %}</button>
</div>
</form>
</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>
{# ── Modal Ajouter un produit ────────────────────────────── #}
{% if inventory.is_editable %}
<div class="modal fade" id="addProductModal" tabindex="-1" aria-labelledby="addProductModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addProductModalLabel">
<i class="bi bi-plus-circle me-2"></i>
{% translate "Ajouter un produit" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-bold" for="add-product-select">{% translate "Produit" %}</label>
<select id="add-product-select" class="form-select" style="width:100%"></select>
</div>
<div class="mb-3">
<label class="form-label fw-bold" for="add-product-qty">{% translate "Quantité comptée" %}</label>
<input type="number" id="add-product-qty" class="form-control" min="0" value="0">
</div>
<div class="mb-3">
<label class="form-label fw-bold" for="add-product-comment">{% translate "Commentaire" %}</label>
<textarea id="add-product-comment" class="form-control" rows="2" placeholder="{% translate 'Commentaire optionnel...' %}"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" id="btn-submit-add-product" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> {% translate "Ajouter" %}
</button>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock content %}
{% block script %}
<script src="{% static 'common/select2.min.js' %}"></script>
<script>
(function () {
const filterType = document.getElementById('filter-type');
const filterThreshold = document.getElementById('filter-threshold');
const thresholdGroup = document.getElementById('threshold-group');
const filterValidation = document.getElementById('filter-validation');
const filterSearch = document.getElementById('filter-search');
const visibleCount = document.getElementById('visible-count');
// Summary counters initially
recalculateSummary();
function applyFilters() {
const type = filterType.value;
const threshold = parseInt(filterThreshold.value, 10) || 0;
const validation = filterValidation.value;
const search = filterSearch.value.trim().toLowerCase();
let shown = 0;
thresholdGroup.style.display = type === 'threshold' ? '' : 'none';
const currentRows = document.querySelectorAll('#inventory-items-body tr[data-diff]');
currentRows.forEach(r => {
const diff = parseInt(r.dataset.diff, 10);
const absDiff = Math.abs(diff);
const validated = r.dataset.validated === '1';
const product = r.dataset.product;
let visible = true;
// Type filter
if (type === 'discrepancy' && diff === 0) visible = false;
if (type === 'no_discrepancy' && diff !== 0) visible = false;
if (type === 'negative' && diff >= 0) visible = false;
if (type === 'positive' && diff <= 0) visible = false;
if (type === 'threshold' && absDiff <= threshold) visible = false;
// Validation filter
if (validation === 'validated' && !validated) visible = false;
if (validation === 'not_validated' && validated) visible = false;
// Search filter
if (search && !product.includes(search)) visible = false;
r.style.display = visible ? '' : 'none';
if (visible) shown++;
});
visibleCount.textContent = shown + ' / ' + currentRows.length;
}
function recalculateSummary() {
const currentRows = document.querySelectorAll('#inventory-items-body tr[data-diff]');
let negCount = 0, posCount = 0, okCount = 0;
currentRows.forEach(r => {
const d = parseInt(r.dataset.diff, 10);
if (d < 0) negCount++;
else if (d > 0) posCount++;
else okCount++;
});
document.getElementById('summary-neg').textContent = negCount;
document.getElementById('summary-pos').textContent = posCount;
document.getElementById('summary-ok').textContent = okCount;
const totalCard = document.getElementById('summary-total');
if (totalCard) {
totalCard.textContent = currentRows.length;
}
}
filterType.addEventListener('change', applyFilters);
filterThreshold.addEventListener('input', applyFilters);
filterValidation.addEventListener('change', applyFilters);
filterSearch.addEventListener('input', applyFilters);
applyFilters();
// ── Toggle validation via AJAX ─────────────────────────────────────
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
document.getElementById('inventory-items-body').addEventListener('click', function (e) {
const btn = e.target.closest('.btn-toggle-validate');
if (!btn || btn.disabled) return;
const row = btn.closest('tr');
btn.disabled = true;
const savedHTML = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
fetch(btn.dataset.url, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
})
.then(r => r.json())
.then(data => {
if (!data.success) {
alert(data.error || '{% translate "Une erreur est survenue." %}');
btn.innerHTML = savedHTML;
btn.disabled = false;
return;
}
// Update row data attribute (used by filters)
row.dataset.validated = data.is_validated ? '1' : '0';
// Update status badge
const statusCell = row.querySelector('.td-status');
if (data.is_validated) {
statusCell.innerHTML = `<span class="badge bg-success" title="${data.validated_by} – ${data.validated_at}"><i class="bi bi-check-lg"></i> {% translate "Validé" %}</span>`;
} else {
statusCell.innerHTML = `<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split"></i> {% translate "En attente" %}</span>`;
}
// Update toggle button
if (data.is_validated) {
btn.className = 'btn btn-sm btn-toggle-validate btn-success';
btn.title = '{% translate "Annuler la validation" %}';
btn.innerHTML = '<i class="bi bi-check-circle-fill"></i>';
} else {
btn.className = 'btn btn-sm btn-toggle-validate btn-outline-success';
btn.title = '{% translate "Valider cet article" %}';
btn.innerHTML = '<i class="bi bi-check"></i>';
}
btn.disabled = false;
// Re-apply filters so validation filter stays consistent
applyFilters();
})
.catch(() => {
btn.innerHTML = savedHTML;
btn.disabled = false;
alert('{% translate "Erreur réseau. Veuillez réessayer." %}');
});
});
// ── Bouton « Tout valider » dans le header du tableau ──────────────
const btnValidateVisible = document.getElementById('btn-validate-visible');
if (btnValidateVisible) {
btnValidateVisible.addEventListener('click', function () {
const currentRows = document.querySelectorAll('#inventory-items-body tr[data-diff]');
const ids = [];
currentRows.forEach(r => {
if (r.style.display !== 'none' && r.dataset.validated === '0') {
ids.push(r.dataset.itemId);
}
});
if (ids.length === 0) {
alert('{% translate "Aucun article non validé visible à valider." %}');
return;
}
if (!confirm('{% translate "Valider les" %} ' + ids.length + ' {% translate "article(s) visible(s) ?" %}')) {
return;
}
const container = document.getElementById('batch-item-ids');
container.innerHTML = '';
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'item_ids';
input.value = id;
container.appendChild(input);
});
document.getElementById('batch-validate-form').classList.remove('d-none');
document.getElementById('batch-validate-form').submit();
});
}
// ── Bouton commentaire → modal ────────────────────────────────
const commentModal = new bootstrap.Modal(document.getElementById('commentModal'));
let activeCommentItemId = null;
let activeCommentUpdateUrl = null;
let activeCommentRow = null;
let activeCommentBtn = null;
// Rebuild the product-name cell with both comments + authors
function _updateProductCell(row, data) {
const cell = row.querySelector('td:first-child');
if (!cell) return;
const productName = (cell.querySelector('.fw-semibold') || {}).textContent || '';
let html = `<span class="fw-semibold">${escapeHtml(productName)}</span>`;
if (data.comment) {
html += `<br><small class="text-muted"><i class="bi bi-person-fill me-1"></i><strong>${escapeHtml(data.comment_by_short_name || '?')}</strong> \u2014 <span class="worker-comment-text">${escapeHtml(data.comment)}</span></small>`;
}
if (data.validator_comment) {
html += `<br><small class="text-info"><i class="bi bi-person-badge-fill me-1"></i><strong>${escapeHtml(data.validator_comment_by_short_name || '?')}</strong> \u2014 <span class="val-comment-text">${escapeHtml(data.validator_comment)}</span></small>`;
}
cell.innerHTML = html;
}
document.getElementById('inventory-items-body').addEventListener('click', function (e) {
const btn = e.target.closest('.btn-show-comment');
if (!btn) return;
activeCommentBtn = btn;
activeCommentRow = btn.closest('tr');
activeCommentItemId = btn.dataset.itemId;
activeCommentUpdateUrl = btn.dataset.updateUrl;
document.getElementById('comment-modal-product').textContent = btn.dataset.product;
// Worker comment section
const workerComment = btn.dataset.comment || '';
const workerAuthorEl = document.getElementById('comment-modal-worker-author');
if (workerAuthorEl) workerAuthorEl.textContent = btn.dataset.commentByShortName || '';
const workerInput = document.getElementById('comment-modal-worker-input');
if (workerInput) {
workerInput.value = workerComment;
} else {
const workerText = document.getElementById('comment-modal-text');
if (workerText) workerText.textContent = workerComment;
}
document.getElementById('comment-modal-worker-section').style.display = '';
// Validator comment section
const valComment = btn.dataset.validatorComment || '';
const valAuthorEl = document.getElementById('comment-modal-validator-author');
if (valAuthorEl) valAuthorEl.textContent = btn.dataset.validatorCommentByShortName || '';
const valInput = document.getElementById('comment-modal-validator-input');
if (valInput) {
valInput.value = valComment;
document.getElementById('comment-modal-validator-section').style.display = '';
} else {
const valText = document.getElementById('comment-modal-validator-text');
if (valText) {
valText.textContent = valComment;
document.getElementById('comment-modal-validator-section').style.display = valComment ? '' : 'none';
}
}
document.getElementById('comment-modal-divider').style.display = '';
commentModal.show();
});
// ── Enregistrer commentaire magasinier (modal) ────────────────
const btnSaveWorkerComment = document.getElementById('btn-save-modal-worker-comment');
if (btnSaveWorkerComment) {
btnSaveWorkerComment.addEventListener('click', function() {
const workerInput = document.getElementById('comment-modal-worker-input');
if (!workerInput || !activeCommentUpdateUrl) return;
btnSaveWorkerComment.disabled = true;
const originalHTML = btnSaveWorkerComment.innerHTML;
btnSaveWorkerComment.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
const formData = new FormData();
formData.append('comment', workerInput.value);
fetch(activeCommentUpdateUrl, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
body: formData,
})
.then(r => r.json())
.then(data => {
if (!data.success) {
alert(data.error || '{% translate "Une erreur est survenue." %}');
return;
}
activeCommentBtn.dataset.comment = data.comment;
activeCommentBtn.dataset.commentByShortName = data.comment_by_short_name;
const icon = activeCommentBtn.querySelector('i');
const hasComment = (data.comment || '') !== '' || (activeCommentBtn.dataset.validatorComment || '') !== '';
icon.className = hasComment ? 'bi bi-chat-text-fill text-primary' : 'bi bi-chat-text';
_updateProductCell(activeCommentRow, {
comment: data.comment,
comment_by_short_name: data.comment_by_short_name,
validator_comment: activeCommentBtn.dataset.validatorComment || '',
validator_comment_by_short_name: activeCommentBtn.dataset.validatorCommentByShortName || '',
});
commentModal.hide();
})
.catch(() => { alert('{% translate "Erreur réseau. Veuillez réessayer." %}'); })
.finally(() => {
btnSaveWorkerComment.disabled = false;
btnSaveWorkerComment.innerHTML = originalHTML;
});
});
}
// ── Enregistrer commentaire validateur (modal) ────────────────
const btnSaveValComment = document.getElementById('btn-save-modal-validator-comment');
if (btnSaveValComment) {
btnSaveValComment.addEventListener('click', function() {
const valInput = document.getElementById('comment-modal-validator-input');
if (!valInput || !activeCommentUpdateUrl) return;
btnSaveValComment.disabled = true;
const originalHTML = btnSaveValComment.innerHTML;
btnSaveValComment.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
const formData = new FormData();
formData.append('validator_comment', valInput.value);
fetch(activeCommentUpdateUrl, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
body: formData,
})
.then(r => r.json())
.then(data => {
if (!data.success) {
alert(data.error || '{% translate "Une erreur est survenue." %}');
return;
}
activeCommentBtn.dataset.validatorComment = data.validator_comment;
activeCommentBtn.dataset.validatorCommentByShortName = data.validator_comment_by_short_name;
const icon = activeCommentBtn.querySelector('i');
const hasComment = (activeCommentBtn.dataset.comment || '') !== '' || (data.validator_comment || '') !== '';
icon.className = hasComment ? 'bi bi-chat-text-fill text-primary' : 'bi bi-chat-text';
_updateProductCell(activeCommentRow, {
comment: activeCommentBtn.dataset.comment || '',
comment_by_short_name: activeCommentBtn.dataset.commentByShortName || '',
validator_comment: data.validator_comment,
validator_comment_by_short_name: data.validator_comment_by_short_name,
});
commentModal.hide();
})
.catch(() => { alert('{% translate "Erreur réseau. Veuillez réessayer." %}'); })
.finally(() => {
btnSaveValComment.disabled = false;
btnSaveValComment.innerHTML = originalHTML;
});
});
}
// ── Modal QR Code ─────────────────────────────────────────────
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;
});
}
// ── Inline Editing - Quantités comptées ───────────────────────
document.getElementById('inventory-items-body').addEventListener('change', function (e) {
const input = e.target.closest('.input-counted-quantity');
if (!input) return;
const row = input.closest('tr');
const updateUrl = input.dataset.updateUrl;
const val = parseInt(input.value, 10);
if (isNaN(val) || val < 0) {
alert('{% translate "La quantité ne peut pas être négative ou invalide." %}');
input.value = input.dataset.originalVal || 0;
return;
}
input.disabled = true;
const formData = new FormData();
formData.append('counted_quantity', val);
fetch(updateUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
})
.then(r => r.json())
.then(data => {
input.disabled = false;
if (!data.success) {
alert(data.error || '{% translate "Une erreur est survenue." %}');
input.value = input.dataset.originalVal || 0;
return;
}
input.dataset.originalVal = data.counted_quantity;
input.value = data.counted_quantity;
const diffCell = row.querySelector('.td-difference');
if (diffCell) {
const diff = data.difference;
row.dataset.diff = diff;
diffCell.textContent = (diff > 0 ? '+' : '') + diff;
diffCell.className = 'text-end fw-bold td-difference ' + (diff < 0 ? 'text-danger' : (diff > 0 ? 'text-success' : ''));
}
row.className = data.difference < 0 ? 'table-danger' : (data.difference > 0 ? 'table-success' : '');
recalculateSummary();
applyFilters();
})
.catch(() => {
input.disabled = false;
alert('{% translate "Erreur réseau. Veuillez réessayer." %}');
});
});
// ── Inline Editing - Commentaires magasinier ───────────────────
document.getElementById('inventory-items-body').addEventListener('change', function (e) {
const input = e.target.closest('.input-comment');
if (!input) return;
const row = input.closest('tr');
const updateUrl = input.dataset.updateUrl;
const val = input.value.trim();
input.disabled = true;
const formData = new FormData();
formData.append('comment', val);
fetch(updateUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
})
.then(r => r.json())
.then(data => {
input.disabled = false;
if (!data.success) {
alert(data.error || '{% translate "Une erreur est survenue." %}');
input.value = input.dataset.originalVal || '';
return;
}
input.dataset.originalVal = data.comment;
input.value = data.comment;
const commentBtn = row.querySelector('.btn-show-comment');
if (commentBtn) {
commentBtn.dataset.comment = data.comment;
const icon = commentBtn.querySelector('i');
const hasComment = data.comment !== '' || (commentBtn.dataset.validatorComment || '') !== '';
if (icon) {
if (hasComment) {
icon.className = 'bi bi-chat-text-fill text-primary';
} else {
icon.className = 'bi bi-chat-text';
}
}
}
})
.catch(() => {
input.disabled = false;
alert('{% translate "Erreur réseau. Veuillez réessayer." %}');
});
});
// ── AJAX Deletion of Lines ──────────────────────────────────────
document.getElementById('inventory-items-body').addEventListener('click', function (e) {
const btn = e.target.closest('.btn-delete-item');
if (!btn || btn.disabled) return;
{% translate "Voulez-vous vraiment supprimer cet article de l'inventaire ?" as delete_confirm_msg %}
if (!confirm('{{ delete_confirm_msg|escapejs }}')) {
return;
}
const row = btn.closest('tr');
btn.disabled = true;
const savedHTML = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
fetch(btn.dataset.url, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
})
.then(r => r.json())
.then(data => {
if (!data.success) {
alert(data.error || '{% translate "Une erreur est survenue." %}');
btn.innerHTML = savedHTML;
btn.disabled = false;
return;
}
row.remove();
const remainingRows = document.querySelectorAll('#inventory-items-body tr[data-diff]');
if (remainingRows.length === 0) {
const tbody = document.getElementById('inventory-items-body');
const emptyRow = document.createElement('tr');
emptyRow.id = 'empty-row';
emptyRow.innerHTML = `<td colspan="9" class="text-center text-muted py-4">{% translate "Aucun article dans cet inventaire." %}</td>`;
tbody.appendChild(emptyRow);
}
recalculateSummary();
applyFilters();
})
.catch(() => {
btn.innerHTML = savedHTML;
btn.disabled = false;
alert('{% translate "Erreur réseau. Veuillez réessayer." %}');
});
});
// ── Auto-complete Products & Dynamic addition ──────────────────
{% if inventory.is_editable %}
const productsList = {{ products_json|safe }};
const productSelect = document.getElementById('add-product-select');
if (productSelect) {
let options = '<option value="">-- {% translate "Sélectionner" %} --</option>';
productsList.forEach(p => {
options += `<option value="${p.id}">${p.code} – ${p.name}</option>`;
});
productSelect.innerHTML = options;
const addProductModalEl = document.getElementById('addProductModal');
if (addProductModalEl) {
addProductModalEl.addEventListener('shown.bs.modal', function () {
$('#add-product-select').select2({
theme: 'bootstrap-5',
dropdownParent: $('#addProductModal')
});
});
}
}
const btnSubmitAddProduct = document.getElementById('btn-submit-add-product');
if (btnSubmitAddProduct) {
btnSubmitAddProduct.addEventListener('click', function () {
const productSelect = document.getElementById('add-product-select');
const qtyInput = document.getElementById('add-product-qty');
const commentInput = document.getElementById('add-product-comment');
const productId = productSelect.value;
const countedQuantity = qtyInput.value;
const comment = commentInput.value;
if (!productId) {
alert('{% translate "Veuillez sélectionner un produit." %}');
return;
}
btnSubmitAddProduct.disabled = true;
const originalHTML = btnSubmitAddProduct.innerHTML;
btnSubmitAddProduct.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
const formData = new FormData();
formData.append('product_id', productId);
formData.append('counted_quantity', countedQuantity);
formData.append('comment', comment);
fetch("{% url 'stock:inventory_add_item_api' inventory.pk %}", {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
})
.then(r => r.json())
.then(data => {
btnSubmitAddProduct.disabled = false;
btnSubmitAddProduct.innerHTML = originalHTML;
if (!data.success) {
alert(data.error || '{% translate "Une erreur est survenue." %}');
return;
}
const modalEl = document.getElementById('addProductModal');
const modalInstance = bootstrap.Modal.getInstance(modalEl);
if (modalInstance) {
modalInstance.hide();
}
$(productSelect).val('').trigger('change');
qtyInput.value = '0';
commentInput.value = '';
const emptyRow = document.getElementById('empty-row');
if (emptyRow) {
emptyRow.remove();
}
const tbody = document.getElementById('inventory-items-body');
const tr = document.createElement('tr');
tr.className = data.difference < 0 ? 'table-danger' : (data.difference > 0 ? 'table-success' : '');
tr.dataset.diff = data.difference;
tr.dataset.validated = data.is_validated ? '1' : '0';
tr.dataset.product = data.product_name.toLowerCase();
tr.dataset.itemId = data.item_id;
let rowHTML = `
<td>
<span class="fw-semibold">${escapeHtml(data.product_name)}</span>
</td>
<td>{{ inventory.warehouse_location.name }}</td>
<td class="text-end td-expected">${data.expected_quantity}</td>
<td class="td-counted">
<div class="d-flex align-items-center justify-content-end gap-1">
<input type="number" class="form-control form-control-sm text-end input-counted-quantity"
value="${data.counted_quantity}" min="0" style="width:90px"
data-item-id="${data.item_id}"
data-original-val="${data.counted_quantity}"
data-update-url="${data.update_url}">
<button type="button"
class="btn btn-sm btn-outline-secondary flex-shrink-0 btn-show-comment"
data-comment="${escapeHtml(data.comment)}"
data-comment-by-short-name=""
data-validator-comment=""
data-validator-comment-by-short-name=""
data-product="${escapeHtml(data.product_name)}"
data-item-id="${data.item_id}"
data-update-url="${data.update_url}"
title="${data.can_validate ? '{% translate "Commentaires et validation" %}' : '{% translate "Voir/ajouter un commentaire" %}'}">
<i class="bi ${data.comment ? 'bi-chat-text-fill text-primary' : 'bi-chat-text'}"></i>
</button>
</div>
</td>
<td class="text-end fw-bold td-difference ${data.difference < 0 ? 'text-danger' : (data.difference > 0 ? 'text-success' : '')}">
${data.difference > 0 ? '+' : ''}${data.difference}
</td>
<td class="text-center td-status">
<span class="badge bg-warning text-dark">
<i class="bi bi-hourglass-split"></i> {% translate "En attente" %}
</span>
</td>`;
if (data.can_validate) {
rowHTML += `
<td class="text-center td-toggle-validate-cell">
<button type="button"
class="btn btn-sm btn-toggle-validate btn-outline-success"
data-url="${data.validate_url}"
title="{% translate 'Valider cet article' %}">
<i class="bi bi-check"></i>
</button>
</td>`;
}
if (data.can_validate) {
rowHTML += `
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-item"
data-url="${data.delete_url}"
title="{% translate 'Supprimer cette ligne' %}">
<i class="bi bi-trash"></i>
</button>
</td>`;
}
tr.innerHTML = rowHTML;
tbody.appendChild(tr);
recalculateSummary();
applyFilters();
})
.catch(() => {
btnSubmitAddProduct.disabled = false;
btnSubmitAddProduct.innerHTML = originalHTML;
alert('{% translate "Erreur réseau. Veuillez réessayer." %}');
});
});
}
function escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
{% endif %}
})();
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 script %}