mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 05:58:10 +02:00
Merge pull request #1 from R0m1k3/claude/exciting-goldberg-wILkj
feat(commandes-auto): cadencier d'alertes de commande par fournisseur…
This commit is contained in:
7 files changed
+668
-4
No files matched your search
@@ -74,6 +74,28 @@ async function main() {
|
|||||||
`);
|
`);
|
||||||
console.log("[DB Init] Table users is verified/created.");
|
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();
|
await tempPool.end();
|
||||||
console.log("[DB Init] Initialization successful. Exiting.");
|
console.log("[DB Init] Initialization successful. Exiting.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -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<CadenceStatut, { label: string; cls: string; Icon: typeof CheckCircle2 }> = {
|
||||||
|
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 (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${cls}`}>
|
||||||
|
<Icon className="h-3 w-3" /> {label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<CadenceStatut, number> = { 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<string>;
|
||||||
|
onDone: (input: { codefou: string; nomfou: string; semaines: number }) => void;
|
||||||
|
pending: boolean;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [semaines, setSemaines] = useState(4);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const listId = `fou-list-${site}`;
|
||||||
|
|
||||||
|
// label affiché dans le datalist → fournisseur
|
||||||
|
const byLabel = useMemo(() => {
|
||||||
|
const m = new Map<string, { code: string; nom: string }>();
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-3">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<label className="mb-1 text-xs font-medium text-gray-500">Fournisseur</label>
|
||||||
|
<input
|
||||||
|
list={listId}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<datalist id={listId}>
|
||||||
|
{fournisseurs.map((f) => (
|
||||||
|
<option key={f.code} value={`${f.nom} — ${f.code}`} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<label className="mb-1 text-xs font-medium text-gray-500">Toutes les (semaines)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={104}
|
||||||
|
value={semaines}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={pending}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> Activer l'alerte
|
||||||
|
</button>
|
||||||
|
{err && <span className="text-xs font-medium text-red-600">{err}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<unknown>) {
|
||||||
|
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 (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
|
||||||
|
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700">
|
||||||
|
{rows.length} cadencé{rows.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{nbACommander > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-semibold text-red-700">
|
||||||
|
<AlertTriangle className="h-3 w-3" /> {nbACommander} à commander
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddCadenceForm
|
||||||
|
site={site}
|
||||||
|
fournisseurs={fournisseurs}
|
||||||
|
existingCodes={existingCodes}
|
||||||
|
onDone={add}
|
||||||
|
pending={pending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead className="border-b border-gray-200 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Code</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Fournisseur</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-600">Toutes les</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Dernière réception</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Prochaine commande</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Temps restant</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Statut</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-3 py-8 text-center text-gray-400">
|
||||||
|
Aucun fournisseur cadencé sur ce magasin. Ajoutez-en un ci-dessus.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{sorted.map((r) => {
|
||||||
|
const urgent = r.actif && r.statut === "a_commander";
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${r.site}-${r.codefou}`}
|
||||||
|
className={[
|
||||||
|
"transition-colors",
|
||||||
|
!r.actif ? "opacity-50" : "",
|
||||||
|
urgent ? "bg-red-50/60 hover:bg-red-50" : "hover:bg-gray-50",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{r.codefou}</td>
|
||||||
|
<td className="px-3 py-2.5 font-medium text-gray-900">{r.nomfou}</td>
|
||||||
|
<td className="px-3 py-2.5 text-center">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={104}
|
||||||
|
defaultValue={r.intervalleSemaines}
|
||||||
|
onBlur={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-400">sem.</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-gray-600">{fmtDate(r.derniereReception)}</td>
|
||||||
|
<td className="px-3 py-2.5 font-medium text-gray-800">{fmtDate(r.echeance)}</td>
|
||||||
|
<td className={["px-3 py-2.5 font-semibold", urgent ? "text-red-700" : "text-gray-700"].join(" ")}>
|
||||||
|
{tempsRestant(r.joursRestants)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<StatutBadge statut={r.actif ? r.statut : "inconnu"} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={r.actif ? "Désactiver" : "Réactiver"}
|
||||||
|
onClick={() => run(() => toggleCadence(r.codefou, r.site, !r.actif))}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Supprimer"
|
||||||
|
onClick={() => run(() => removeCadence(r.codefou, r.site))}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-md p-1.5 text-gray-500 hover:bg-red-100 hover:text-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CadencierClient({
|
||||||
|
cadences,
|
||||||
|
fournisseurs,
|
||||||
|
}: {
|
||||||
|
cadences: CadenceView[];
|
||||||
|
fournisseurs: { code: string; nom: string }[];
|
||||||
|
}) {
|
||||||
|
const bySite = useMemo(() => {
|
||||||
|
const m = new Map<string, CadenceView[]>();
|
||||||
|
for (const c of cadences) {
|
||||||
|
if (!m.has(c.site)) m.set(c.site, []);
|
||||||
|
m.get(c.site)!.push(c);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [cadences]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<p className="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">
|
||||||
|
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{" "}
|
||||||
|
<strong>dernière réception</strong> du fournisseur sur le magasin.
|
||||||
|
</p>
|
||||||
|
{SITES.map(({ code, label }) => (
|
||||||
|
<SiteSection
|
||||||
|
key={code}
|
||||||
|
site={code}
|
||||||
|
label={label}
|
||||||
|
rows={bySite.get(code) ?? []}
|
||||||
|
fournisseurs={fournisseurs}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client";
|
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 type { PgCommandeAutoRow };
|
||||||
|
|
||||||
export default async function CommandesAutoPage() {
|
export default async function CommandesAutoPage() {
|
||||||
const rows = await pgGetCommandesAuto();
|
const [rows, cadences, fournisseurs] = await Promise.all([
|
||||||
|
pgGetCommandesAuto(),
|
||||||
|
listCadences(),
|
||||||
|
getFournisseursPourCadence(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-6">
|
<div className="min-h-screen bg-gray-50 p-6">
|
||||||
<div className="mx-auto max-w-screen-2xl">
|
<div className="mx-auto max-w-screen-2xl">
|
||||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Commandes automatiques</h1>
|
<h1 className="mb-6 text-3xl font-bold text-gray-900">Commandes automatiques</h1>
|
||||||
<CommandesAutoClient rows={rows} />
|
<CommandesAutoTabs rows={rows} cadences={cadences} fournisseurs={fournisseurs} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<TabKey>("propositions");
|
||||||
|
|
||||||
|
const nbACommander = cadences.filter((c) => c.actif && c.statut === "a_commander").length;
|
||||||
|
|
||||||
|
const tabBtn = (key: TabKey, label: string, Icon: typeof ShoppingCart, badge?: number) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
className={[
|
||||||
|
"inline-flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors",
|
||||||
|
tab === key
|
||||||
|
? "border-blue-600 text-blue-700"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-800",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
{badge != null && badge > 0 && (
|
||||||
|
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-bold text-red-700">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex gap-2 border-b border-gray-200">
|
||||||
|
{tabBtn("propositions", "Propositions", ShoppingCart)}
|
||||||
|
{tabBtn("cadencier", "Cadencier / Alertes", CalendarClock, nbACommander)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "propositions" ? (
|
||||||
|
<CommandesAutoClient rows={rows} />
|
||||||
|
) : (
|
||||||
|
<CadencierClient cadences={cadences} fournisseurs={fournisseurs} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+28
-1
@@ -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", {
|
export const ventesProduits = pgTable("ventes_produits", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
@@ -77,6 +77,33 @@ export const sessionSnapshots = pgTable("session_snapshots", {
|
|||||||
createdAt: timestamp("created_at").defaultNow(),
|
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) */
|
/** AI Context rules per supplier (Epic: AI Context) */
|
||||||
export const aiSupplierContext = pgTable("ai_supplier_context", {
|
export const aiSupplierContext = pgTable("ai_supplier_context", {
|
||||||
/** Supplier code serving as the primary key */
|
/** Supplier code serving as the primary key */
|
||||||
|
|||||||
@@ -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<CadenceView[]> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1211,3 +1211,50 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
|
|||||||
return [];
|
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<Map<string, string>> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user