diff --git a/scripts/db-init.js b/scripts/db-init.js index f844fab..ee62f72 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -74,6 +74,28 @@ async function main() { `); console.log("[DB Init] Table users is verified/created."); + await tempPool.query(` + CREATE TABLE IF NOT EXISTS "commande_cadences" ( + "id" serial PRIMARY KEY NOT NULL, + "code_fournisseur" varchar(20) NOT NULL, + "nom_fournisseur" varchar(255), + "site" varchar(20) NOT NULL, + "intervalle_semaines" integer NOT NULL, + "actif" boolean NOT NULL DEFAULT true, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() + ); + `); + await tempPool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "uq_cadence_fou_site" + ON "commande_cadences" ("code_fournisseur", "site"); + `); + await tempPool.query(` + CREATE INDEX IF NOT EXISTS "idx_cadence_site" + ON "commande_cadences" ("site"); + `); + console.log("[DB Init] Table commande_cadences is verified/created."); + await tempPool.end(); console.log("[DB Init] Initialization successful. Exiting."); process.exit(0); diff --git a/src/app/(dashboard)/commandes-auto/cadencier-client.tsx b/src/app/(dashboard)/commandes-auto/cadencier-client.tsx new file mode 100644 index 0000000..0626ea4 --- /dev/null +++ b/src/app/(dashboard)/commandes-auto/cadencier-client.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + AlertTriangle, + CalendarClock, + CheckCircle2, + HelpCircle, + Plus, + Power, + Trash2, +} from "lucide-react"; +import { + upsertCadence, + toggleCadence, + removeCadence, + type CadenceView, + type CadenceStatut, +} from "@/features/commandes-auto/actions"; + +const SITES: { code: string; label: string }[] = [ + { code: "292", label: "292 — Frouard / Nancy" }, + { code: "579", label: "579 — Houdemont" }, +]; + +function fmtDate(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }); +} + +function tempsRestant(jours: number | null): string { + if (jours === null) return "Réception inconnue"; + if (jours === 0) return "Aujourd'hui"; + if (jours < 0) return `En retard de ${Math.abs(jours)} j`; + if (jours === 1) return "Demain"; + return `Dans ${jours} j`; +} + +const STATUT_META: Record = { + a_commander: { label: "À commander", cls: "bg-red-100 text-red-700", Icon: AlertTriangle }, + bientot: { label: "Bientôt", cls: "bg-amber-100 text-amber-700", Icon: CalendarClock }, + ok: { label: "OK", cls: "bg-emerald-100 text-emerald-700", Icon: CheckCircle2 }, + inconnu: { label: "Inconnu", cls: "bg-gray-100 text-gray-500", Icon: HelpCircle }, +}; + +function StatutBadge({ statut }: { statut: CadenceStatut }) { + const { label, cls, Icon } = STATUT_META[statut]; + return ( + + {label} + + ); +} + +/** Tri : à commander d'abord (plus en retard en tête), puis bientôt, ok, inconnu, et inactifs en dernier. */ +function trier(rows: CadenceView[]): CadenceView[] { + const rank: Record = { a_commander: 0, bientot: 1, ok: 2, inconnu: 3 }; + return [...rows].sort((a, b) => { + if (a.actif !== b.actif) return a.actif ? -1 : 1; + if (a.statut !== b.statut) return rank[a.statut] - rank[b.statut]; + const ja = a.joursRestants ?? Number.POSITIVE_INFINITY; + const jb = b.joursRestants ?? Number.POSITIVE_INFINITY; + return ja - jb; + }); +} + +function AddCadenceForm({ + site, + fournisseurs, + existingCodes, + onDone, + pending, +}: { + site: string; + fournisseurs: { code: string; nom: string }[]; + existingCodes: Set; + onDone: (input: { codefou: string; nomfou: string; semaines: number }) => void; + pending: boolean; +}) { + const [value, setValue] = useState(""); + const [semaines, setSemaines] = useState(4); + const [err, setErr] = useState(null); + const listId = `fou-list-${site}`; + + // label affiché dans le datalist → fournisseur + const byLabel = useMemo(() => { + const m = new Map(); + for (const f of fournisseurs) m.set(`${f.nom} — ${f.code}`, f); + return m; + }, [fournisseurs]); + + function submit() { + setErr(null); + const f = byLabel.get(value.trim()); + if (!f) { + setErr("Sélectionnez un fournisseur dans la liste."); + return; + } + if (existingCodes.has(f.code)) { + setErr("Ce fournisseur est déjà cadencé sur ce magasin."); + return; + } + if (!Number.isFinite(semaines) || semaines < 1 || semaines > 104) { + setErr("Intervalle : 1 à 104 semaines."); + return; + } + onDone({ codefou: f.code, nomfou: f.nom, semaines }); + setValue(""); + setSemaines(4); + } + + return ( +
+
+ + setValue(e.target.value)} + placeholder="Rechercher…" + className="min-w-[260px] rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + {fournisseurs.map((f) => ( + +
+
+ + setSemaines(parseInt(e.target.value, 10))} + className="w-28 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {err && {err}} +
+ ); +} + +function SiteSection({ + site, + label, + rows, + fournisseurs, +}: { + site: string; + label: string; + rows: CadenceView[]; + fournisseurs: { code: string; nom: string }[]; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + + const sorted = useMemo(() => trier(rows), [rows]); + const existingCodes = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]); + const nbACommander = rows.filter((r) => r.actif && r.statut === "a_commander").length; + + function run(fn: () => Promise) { + startTransition(async () => { + await fn(); + router.refresh(); + }); + } + + const add = (input: { codefou: string; nomfou: string; semaines: number }) => + run(() => + upsertCadence({ + codefou: input.codefou, + nomfou: input.nomfou, + site, + intervalleSemaines: input.semaines, + actif: true, + }), + ); + + const changeSemaines = (r: CadenceView, semaines: number) => + run(() => + upsertCadence({ + codefou: r.codefou, + nomfou: r.nomfou, + site, + intervalleSemaines: semaines, + actif: r.actif, + }), + ); + + return ( +
+
+

{label}

+ + {rows.length} cadencé{rows.length !== 1 ? "s" : ""} + + {nbACommander > 0 && ( + + {nbACommander} à commander + + )} +
+ + + +
+ + + + + + + + + + + + + + + {sorted.length === 0 && ( + + + + )} + {sorted.map((r) => { + const urgent = r.actif && r.statut === "a_commander"; + return ( + + + + + + + + + + + ); + })} + +
CodeFournisseurToutes lesDernière réceptionProchaine commandeTemps restantStatutActions
+ Aucun fournisseur cadencé sur ce magasin. Ajoutez-en un ci-dessus. +
{r.codefou}{r.nomfou} + + { + const v = parseInt(e.target.value, 10); + if (v && v !== r.intervalleSemaines && v >= 1 && v <= 104) changeSemaines(r, v); + }} + className="w-16 rounded-md border border-gray-200 px-2 py-1 text-center text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + sem. + + {fmtDate(r.derniereReception)}{fmtDate(r.echeance)} + {tempsRestant(r.joursRestants)} + + + +
+ + +
+
+
+
+ ); +} + +export function CadencierClient({ + cadences, + fournisseurs, +}: { + cadences: CadenceView[]; + fournisseurs: { code: string; nom: string }[]; +}) { + const bySite = useMemo(() => { + const m = new Map(); + for (const c of cadences) { + if (!m.has(c.site)) m.set(c.site, []); + m.get(c.site)!.push(c); + } + return m; + }, [cadences]); + + return ( +
+

+ Activez un fournisseur en alerte de commande sur un magasin et définissez sa fréquence + (toutes les X semaines). L'échéance est calculée à partir de la{" "} + dernière réception du fournisseur sur le magasin. +

+ {SITES.map(({ code, label }) => ( + + ))} +
+ ); +} diff --git a/src/app/(dashboard)/commandes-auto/page.tsx b/src/app/(dashboard)/commandes-auto/page.tsx index 44ec19d..2b7ee83 100644 --- a/src/app/(dashboard)/commandes-auto/page.tsx +++ b/src/app/(dashboard)/commandes-auto/page.tsx @@ -1,15 +1,21 @@ import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client"; -import { CommandesAutoClient } from "./client"; +import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions"; +import { CommandesAutoTabs } from "./tabs"; export type { PgCommandeAutoRow }; export default async function CommandesAutoPage() { - const rows = await pgGetCommandesAuto(); + const [rows, cadences, fournisseurs] = await Promise.all([ + pgGetCommandesAuto(), + listCadences(), + getFournisseursPourCadence(), + ]); + return (

Commandes automatiques

- +
); diff --git a/src/app/(dashboard)/commandes-auto/tabs.tsx b/src/app/(dashboard)/commandes-auto/tabs.tsx new file mode 100644 index 0000000..4bb8e84 --- /dev/null +++ b/src/app/(dashboard)/commandes-auto/tabs.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; +import { ShoppingCart, CalendarClock } from "lucide-react"; +import { CommandesAutoClient } from "./client"; +import { CadencierClient } from "./cadencier-client"; +import type { PgCommandeAutoRow } from "./page"; +import type { CadenceView } from "@/features/commandes-auto/actions"; + +type TabKey = "propositions" | "cadencier"; + +export function CommandesAutoTabs({ + rows, + cadences, + fournisseurs, +}: { + rows: PgCommandeAutoRow[]; + cadences: CadenceView[]; + fournisseurs: { code: string; nom: string }[]; +}) { + const [tab, setTab] = useState("propositions"); + + const nbACommander = cadences.filter((c) => c.actif && c.statut === "a_commander").length; + + const tabBtn = (key: TabKey, label: string, Icon: typeof ShoppingCart, badge?: number) => ( + + ); + + return ( +
+
+ {tabBtn("propositions", "Propositions", ShoppingCart)} + {tabBtn("cadencier", "Cadencier / Alertes", CalendarClock, nbACommander)} +
+ + {tab === "propositions" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 3a98813..eb10177 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer } from "drizzle-orm/pg-core"; +import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean } from "drizzle-orm/pg-core"; export const ventesProduits = pgTable("ventes_produits", { id: serial("id").primaryKey(), @@ -77,6 +77,33 @@ export const sessionSnapshots = pgTable("session_snapshots", { createdAt: timestamp("created_at").defaultNow(), }); +/** + * Cadencier / alertes de commande par fournisseur ET par magasin. + * Un fournisseur peut être cadencé indépendamment sur chaque site (292/579) + * avec un intervalle propre (toutes les X semaines). L'échéance est calculée + * à partir de la dernière commande réelle (FF Nancy) + intervalle. + */ +export const commandeCadences = pgTable("commande_cadences", { + id: serial("id").primaryKey(), + /** Code fournisseur (fouident.code) */ + codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(), + /** Nom fournisseur dénormalisé pour l'affichage */ + nomFournisseur: varchar("nom_fournisseur", { length: 255 }), + /** Site / magasin : "292" | "579" */ + site: varchar("site", { length: 20 }).notNull(), + /** Fréquence de commande, en semaines */ + intervalleSemaines: integer("intervalle_semaines").notNull(), + /** Cadence active ou non */ + actif: boolean("actif").default(true).notNull(), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}, (table) => { + return [ + uniqueIndex("uq_cadence_fou_site").on(table.codeFournisseur, table.site), + index("idx_cadence_site").on(table.site), + ]; +}); + /** AI Context rules per supplier (Epic: AI Context) */ export const aiSupplierContext = pgTable("ai_supplier_context", { /** Supplier code serving as the primary key */ diff --git a/src/features/commandes-auto/actions.ts b/src/features/commandes-auto/actions.ts new file mode 100644 index 0000000..06d878b --- /dev/null +++ b/src/features/commandes-auto/actions.ts @@ -0,0 +1,154 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { and, eq } from "drizzle-orm"; +import { db } from "@/db"; +import { commandeCadences } from "@/db/schema"; +import { pgGetFournisseurs, pgGetDerniereReceptionParFournisseur } from "@/lib/pg-ff-client"; + +export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu"; + +export interface CadenceView { + id: number; + codefou: string; + nomfou: string; + site: string; + intervalleSemaines: number; + actif: boolean; + /** Date ISO de la dernière réception, ou null si inconnue */ + derniereReception: string | null; + /** Date ISO d'échéance (dernière réception + X semaines), ou null */ + echeance: string | null; + /** Jours restants avant l'échéance (négatif = en retard), ou null */ + joursRestants: number | null; + statut: CadenceStatut; +} + +const MS_PAR_JOUR = 24 * 60 * 60 * 1000; +/** Seuil (en jours) en dessous duquel une cadence passe en "bientôt à commander" */ +const SEUIL_BIENTOT_JOURS = 7; + +function calcStatut(joursRestants: number | null): CadenceStatut { + if (joursRestants === null) return "inconnu"; + if (joursRestants <= 0) return "a_commander"; + if (joursRestants <= SEUIL_BIENTOT_JOURS) return "bientot"; + return "ok"; +} + +/** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */ +export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> { + return pgGetFournisseurs(); +} + +/** + * Retourne toutes les cadences enrichies du calcul d'échéance à partir + * de la dernière réception (FF Nancy). + */ +export async function listCadences(): Promise { + const [rows, receptions] = await Promise.all([ + db.select().from(commandeCadences), + pgGetDerniereReceptionParFournisseur(), + ]); + + const now = Date.now(); + + return rows.map((r) => { + const receptionIso = receptions.get(`${r.codeFournisseur}|${r.site}`) ?? null; + + let echeanceIso: string | null = null; + let joursRestants: number | null = null; + if (receptionIso) { + const echeance = new Date(receptionIso).getTime() + r.intervalleSemaines * 7 * MS_PAR_JOUR; + echeanceIso = new Date(echeance).toISOString(); + joursRestants = Math.ceil((echeance - now) / MS_PAR_JOUR); + } + + return { + id: r.id, + codefou: r.codeFournisseur, + nomfou: r.nomFournisseur ?? r.codeFournisseur, + site: r.site, + intervalleSemaines: r.intervalleSemaines, + actif: r.actif, + derniereReception: receptionIso, + echeance: echeanceIso, + joursRestants, + statut: r.actif ? calcStatut(joursRestants) : "inconnu", + } satisfies CadenceView; + }); +} + +export interface UpsertCadenceInput { + codefou: string; + nomfou?: string | null; + site: string; + intervalleSemaines: number; + actif?: boolean; +} + +/** Crée ou met à jour la cadence d'un fournisseur sur un site. */ +export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: boolean; error?: string }> { + const codefou = input.codefou?.trim(); + const site = input.site?.trim(); + const intervalle = Math.round(Number(input.intervalleSemaines)); + + if (!codefou || !site) return { ok: false, error: "Fournisseur et magasin requis." }; + if (!Number.isFinite(intervalle) || intervalle < 1 || intervalle > 104) { + return { ok: false, error: "L'intervalle doit être compris entre 1 et 104 semaines." }; + } + + try { + await db + .insert(commandeCadences) + .values({ + codeFournisseur: codefou, + nomFournisseur: input.nomfou ?? null, + site, + intervalleSemaines: intervalle, + actif: input.actif ?? true, + }) + .onConflictDoUpdate({ + target: [commandeCadences.codeFournisseur, commandeCadences.site], + set: { + nomFournisseur: input.nomfou ?? null, + intervalleSemaines: intervalle, + actif: input.actif ?? true, + updatedAt: new Date(), + }, + }); + revalidatePath("/commandes-auto"); + return { ok: true }; + } catch (e) { + console.error("[cadence] upsertCadence error:", (e as Error).message); + return { ok: false, error: "Échec de l'enregistrement." }; + } +} + +/** Active / désactive une cadence sans la supprimer. */ +export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean }> { + try { + await db + .update(commandeCadences) + .set({ actif, updatedAt: new Date() }) + .where(and(eq(commandeCadences.codeFournisseur, codefou), eq(commandeCadences.site, site))); + revalidatePath("/commandes-auto"); + return { ok: true }; + } catch (e) { + console.error("[cadence] toggleCadence error:", (e as Error).message); + return { ok: false }; + } +} + +/** Supprime définitivement une cadence. */ +export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean }> { + try { + await db + .delete(commandeCadences) + .where(and(eq(commandeCadences.codeFournisseur, codefou), eq(commandeCadences.site, site))); + revalidatePath("/commandes-auto"); + return { ok: true }; + } catch (e) { + console.error("[cadence] removeCadence error:", (e as Error).message); + return { ok: false }; + } +} diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index cd23d4e..ee46cdd 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -1211,3 +1211,50 @@ export async function pgGetCommandesAuto(): Promise { return []; } } + +// --------------------------------------------------------------------------- +// 12. Dernière réception par fournisseur ET par site (cadencier) +// --------------------------------------------------------------------------- + +/** + * Retourne la date de la dernière réception (entrée de marchandise) par + * fournisseur et par site, pour piloter le cadencier de commande. + * + * Source : mvtart (genremvt IN (1, 2) = réceptions, voir pgGetMensuelByFournisseur). + * La réception est rattachée à l'article ; on l'attribue au fournisseur + * principal de l'article (artfou1.preference = 1), comme partout ailleurs. + * + * Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur + * (l'UI affichera alors « échéance inconnue »). + */ +export async function pgGetDerniereReceptionParFournisseur(): Promise> { + const map = new Map(); + try { + const result = await pgNoParallel(sql` + SELECT + af.code AS codefou, + m.site AS site, + MAX(m.datmvt) AS derniere + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 + WHERE m.genremvt IN (1, 2) + AND m.site IN ('292', '579') + AND m.datmvt IS NOT NULL + GROUP BY af.code, m.site + `); + + for (const row of result.rows as { codefou: string; site: string; derniere: string | Date }[]) { + if (!row.codefou || !row.derniere) continue; + const iso = row.derniere instanceof Date + ? row.derniere.toISOString() + : new Date(row.derniere).toISOString(); + map.set(`${String(row.codefou).trim()}|${String(row.site ?? "").trim()}`, iso); + } + console.log(`[pg-ff] pgGetDerniereReceptionParFournisseur: ${map.size} entrées (mvtart)`); + return map; + } catch (e) { + console.error("[pg-ff] pgGetDerniereReceptionParFournisseur error:", (e as Error).message?.slice(0, 250)); + return map; + } +}