← Blog

Construire un tableau de bord d’administration e-commerce avec Next.js : commandes, remboursements et stock

Les parties difficiles d’un back-office de boutique Next.js : deux statuts de commande, les échecs partiels des actions groupées, des remboursements justes au centime près, les ajustements de stock et les remises.

Preet Suthar · · 6 min de lecture

  • Next.js
  • React
  • E-commerce
  • Dashboard

Un back-office e-commerce ressemble à une application CRUD : un tableau de commandes, une page par commande, un formulaire par produit. Les tableaux sont la partie facile. La difficulté, c’est que chaque nombre d’un back-office de boutique est de l’argent ou du stock, et que les deux doivent rester justes pendant que les clients continuent d’acheter en arrière-plan.

Voici ce que j’ai appris en construisant le côté administration d’une boutique avec Next.js : des tableaux de commandes aux statuts honnêtes, une page de commande qui montre ce qui reste à faire, des remboursements justes au centime près, des modifications de stock qui résistent aux commandes en direct, et des remises que les marchands peuvent lire.

Les commandes : deux statuts, pas un

La première erreur est une colonne status unique. Une commande a au moins deux états indépendants : le paiement (en attente, autorisée, payée, partiellement remboursée, remboursée) et l’expédition (non expédiée, partiellement expédiée, expédiée). « Payée et non expédiée » est la liste à partir de laquelle un marchand travaille chaque matin, et une seule colonne de statut ne peut pas l’exprimer.

Affichez-les sous forme de deux pastilles. Ne laissez pas la couleur porter le sens. Les WCAG demandent que la couleur ne soit pas le seul moyen de transmettre une information, et « Partiellement payée » en orange clair à côté de « Payée » en vert est difficile à distinguer pour beaucoup de gens. Un libellé texte avec un petit glyphe, un cercle vide, à moitié plein ou plein, se lit d’un coup d’œil.

Les vues enregistrées vivent dans l’URL

Les marchands ne parcourent pas les commandes, ils traitent des listes : Non expédiées, Non payées, Ouvertes. Une vue enregistrée, ce n’est que des filtres, un tri et une recherche. Gardez toute la requête dans l’URL pour qu’un lien ouvre la même liste et que Retour fonctionne, et lisez-la sur le serveur dans Next.js :

type SearchParams = Promise<Record<string, string | string[] | undefined>>

export default async function OrdersPage({
  searchParams,
}: {
  searchParams: SearchParams
}) {
  const query = parseOrdersQuery(await searchParams)
  const page = await listOrders(query)
  return <OrdersTable query={query} page={page} />
}

Quand les filtres actuels diffèrent de la vue enregistrée, marquez l’onglet comme modifié et proposez Réinitialiser, Mettre à jour la vue et Enregistrer comme nouvelle vue, comme un tableur vous indique qu’un filtre est actif.

Les nouvelles commandes ne doivent pas déplacer la ligne sous le curseur

Les commandes arrivent pendant que le marchand regarde la liste. Si les nouvelles lignes poussent tout vers le bas, la case qu’il s’apprêtait à cocher devient une autre commande. Tant qu’une ligne a le focus ou que quelque chose est sélectionné, retenez les nouvelles commandes derrière une pastille « 1 nouvelle commande · Afficher », et ne les insérez en haut que lorsque rien n’est en cours.

Les actions groupées échouent partiellement

« Marquer 6 commandes comme expédiées » en expédiera parfois 5. L’une est en attente, ou a été annulée il y a une minute. Concevez l’API en conséquence : renvoyez les ids qui ont échoué, chacun avec une raison, et affichez exactement cela.

type BulkResult = {
  succeeded: string[]
  failed: { id: string; reason: string }[]
}

Le message s’écrit alors tout seul : « 5 commandes expédiées. 1 n’a pas pu être expédiée : #1031 est en attente », avec un lien vers cette commande. Un toast d’erreur tout-ou-rien laisserait le marchand deviner lesquelles sont passées.

La page de commande montre ce qui reste

Sur la page de commande, regroupez les articles selon ce qui leur est arrivé, pas selon la façon dont ils ont été achetés. Chaque envoi a sa propre carte avec le transporteur et le suivi. Les articles remboursés ont la leur. Et la carte qui compte le plus, c’est ce qui reste à expédier :

function unfulfilledQuantity(line: OrderLine) {
  return line.quantity - line.fulfilledQuantity - line.refundedQuantity
}

La chronologie en dessous doit être construite à partir des événements de la commande, avec des titres courts tirés des données (« Paiement de $564.41 capturé », « Expédiée avec UPS » avec le numéro de suivi en lien), l’heure exacte dans une infobulle et les événements répétés du même type regroupés en une seule ligne. Les commentaires de l’équipe se trouvent dans le même fil et sont publiés de façon optimiste, avec une nouvelle tentative s’ils échouent.

Le calcul des remboursements doit tomber juste au centime

Les remboursements sont l’endroit où le code d’administration se trompe le plus souvent sur l’argent. Un client a acheté trois articles avec une remise de $10 sur la commande et des taxes, et en renvoie un. Combien lui rend-on ?

Répartissez les montants au niveau de la commande sur les lignes, proportionnellement au prix de chaque ligne. Les remises selon le prix, les taxes selon le prix après remise. Une répartition en centimes entiers laisse des restes, et la méthode du plus fort reste les distribue pour que les parts s’additionnent toujours au montant d’origine :

function allocate(total: number, weights: number[]): number[] {
  const sum = weights.reduce((a, b) => a + b, 0)
  if (sum === 0) return weights.map(() => 0)
  const exact = weights.map((weight) => (total * weight) / sum)
  const parts = exact.map(Math.floor)
  let left = total - parts.reduce((a, b) => a + b, 0)
  const byFraction = exact
    .map((value, index) => ({ index, fraction: value - Math.floor(value) }))
    .sort((a, b) => b.fraction - a.fraction)
  for (const { index } of byFraction) {
    if (left === 0) break
    parts[index] += 1
    left -= 1
  }
  return parts
}

allocate(1000, [3000, 3000, 3000]) donne [334, 333, 333], dont la somme fait exactement 1000.

Il y a un second piège. Remboursez une unité d’une ligne aujourd’hui et les deux autres la semaine prochaine, et les totaux doivent correspondre à un remboursement des trois d’un coup. Donnez à chaque unité une tranche cumulative de la part de la ligne au lieu d’arrondir chaque remboursement séparément :

function unitsShare(lineShare: number, quantity: number, from: number, count: number) {
  const upTo = (units: number) => Math.floor((lineShare * units) / quantity)
  return upTo(from + count) - upTo(from)
}

from correspond au nombre d’unités déjà remboursées. Quelle que soit la façon dont les remboursements sont répartis, les tranches s’additionnent pour donner lineShare.

Le reste de l’écran de remboursement, c’est de la gestion d’états. La livraison peut être remboursée jusqu’à ce qui a été payé moins ce qui a déjà été remboursé. L’argent retourne vers les moyens de paiement dans un ordre fixe quand une commande a été réglée en partie par carte cadeau et en partie par carte bancaire. Envoyez une clé d’idempotence pour qu’une requête relancée ne rembourse jamais deux fois, demandez une confirmation pour les gros remboursements, et affichez « en attente auprès de la banque » comme un état à part, pas comme un succès ni un échec.

Le stock : ajuster, pas écraser

L’éditeur de stock naïf envoie le nouveau nombre : « Sand / Large vaut 47 ». Si deux commandes sont arrivées pendant que le marchand tapait, enregistrer 47 les annule en silence.

Envoyez plutôt les changements sous forme d’ajustements. « 5 reçus » est un delta, et un delta peut s’appliquer sans risque par-dessus tout ce qui s’est passé entre-temps. Quand le marchand veut vraiment un compte, « Définir à 47 » après un inventaire tournant, transformez-le en delta par rapport à la quantité qu’il a vue et laissez le serveur le rejeter si cette quantité a bougé :

type Adjustment = {
  variantId: string
  delta: number
  basedOn: number
  reason: "received" | "cycle_count" | "damaged" | "correction"
}

Une ligne rejetée reste dans le brouillon avec un message comme « Quantité passée à 40 depuis un autre appareil », et Réappliquer la renvoie par-dessus le nouveau nombre. La mutation inventoryAdjustQuantities de Shopify fonctionne de la même façon avec son champ changeFromQuantity.

Découpez aussi le nombre. Le stock physique, c’est ce qui se trouve dans l’entrepôt, mais le stock engagé est déjà vendu et le stock indisponible est endommagé ou bloqué. Les marchands doivent voir le disponible séparément du physique, sinon ils vendent du stock qui a déjà été vendu.

Les remises sous forme de phrase

Les formulaires de remise sont généralement une longue liste de champs, et les marchands ne savent pas ce qu’ils ont construit avant qu’un client ne se plaigne. Rédigez l’offre comme une seule phrase aux parties modifiables, « Les clients bénéficient de 15 % de réduction sur Ring Vase et Glass Carafe », placez le minimum d’achat, l’éligibilité des clients, les limites d’utilisation, les cumuls et le calendrier dans des lignes repliées qui affichent leur valeur actuelle, et prévisualisez un exemple de checkout avec la remise appliquée.

Avertissez des configurations risquées, comme 100 % de réduction sans limite d’utilisation, et planifiez dans le fuseau horaire de la boutique, pas dans celui du navigateur.

Une boutique, toutes les pages

La dernière pièce, c’est la cohérence. Remboursez un article, et le statut de paiement de la commande, sa chronologie, le total dépensé par le client et le stock réintégré doivent tous changer ensemble. Faites en sorte que chaque mutation renvoie les entités qu’elle a modifiées, et mettez à jour ou revalidez chaque vue qui les affiche. Et bloquez la navigation quand des modifications de produit, de stock ou de remise ne sont pas enregistrées, y compris via le bouton Retour du navigateur.

Les blocs HextaUI pour ça

Ce sont les problèmes autour desquels sont construits les blocs d’administration de HextaUI Pro. Orders Table a des vues enregistrées conservées dans l’URL, des pastilles séparées pour le paiement et l’expédition avec des glyphes de progression, et des actions groupées qui signalent les échecs partiels. Refund fait ses calculs dans calculateRefund(order, selection), une fonction pure testée qui utilise la méthode du plus fort reste. Inventory enregistre des ajustements, pas des écrasements, et permet aux marchands de réappliquer les lignes rejetées. Discount Builder modifie l’offre sous forme de phrase à côté d’un aperçu de checkout en direct.

Commerce Dashboard les réunit avec Order Detail, Product Editor et Sales Overview dans un seul back-office sur une boutique partagée, avec des routes par hash par défaut ou vos routes Next.js via sa prop router, et des exemples qui associent chaque action à la Shopify Admin API. L’aperçu interactif est le tableau de bord complet : vous pouvez rembourser une commande et voir chaque page se mettre à jour.

FAQ

Une commande doit-elle avoir un statut ou deux ? Au moins deux : le paiement et l’expédition sont indépendants, et les marchands filtrent sur leurs combinaisons.

Comment répartir une remise entre des articles remboursés ? En centimes entiers, proportionnellement au prix de chaque ligne, avec la méthode du plus fort reste pour que les parts s’additionnent exactement.

Les modifications de stock doivent-elles envoyer la nouvelle quantité ou la variation ? La variation. Les deltas résistent aux commandes passées pendant que le marchand modifiait ; un « définir à » doit être vérifié par rapport à la quantité sur laquelle il se basait.

Où doivent vivre les filtres d’un back-office ? Dans l’URL, pour que les vues puissent être partagées, rechargées et parcourues avec Retour.