feat(stock): reglage rapide capacite + seuils depuis la page Stock (modale + endpoint dedie)
All checks were successful
CI / secret-scan (push) Successful in 19s
CI / php-lint (push) Successful in 43s
CI / static-tests (push) Successful in 1m46s
CI / js-tests (push) Successful in 48s
CI / secret-scan (pull_request) Successful in 29s
CI / php-lint (pull_request) Successful in 44s
CI / static-tests (pull_request) Successful in 1m22s
CI / js-tests (pull_request) Successful in 47s
All checks were successful
CI / secret-scan (push) Successful in 19s
CI / php-lint (push) Successful in 43s
CI / static-tests (push) Successful in 1m46s
CI / js-tests (push) Successful in 48s
CI / secret-scan (pull_request) Successful in 29s
CI / php-lint (pull_request) Successful in 44s
CI / static-tests (pull_request) Successful in 1m22s
CI / js-tests (pull_request) Successful in 47s
This commit is contained in:
parent
be4585aeb2
commit
fce6dae428
9 changed files with 614 additions and 26 deletions
|
|
@ -135,6 +135,23 @@ final class IngredientRepository
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reglage rapide des seuils depuis le tableau de bord stock (F13). Cible UNIQUEMENT
|
||||||
|
* les trois colonnes de calibrage (capacite = reference 100 %, seuils alerte/critique
|
||||||
|
* en %), distinctes de update() qui exige aussi name/unit/pack. stock_quantity n'est
|
||||||
|
* jamais touche : le niveau ne bouge que via restock/inventoryCount (ledger). Les
|
||||||
|
* bornes (capacite >= 1, % 0-100, critique < alerte strict) sont validees par
|
||||||
|
* l'appelant (controleur, RG-T18), pas ici.
|
||||||
|
*/
|
||||||
|
public function updateThresholds(int $id, int $capacity, int $low, int $critical): void
|
||||||
|
{
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE ingredient SET stock_capacity = :cap, low_stock_pct = :low, '
|
||||||
|
. 'critical_stock_pct = :crit WHERE id = :id',
|
||||||
|
['cap' => $capacity, 'low' => $low, 'crit' => $critical, 'id' => $id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suppression dure. Bloquee par FK RESTRICT (product_ingredient / stock_movement)
|
* Suppression dure. Bloquee par FK RESTRICT (product_ingredient / stock_movement)
|
||||||
* des qu'une recette ou un mouvement reference l'ingredient ; le controleur
|
* des qu'une recette ou un mouvement reference l'ingredient ; le controleur
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,17 @@ class IngredientController extends AdminController
|
||||||
return $guard;
|
return $guard;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->renderIndex($guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend le tableau de bord stock. Factorise pour servir la lecture (index, 200) et le
|
||||||
|
* re-rendu en erreur du reglage rapide de seuils (updateThresholds, 422). $error est
|
||||||
|
* affiche en bandeau si present. Les drapeaux de permission pilotent l'affichage des
|
||||||
|
* actions (la garde reelle reste par-route).
|
||||||
|
*/
|
||||||
|
private function renderIndex(GuardResult $guard, ?string $error = null, int $status = 200): Response
|
||||||
|
{
|
||||||
$ingredients = $this->ingredientRepository()->all();
|
$ingredients = $this->ingredientRepository()->all();
|
||||||
|
|
||||||
// Compteurs par bande pour le resume du tableau de bord (3 pastilles).
|
// Compteurs par bande pour le resume du tableau de bord (3 pastilles).
|
||||||
|
|
@ -59,14 +70,15 @@ class IngredientController extends AdminController
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->adminView('admin/ingredients/index', [
|
return $this->adminView('admin/ingredients/index', [
|
||||||
'title' => 'Stock - Wakdo Admin',
|
'title' => 'Stock - Wakdo Admin',
|
||||||
'activeNav' => 'stock',
|
'activeNav' => 'stock',
|
||||||
'ingredients' => $ingredients,
|
'ingredients' => $ingredients,
|
||||||
'bandCounts' => $counts,
|
'bandCounts' => $counts,
|
||||||
'canManage' => $this->may($guard, 'ingredient.manage'),
|
'canManage' => $this->may($guard, 'ingredient.manage'),
|
||||||
'canRestock' => $this->may($guard, 'stock.manage'),
|
'canRestock' => $this->may($guard, 'stock.manage'),
|
||||||
'canCount' => $this->may($guard, 'stock.count'),
|
'canCount' => $this->may($guard, 'stock.count'),
|
||||||
], $guard);
|
'thresholdError' => $error,
|
||||||
|
], $guard, $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -303,6 +315,49 @@ class IngredientController extends AdminController
|
||||||
return $this->redirect('/admin/ingredients');
|
return $this->redirect('/admin/ingredients');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reglage rapide des seuils depuis le tableau de bord stock (F13). Endpoint LEGER :
|
||||||
|
* ne reutilise PAS update() (qui exige name/unit/pack), il ne valide et n'ecrit que
|
||||||
|
* capacite + seuils alerte/critique. Permission stock.manage (calibrage du stock, pas
|
||||||
|
* du catalogue), CSRF, SANS PIN (config, pas un comptage d'inventaire RG-T13). Succes
|
||||||
|
* -> redirect liste + flash ; erreur de validation -> 422 re-rendu de la liste avec un
|
||||||
|
* bandeau d'erreur (convention restock/inventory : 422 + message, pas de redirect muet).
|
||||||
|
*
|
||||||
|
* @param array<string, string> $params
|
||||||
|
*/
|
||||||
|
public function updateThresholds(array $params): Response
|
||||||
|
{
|
||||||
|
$guard = $this->guard('stock.manage');
|
||||||
|
if ($guard instanceof Response) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->request->formBody();
|
||||||
|
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
|
||||||
|
return $this->invalidCsrf();
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) ($params['id'] ?? 0);
|
||||||
|
$ingredient = $this->ingredientRepository()->find($id);
|
||||||
|
if ($ingredient === null) {
|
||||||
|
return $this->notFound($guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$data, $errors] = $this->validateThresholds($form);
|
||||||
|
if ($errors !== []) {
|
||||||
|
// Premier message d'erreur en bandeau : la modale est rouverte cote client si
|
||||||
|
// besoin ; l'equipier voit la raison du rejet sans jargon de champ.
|
||||||
|
$messages = array_values($errors);
|
||||||
|
|
||||||
|
return $this->renderIndex($guard, $messages[0], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ingredientRepository()->updateThresholds($id, $data['stock_capacity'], $data['low_stock_pct'], $data['critical_stock_pct']);
|
||||||
|
$this->setFlash('Seuils mis a jour.');
|
||||||
|
|
||||||
|
return $this->redirect('/admin/ingredients');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $params
|
* @param array<string, string> $params
|
||||||
*/
|
*/
|
||||||
|
|
@ -562,12 +617,6 @@ class IngredientController extends AdminController
|
||||||
$errors['unit'] = 'L unite est requise (40 caracteres max).';
|
$errors['unit'] = 'L unite est requise (40 caracteres max).';
|
||||||
}
|
}
|
||||||
|
|
||||||
$capRaw = trim($form['stock_capacity'] ?? '');
|
|
||||||
$capValid = ctype_digit($capRaw) && (int) $capRaw >= 1 && (int) $capRaw <= 2147483647;
|
|
||||||
if (!$capValid) {
|
|
||||||
$errors['stock_capacity'] = 'La capacite (reference 100%) doit etre un entier >= 1.';
|
|
||||||
}
|
|
||||||
|
|
||||||
$packRaw = trim($form['pack_size'] ?? '');
|
$packRaw = trim($form['pack_size'] ?? '');
|
||||||
$packValid = ctype_digit($packRaw) && (int) $packRaw >= 1 && (int) $packRaw <= 65535;
|
$packValid = ctype_digit($packRaw) && (int) $packRaw >= 1 && (int) $packRaw <= 65535;
|
||||||
if (!$packValid) {
|
if (!$packValid) {
|
||||||
|
|
@ -579,6 +628,45 @@ class IngredientController extends AdminController
|
||||||
$errors['pack_label'] = 'Libelle de pack trop long (80 caracteres max).';
|
$errors['pack_label'] = 'Libelle de pack trop long (80 caracteres max).';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capacite + seuils : meme regle (capacite >= 1, % 0-100, critique < alerte strict)
|
||||||
|
// que le reglage rapide F13 -> source unique validateThresholds(), pas de copie
|
||||||
|
// divergente. Les messages restent indexes par champ pour le formulaire complet.
|
||||||
|
[$thresholds, $thresholdErrors] = $this->validateThresholds($form);
|
||||||
|
$errors = array_merge($errors, $thresholdErrors);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $name,
|
||||||
|
'unit' => $unit,
|
||||||
|
'stock_capacity' => $thresholds['stock_capacity'],
|
||||||
|
'pack_size' => $packValid ? (int) $packRaw : 0,
|
||||||
|
'pack_label' => $label !== '' ? $label : null,
|
||||||
|
'low_stock_pct' => $thresholds['low_stock_pct'],
|
||||||
|
'critical_stock_pct' => $thresholds['critical_stock_pct'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return [$data, $errors];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation des trois reglages de calibrage du stock, partagee par le formulaire
|
||||||
|
* complet (validate) et l'endpoint leger F13 (updateThresholds) pour qu'une seule
|
||||||
|
* regle existe : capacite (reference 100 %) >= 1 ; seuils alerte/critique entiers
|
||||||
|
* 0-100 ; critique STRICTEMENT inferieur a alerte (RG-CREATE-ING, garanti aussi par
|
||||||
|
* un CHECK de table). Renvoie [valeurs normalisees, erreurs indexees par champ].
|
||||||
|
*
|
||||||
|
* @param array<string, string> $form
|
||||||
|
* @return array{0: array{stock_capacity: int, low_stock_pct: int, critical_stock_pct: int}, 1: array<string, string>}
|
||||||
|
*/
|
||||||
|
private function validateThresholds(array $form): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$capRaw = trim($form['stock_capacity'] ?? '');
|
||||||
|
$capValid = ctype_digit($capRaw) && (int) $capRaw >= 1 && (int) $capRaw <= 2147483647;
|
||||||
|
if (!$capValid) {
|
||||||
|
$errors['stock_capacity'] = 'La capacite (reference 100%) doit etre un entier >= 1.';
|
||||||
|
}
|
||||||
|
|
||||||
$lowRaw = trim($form['low_stock_pct'] ?? '');
|
$lowRaw = trim($form['low_stock_pct'] ?? '');
|
||||||
$lowValid = ctype_digit($lowRaw) && (int) $lowRaw <= 100;
|
$lowValid = ctype_digit($lowRaw) && (int) $lowRaw <= 100;
|
||||||
if (!$lowValid) {
|
if (!$lowValid) {
|
||||||
|
|
@ -591,17 +679,12 @@ class IngredientController extends AdminController
|
||||||
$errors['critical_stock_pct'] = 'Le seuil critique doit etre un entier entre 0 et 100.';
|
$errors['critical_stock_pct'] = 'Le seuil critique doit etre un entier entre 0 et 100.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// RG-CREATE-ING : critical_stock_pct < low_stock_pct (strict).
|
|
||||||
if ($lowValid && $critValid && (int) $critRaw >= (int) $lowRaw) {
|
if ($lowValid && $critValid && (int) $critRaw >= (int) $lowRaw) {
|
||||||
$errors['critical_stock_pct'] = 'Le seuil critique doit etre strictement inferieur au seuil d alerte.';
|
$errors['critical_stock_pct'] = 'Le seuil critique doit etre strictement inferieur au seuil d alerte.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'name' => $name,
|
|
||||||
'unit' => $unit,
|
|
||||||
'stock_capacity' => $capValid ? (int) $capRaw : 0,
|
'stock_capacity' => $capValid ? (int) $capRaw : 0,
|
||||||
'pack_size' => $packValid ? (int) $packRaw : 0,
|
|
||||||
'pack_label' => $label !== '' ? $label : null,
|
|
||||||
'low_stock_pct' => $lowValid ? (int) $lowRaw : 0,
|
'low_stock_pct' => $lowValid ? (int) $lowRaw : 0,
|
||||||
'critical_stock_pct' => $critValid ? (int) $critRaw : 0,
|
'critical_stock_pct' => $critValid ? (int) $critRaw : 0,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,11 @@ declare(strict_types=1);
|
||||||
*
|
*
|
||||||
* @var array<int, array<string, mixed>> $ingredients
|
* @var array<int, array<string, mixed>> $ingredients
|
||||||
* @var array<string, int> $bandCounts
|
* @var array<string, int> $bandCounts
|
||||||
* @var bool $canManage
|
* @var bool $canManage
|
||||||
* @var bool $canRestock
|
* @var bool $canRestock
|
||||||
* @var bool $canCount
|
* @var bool $canCount
|
||||||
* @var string $csrfToken
|
* @var string|null $thresholdError
|
||||||
|
* @var string $csrfToken
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @var array<int, array<string, mixed>> $rows */
|
/** @var array<int, array<string, mixed>> $rows */
|
||||||
|
|
@ -31,6 +32,29 @@ $count = (bool) ($canCount ?? false);
|
||||||
$nCritical = (int) ($counts['critical'] ?? 0);
|
$nCritical = (int) ($counts['critical'] ?? 0);
|
||||||
$nLow = (int) ($counts['low'] ?? 0);
|
$nLow = (int) ($counts['low'] ?? 0);
|
||||||
$nNormal = (int) ($counts['normal'] ?? 0);
|
$nNormal = (int) ($counts['normal'] ?? 0);
|
||||||
|
$thresholdErr = isset($thresholdError) && is_string($thresholdError) ? $thresholdError : null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bouton "Regler les seuils" (F13). Ouvre la modale pre-remplie via des data-attributes
|
||||||
|
* (l'id pour l'action POST, le nom pour le titre, les trois valeurs courantes) ; le JS
|
||||||
|
* stock-thresholds.js intercepte le clic. Affiche seulement si le role peut calibrer le
|
||||||
|
* stock ($restock = stock.manage). Valeurs echappees (attributs HTML).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
$renderThresholdButton = static function (array $row) use ($esc, $restock): string {
|
||||||
|
if (!$restock) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$id = (int) ($row['id'] ?? 0);
|
||||||
|
|
||||||
|
return '<button type="button" class="btn btn-ghost btn-sm" data-threshold-open'
|
||||||
|
. ' data-id="' . $id . '"'
|
||||||
|
. ' data-name="' . $esc($row['name'] ?? '') . '"'
|
||||||
|
. ' data-capacity="' . (int) ($row['stock_capacity'] ?? 0) . '"'
|
||||||
|
. ' data-low="' . (int) ($row['low_stock_pct'] ?? 0) . '"'
|
||||||
|
. ' data-critical="' . (int) ($row['critical_stock_pct'] ?? 0) . '">Regler les seuils</button>';
|
||||||
|
};
|
||||||
|
|
||||||
// Les ingredients a reapprovisionner : critiques d'abord, puis en alerte. Le reste
|
// Les ingredients a reapprovisionner : critiques d'abord, puis en alerte. Le reste
|
||||||
// (au-dessus des seuils) va dans la liste calme "Tous les ingredients" plus bas.
|
// (au-dessus des seuils) va dans la liste calme "Tous les ingredients" plus bas.
|
||||||
|
|
@ -92,6 +116,10 @@ $renderBar = static function (array $row) use ($esc, $barClass): string {
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($thresholdErr !== null && $thresholdErr !== ''): ?>
|
||||||
|
<div class="flash flash-error" role="alert"><?= $esc($thresholdErr) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<p class="stock-explainer">
|
<p class="stock-explainer">
|
||||||
Le stock pilote ce qui est commandable sur la borne. Un ingredient requis par une
|
Le stock pilote ce qui est commandable sur la borne. Un ingredient requis par une
|
||||||
recette qui passe sous son seuil critique rend les produits qui l utilisent
|
recette qui passe sous son seuil critique rend les produits qui l utilisent
|
||||||
|
|
@ -137,9 +165,12 @@ $renderBar = static function (array $row) use ($esc, $barClass): string {
|
||||||
<span class="<?= $bandPill ?>"><?= $bandText ?></span>
|
<span class="<?= $bandPill ?>"><?= $bandText ?></span>
|
||||||
</div>
|
</div>
|
||||||
<?= $renderBar($row) ?>
|
<?= $renderBar($row) ?>
|
||||||
<?php if ($restock): ?>
|
<div class="stock-card__actions">
|
||||||
<a class="btn btn-primary stock-card__action" href="/admin/ingredients/<?= $id ?>/restock">Reapprovisionner</a>
|
<?php if ($restock): ?>
|
||||||
<?php endif; ?>
|
<a class="btn btn-primary stock-card__action" href="/admin/ingredients/<?= $id ?>/restock">Reapprovisionner</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?= $renderThresholdButton($row) ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -172,6 +203,7 @@ $renderBar = static function (array $row) use ($esc, $barClass): string {
|
||||||
<?php if ($count): ?>
|
<?php if ($count): ?>
|
||||||
<a class="btn btn-secondary btn-sm" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
|
<a class="btn btn-secondary btn-sm" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?= $renderThresholdButton($row) ?>
|
||||||
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
|
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
|
||||||
<?php if ($manage): ?>
|
<?php if ($manage): ?>
|
||||||
<span class="stock-list__crud">
|
<span class="stock-list__crud">
|
||||||
|
|
@ -189,3 +221,45 @@ $renderBar = static function (array $row) use ($esc, $barClass): string {
|
||||||
</ul>
|
</ul>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<?php if ($restock): ?>
|
||||||
|
<?php /*
|
||||||
|
Modale de reglage rapide des seuils (F13), rendue serveur (VRAI form POST + CSRF,
|
||||||
|
comme restock/inventory ; pas de fetch). Une seule modale pour la page : le bouton
|
||||||
|
clique (data-threshold-open) y injecte l'action /admin/ingredients/{id}/thresholds
|
||||||
|
et pre-remplit les trois champs depuis ses data-attributes (stock-thresholds.js).
|
||||||
|
Reutilise les classes .pin-modal-* (overlay generique). Cachee par defaut (pas de
|
||||||
|
classe .open) : sans JS, elle reste invisible et les actions classiques fonctionnent.
|
||||||
|
*/ ?>
|
||||||
|
<div class="pin-modal-overlay" data-threshold-modal role="dialog" aria-modal="true" aria-label="Reglage des seuils de stock">
|
||||||
|
<div class="pin-modal">
|
||||||
|
<div class="pin-modal-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="pin-modal-title">Regler les seuils</h2>
|
||||||
|
<p class="pin-modal-sub" data-threshold-name>Capacite de reference et seuils d alerte de l ingredient.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="" data-threshold-form>
|
||||||
|
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="th-capacity">Capacite (quantite consideree comme 100%)</label>
|
||||||
|
<input class="form-input" type="number" id="th-capacity" name="stock_capacity" min="1" step="1" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="th-low">Seuil d alerte (% du plein)</label>
|
||||||
|
<input class="form-input" type="number" id="th-low" name="low_stock_pct" min="0" max="100" step="1" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="th-critical">Seuil critique (% du plein)</label>
|
||||||
|
<input class="form-input" type="number" id="th-critical" name="critical_stock_pct" min="0" max="100" step="1" required>
|
||||||
|
<p class="form-hint">Le seuil critique doit etre inferieur au seuil d alerte. Sous le critique, les produits qui utilisent cet ingredient passent indisponibles sur la borne.</p>
|
||||||
|
</div>
|
||||||
|
<p class="form-error" data-threshold-error hidden></p>
|
||||||
|
<div class="pin-modal-actions">
|
||||||
|
<button class="btn btn-secondary" type="button" data-threshold-cancel>Annuler</button>
|
||||||
|
<button class="btn btn-primary" type="submit">Enregistrer les seuils</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
|
||||||
|
|
@ -169,5 +169,6 @@ $navClass = static function (string $code, string $current): string {
|
||||||
</div>
|
</div>
|
||||||
<script src="/assets/js/admin.js"></script>
|
<script src="/assets/js/admin.js"></script>
|
||||||
<script src="/assets/js/pin-modal.js"></script>
|
<script src="/assets/js/pin-modal.js"></script>
|
||||||
|
<script src="/assets/js/stock-thresholds.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2103,3 +2103,36 @@ tbody td.mono {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bandeau de confirmation (flash succes, pose par setFlash apres redirection) et sa
|
||||||
|
variante erreur (re-rendu 422 du reglage rapide de seuils, F13). */
|
||||||
|
.flash {
|
||||||
|
color: var(--color-success-text);
|
||||||
|
background: var(--color-success-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.flash-error {
|
||||||
|
color: var(--color-danger-text);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aide sous un champ de formulaire (texte explicatif, pas une erreur). */
|
||||||
|
.form-hint {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zone d'actions d'une carte a reapprovisionner : reappro + reglage des seuils empiles. */
|
||||||
|
.stock-card__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.stock-card__actions .btn-sm {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
|
||||||
130
src/public/admin/assets/js/stock-thresholds.js
Normal file
130
src/public/admin/assets/js/stock-thresholds.js
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
/**
|
||||||
|
* stock-thresholds.js — Reglage rapide des seuils de stock depuis le tableau de bord (F13).
|
||||||
|
*
|
||||||
|
* Chaque carte/ligne ingredient porte un bouton "Regler les seuils" (data-threshold-open)
|
||||||
|
* decore de ses valeurs courantes (data-id, data-name, data-capacity, data-low,
|
||||||
|
* data-critical). Au clic, on pre-remplit l'unique modale rendue serveur (un VRAI form POST
|
||||||
|
* avec CSRF, pas de fetch), on pointe son action sur /admin/ingredients/{id}/thresholds, et
|
||||||
|
* on l'ouvre. La validation finale reste cote serveur (validateThresholds) ; on ajoute ici
|
||||||
|
* un garde-fou client leger (capacite >= 1, % 0-100, critique < alerte strict) pour eviter
|
||||||
|
* un aller-retour evident. Sans JS, la modale reste cachee et le reste de la page marche.
|
||||||
|
*
|
||||||
|
* CSP 'self' : script externe, aucun handler inline. Style CommonJS testable + browser-safe.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function init(doc) {
|
||||||
|
var overlay = doc.querySelector('[data-threshold-modal]');
|
||||||
|
if (!overlay) {
|
||||||
|
return; // role sans stock.manage : la modale n'est pas rendue.
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = overlay.querySelector('[data-threshold-form]');
|
||||||
|
var inCapacity = doc.getElementById('th-capacity');
|
||||||
|
var inLow = doc.getElementById('th-low');
|
||||||
|
var inCritical = doc.getElementById('th-critical');
|
||||||
|
var nameLabel = overlay.querySelector('[data-threshold-name]');
|
||||||
|
var errorBox = overlay.querySelector('[data-threshold-error]');
|
||||||
|
if (!form || !inCapacity || !inLow || !inCritical) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var openers = doc.querySelectorAll('[data-threshold-open]');
|
||||||
|
for (var i = 0; i < openers.length; i++) {
|
||||||
|
openers[i].addEventListener('click', function (e) {
|
||||||
|
openModal(e.currentTarget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.querySelector('[data-threshold-cancel]').addEventListener('click', closeModal);
|
||||||
|
overlay.addEventListener('mousedown', function (e) {
|
||||||
|
if (e.target === overlay) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
doc.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && overlay.classList.contains('open')) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Garde-fou client : on ne bloque que les cas evidents (le serveur reste l'autorite).
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
var error = validate(inCapacity.value, inLow.value, inCritical.value);
|
||||||
|
if (error !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (errorBox) {
|
||||||
|
errorBox.textContent = error;
|
||||||
|
errorBox.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openModal(button) {
|
||||||
|
var id = button.getAttribute('data-id') || '';
|
||||||
|
form.setAttribute('action', '/admin/ingredients/' + id + '/thresholds');
|
||||||
|
if (nameLabel) {
|
||||||
|
var name = button.getAttribute('data-name') || '';
|
||||||
|
nameLabel.textContent = name === '' ? '' : 'Ingredient : ' + name;
|
||||||
|
}
|
||||||
|
inCapacity.value = button.getAttribute('data-capacity') || '';
|
||||||
|
inLow.value = button.getAttribute('data-low') || '';
|
||||||
|
inCritical.value = button.getAttribute('data-critical') || '';
|
||||||
|
if (errorBox) {
|
||||||
|
errorBox.hidden = true;
|
||||||
|
}
|
||||||
|
overlay.classList.add('open');
|
||||||
|
inCapacity.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
overlay.classList.remove('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation cliente legere, miroir de validateThresholds() cote serveur :
|
||||||
|
* capacite entiere >= 1 ; seuils entiers 0-100 ; critique STRICTEMENT < alerte.
|
||||||
|
* Renvoie un message d'erreur (string) ou null si tout est coherent.
|
||||||
|
*/
|
||||||
|
function validate(capacityRaw, lowRaw, criticalRaw) {
|
||||||
|
var capacity = toInt(capacityRaw);
|
||||||
|
var low = toInt(lowRaw);
|
||||||
|
var critical = toInt(criticalRaw);
|
||||||
|
|
||||||
|
if (capacity === null || capacity < 1) {
|
||||||
|
return 'La capacite (reference 100%) doit etre un entier superieur ou egal a 1.';
|
||||||
|
}
|
||||||
|
if (low === null || low < 0 || low > 100) {
|
||||||
|
return 'Le seuil d alerte doit etre un entier entre 0 et 100.';
|
||||||
|
}
|
||||||
|
if (critical === null || critical < 0 || critical > 100) {
|
||||||
|
return 'Le seuil critique doit etre un entier entre 0 et 100.';
|
||||||
|
}
|
||||||
|
if (critical >= low) {
|
||||||
|
return 'Le seuil critique doit etre strictement inferieur au seuil d alerte.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entier strict (suite de chiffres) ou null : refuse "", " 5", "5.0", "abc". */
|
||||||
|
function toInt(raw) {
|
||||||
|
var value = String(raw === undefined || raw === null ? '' : raw).trim();
|
||||||
|
if (!/^[0-9]+$/.test(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseInt(value, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { init: init, validate: validate };
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined' && document.addEventListener) {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
init(document);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -221,6 +221,11 @@ try {
|
||||||
$router->add('POST', '/admin/ingredients/{id}/delete', [IngredientController::class, 'destroy']);
|
$router->add('POST', '/admin/ingredients/{id}/delete', [IngredientController::class, 'destroy']);
|
||||||
$router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']);
|
$router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']);
|
||||||
$router->add('POST', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restock']);
|
$router->add('POST', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restock']);
|
||||||
|
// Reglage rapide des seuils (F13) : capacite/alerte/critique edites depuis la page
|
||||||
|
// Stock via une modale, sans passer par le formulaire complet. stock.manage (calibrage
|
||||||
|
// du stock), CSRF, SANS PIN (config, pas un comptage d'inventaire). {id} = un seul
|
||||||
|
// segment ; /thresholds ne chevauche ni /restock ni /inventory.
|
||||||
|
$router->add('POST', '/admin/ingredients/{id}/thresholds', [IngredientController::class, 'updateThresholds']);
|
||||||
$router->add('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']);
|
$router->add('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']);
|
||||||
$router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']);
|
$router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']);
|
||||||
$router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']);
|
$router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']);
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,41 @@ final class IngredientControllerTest extends TestCase
|
||||||
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">au-dessus du seuil/', $body);
|
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">au-dessus du seuil/', $body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testIndexExposesThresholdButtonWithCurrentValuesForStockManager(): void
|
||||||
|
{
|
||||||
|
// F13 : la page Stock porte un acces rapide "Regler les seuils" decore des
|
||||||
|
// valeurs courantes (data-attributes) qui pre-remplissent la modale, plus la
|
||||||
|
// modale rendue serveur (VRAI form POST). Visible pour stock.manage.
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$db->ingredientsRows = [$this->ingredient(['stock_capacity' => 100, 'low_stock_pct' => 12, 'critical_stock_pct' => 4])];
|
||||||
|
|
||||||
|
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
|
||||||
|
|
||||||
|
self::assertStringContainsString('Regler les seuils', $body);
|
||||||
|
self::assertStringContainsString('data-threshold-open', $body);
|
||||||
|
self::assertStringContainsString('data-capacity="100"', $body);
|
||||||
|
self::assertStringContainsString('data-low="12"', $body);
|
||||||
|
self::assertStringContainsString('data-critical="4"', $body);
|
||||||
|
// Modale rendue serveur avec un VRAI form POST (pas de fetch) + champs cibles.
|
||||||
|
self::assertStringContainsString('data-threshold-modal', $body);
|
||||||
|
self::assertStringContainsString('name="stock_capacity"', $body);
|
||||||
|
self::assertStringContainsString('name="critical_stock_pct"', $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexHidesThresholdAccessWithoutStockManage(): void
|
||||||
|
{
|
||||||
|
// Sans stock.manage (lecture seule), ni le bouton ni la modale ne sont rendus :
|
||||||
|
// l'acces rapide suit la permission de calibrage.
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$db->grantedCodes = ['stock.read'];
|
||||||
|
$db->ingredientsRows = [$this->ingredient()];
|
||||||
|
|
||||||
|
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('data-threshold-open', $body);
|
||||||
|
self::assertStringNotContainsString('data-threshold-modal', $body);
|
||||||
|
}
|
||||||
|
|
||||||
public function testIndexForbiddenWithoutStockRead(): void
|
public function testIndexForbiddenWithoutStockRead(): void
|
||||||
{
|
{
|
||||||
$db = $this->permittedDb();
|
$db = $this->permittedDb();
|
||||||
|
|
@ -427,6 +462,100 @@ final class IngredientControllerTest extends TestCase
|
||||||
self::assertStringContainsString('reference', $response->body());
|
self::assertStringContainsString('reference', $response->body());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- THRESHOLDS (F13, stock.manage, SANS PIN) : reglage rapide capacite + seuils ---
|
||||||
|
|
||||||
|
public function testUpdateThresholdsWritesOnlyTheThreeColumns(): void
|
||||||
|
{
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$form = ['_csrf' => $this->csrf, 'stock_capacity' => '200', 'low_stock_pct' => '15', 'critical_stock_pct' => '5'];
|
||||||
|
|
||||||
|
$response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']);
|
||||||
|
|
||||||
|
self::assertSame(302, $response->status());
|
||||||
|
self::assertSame('/admin/ingredients', $response->header('Location'));
|
||||||
|
$params = $this->writeParams($db, 'UPDATE ingredient SET stock_capacity');
|
||||||
|
self::assertNotNull($params);
|
||||||
|
self::assertSame(200, $params['cap']);
|
||||||
|
self::assertSame(15, $params['low']);
|
||||||
|
self::assertSame(5, $params['crit']);
|
||||||
|
// Endpoint leger : ne touche ni le stock ni le catalogue (name/unit/pack/is_active).
|
||||||
|
$sql = $this->writeSql($db, 'UPDATE ingredient SET stock_capacity');
|
||||||
|
self::assertStringNotContainsString('stock_quantity', $sql);
|
||||||
|
self::assertStringNotContainsString('is_active', $sql);
|
||||||
|
self::assertStringNotContainsString('name', $sql);
|
||||||
|
self::assertStringNotContainsString('pack_size', $sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateThresholdsRejectsCapacityBelowOne(): void
|
||||||
|
{
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$form = ['_csrf' => $this->csrf, 'stock_capacity' => '0', 'low_stock_pct' => '15', 'critical_stock_pct' => '5'];
|
||||||
|
|
||||||
|
$response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']);
|
||||||
|
|
||||||
|
self::assertSame(422, $response->status());
|
||||||
|
self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity'));
|
||||||
|
self::assertStringContainsString('capacite', $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateThresholdsRejectsPercentageAboveHundred(): void
|
||||||
|
{
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$form = ['_csrf' => $this->csrf, 'stock_capacity' => '100', 'low_stock_pct' => '120', 'critical_stock_pct' => '5'];
|
||||||
|
|
||||||
|
$response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']);
|
||||||
|
|
||||||
|
self::assertSame(422, $response->status());
|
||||||
|
self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateThresholdsRejectsCriticalNotStrictlyBelowLow(): void
|
||||||
|
{
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$form = ['_csrf' => $this->csrf, 'stock_capacity' => '100', 'low_stock_pct' => '10', 'critical_stock_pct' => '10'];
|
||||||
|
|
||||||
|
$response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']);
|
||||||
|
|
||||||
|
self::assertSame(422, $response->status());
|
||||||
|
self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity'));
|
||||||
|
self::assertStringContainsString('strictement inferieur', $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateThresholdsForbiddenWithoutStockManage(): void
|
||||||
|
{
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$db->grantedCodes = ['stock.read']; // lecture sans stock.manage
|
||||||
|
$form = ['_csrf' => $this->csrf, 'stock_capacity' => '100', 'low_stock_pct' => '10', 'critical_stock_pct' => '5'];
|
||||||
|
|
||||||
|
$response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']);
|
||||||
|
|
||||||
|
self::assertSame(403, $response->status());
|
||||||
|
self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateThresholdsRejectsInvalidCsrf(): void
|
||||||
|
{
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$form = ['_csrf' => 'bad', 'stock_capacity' => '100', 'low_stock_pct' => '10', 'critical_stock_pct' => '5'];
|
||||||
|
|
||||||
|
$response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']);
|
||||||
|
|
||||||
|
self::assertSame(403, $response->status());
|
||||||
|
self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateThresholdsNotFound(): void
|
||||||
|
{
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$db->ingredientRow = null;
|
||||||
|
$form = ['_csrf' => $this->csrf, 'stock_capacity' => '100', 'low_stock_pct' => '10', 'critical_stock_pct' => '5'];
|
||||||
|
|
||||||
|
$response = $this->controller($this->post($form, '/admin/ingredients/9/thresholds'), $db)->updateThresholds(['id' => '9']);
|
||||||
|
|
||||||
|
self::assertSame(404, $response->status());
|
||||||
|
self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity'));
|
||||||
|
}
|
||||||
|
|
||||||
// --- RESTOCK (9.1, stock.manage, SANS PIN) ---
|
// --- RESTOCK (9.1, stock.manage, SANS PIN) ---
|
||||||
|
|
||||||
public function testRestockAddsPacksAndRecordsMovementUnderSessionActor(): void
|
public function testRestockAddsPacksAndRecordsMovementUnderSessionActor(): void
|
||||||
|
|
|
||||||
116
tests/js/stock-thresholds.test.js
Normal file
116
tests/js/stock-thresholds.test.js
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/*
|
||||||
|
* Tests du reglage rapide des seuils de stock (F13) du back-office (node:test + jsdom).
|
||||||
|
*
|
||||||
|
* Couvre la logique testable du module : pre-remplissage de la modale depuis les
|
||||||
|
* data-attributes du bouton + pointage de l'action POST sur l'id, ouverture/fermeture,
|
||||||
|
* et le garde-fou client validate() (capacite >= 1, % 0-100, critique < alerte strict).
|
||||||
|
* La modale est rendue serveur (VRAI form POST) ; le module n'ajoute pas de fetch.
|
||||||
|
*/
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
|
||||||
|
// stock-thresholds.js est du CommonJS (admin = racine CommonJS) ; import par defaut.
|
||||||
|
import stockThresholds from '../../src/public/admin/assets/js/stock-thresholds.js';
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const dom = new JSDOM(
|
||||||
|
'<!DOCTYPE html><html><body>' +
|
||||||
|
'<button data-threshold-open data-id="7" data-name="Buns" ' +
|
||||||
|
' data-capacity="200" data-low="15" data-critical="5">Regler les seuils</button>' +
|
||||||
|
'<div class="pin-modal-overlay" data-threshold-modal>' +
|
||||||
|
' <form method="post" action="" data-threshold-form>' +
|
||||||
|
' <input type="hidden" name="_csrf" value="tok">' +
|
||||||
|
' <input type="number" id="th-capacity" name="stock_capacity">' +
|
||||||
|
' <input type="number" id="th-low" name="low_stock_pct">' +
|
||||||
|
' <input type="number" id="th-critical" name="critical_stock_pct">' +
|
||||||
|
' <p data-threshold-error hidden></p>' +
|
||||||
|
' <button type="button" data-threshold-cancel>Annuler</button>' +
|
||||||
|
' <button type="submit">Enregistrer les seuils</button>' +
|
||||||
|
' </form>' +
|
||||||
|
'</div>' +
|
||||||
|
'</body></html>',
|
||||||
|
);
|
||||||
|
return dom;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fire(dom, el, type) {
|
||||||
|
el.dispatchEvent(new dom.window.Event(type, { cancelable: true, bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('validate accepte une configuration coherente et rejette les cas evidents', () => {
|
||||||
|
// Coherent.
|
||||||
|
assert.equal(stockThresholds.validate('200', '15', '5'), null);
|
||||||
|
// Capacite < 1.
|
||||||
|
assert.match(stockThresholds.validate('0', '15', '5'), /capacite/i);
|
||||||
|
// Pourcentage hors 0-100.
|
||||||
|
assert.match(stockThresholds.validate('100', '120', '5'), /alerte/i);
|
||||||
|
assert.match(stockThresholds.validate('100', '15', '200'), /critique/i);
|
||||||
|
// Critique non strictement inferieur a l'alerte.
|
||||||
|
assert.match(stockThresholds.validate('100', '10', '10'), /strictement inferieur/i);
|
||||||
|
// Saisies non entieres refusees (miroir de ctype_digit cote serveur).
|
||||||
|
assert.notEqual(stockThresholds.validate('', '15', '5'), null);
|
||||||
|
assert.notEqual(stockThresholds.validate('10.5', '15', '5'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le clic sur un bouton pre-remplit la modale et pointe l action POST sur l id', () => {
|
||||||
|
const dom = setup();
|
||||||
|
const doc = dom.window.document;
|
||||||
|
stockThresholds.init(doc);
|
||||||
|
|
||||||
|
fire(dom, doc.querySelector('[data-threshold-open]'), 'click');
|
||||||
|
|
||||||
|
const overlay = doc.querySelector('[data-threshold-modal]');
|
||||||
|
assert.equal(overlay.classList.contains('open'), true);
|
||||||
|
assert.equal(doc.querySelector('[data-threshold-form]').getAttribute('action'), '/admin/ingredients/7/thresholds');
|
||||||
|
assert.equal(doc.getElementById('th-capacity').value, '200');
|
||||||
|
assert.equal(doc.getElementById('th-low').value, '15');
|
||||||
|
assert.equal(doc.getElementById('th-critical').value, '5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('soumettre une configuration incoherente bloque le POST et affiche l erreur', () => {
|
||||||
|
const dom = setup();
|
||||||
|
const doc = dom.window.document;
|
||||||
|
stockThresholds.init(doc);
|
||||||
|
|
||||||
|
fire(dom, doc.querySelector('[data-threshold-open]'), 'click');
|
||||||
|
// Critique >= alerte : le garde-fou client doit annuler la soumission.
|
||||||
|
doc.getElementById('th-critical').value = '15';
|
||||||
|
const form = doc.querySelector('[data-threshold-form]');
|
||||||
|
const evt = new dom.window.Event('submit', { cancelable: true, bubbles: true });
|
||||||
|
form.dispatchEvent(evt);
|
||||||
|
|
||||||
|
assert.equal(evt.defaultPrevented, true);
|
||||||
|
assert.equal(doc.querySelector('[data-threshold-error]').hidden, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('soumettre une configuration coherente laisse le form POST partir', () => {
|
||||||
|
const dom = setup();
|
||||||
|
const doc = dom.window.document;
|
||||||
|
stockThresholds.init(doc);
|
||||||
|
|
||||||
|
fire(dom, doc.querySelector('[data-threshold-open]'), 'click');
|
||||||
|
const form = doc.querySelector('[data-threshold-form]');
|
||||||
|
const evt = new dom.window.Event('submit', { cancelable: true, bubbles: true });
|
||||||
|
form.dispatchEvent(evt);
|
||||||
|
|
||||||
|
// Valeurs pre-remplies coherentes (200/15/5) : pas de blocage client (POST reel).
|
||||||
|
assert.equal(evt.defaultPrevented, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Annuler ferme la modale', () => {
|
||||||
|
const dom = setup();
|
||||||
|
const doc = dom.window.document;
|
||||||
|
stockThresholds.init(doc);
|
||||||
|
|
||||||
|
fire(dom, doc.querySelector('[data-threshold-open]'), 'click');
|
||||||
|
assert.equal(doc.querySelector('[data-threshold-modal]').classList.contains('open'), true);
|
||||||
|
|
||||||
|
fire(dom, doc.querySelector('[data-threshold-cancel]'), 'click');
|
||||||
|
assert.equal(doc.querySelector('[data-threshold-modal]').classList.contains('open'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('init sans modale (role sans stock.manage) ne plante pas', () => {
|
||||||
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||||
|
assert.doesNotThrow(() => stockThresholds.init(dom.window.document));
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue