191 lines
5.6 KiB
JavaScript
191 lines
5.6 KiB
JavaScript
import { useEffect, useState } from "react";
|
||
import { INITIAL_DB } from "../../model/seed.js";
|
||
import { STORAGE_KEY, TODAY } from "../../model/schema.js";
|
||
import { clone } from "../../lib/utils.js";
|
||
import { normalizeDb } from "../../lib/validation.js";
|
||
|
||
function loadDb() {
|
||
if (typeof window === "undefined") return clone(INITIAL_DB);
|
||
try {
|
||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||
if (!raw) return clone(INITIAL_DB);
|
||
return normalizeDb(JSON.parse(raw));
|
||
} catch {
|
||
return clone(INITIAL_DB);
|
||
}
|
||
}
|
||
|
||
export function useSoc2Registry() {
|
||
const [db, setDb] = useState(() => loadDb());
|
||
const [lastSavedAt, setLastSavedAt] = useState(null);
|
||
const [importError, setImportError] = useState("");
|
||
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(db, null, 2));
|
||
setLastSavedAt(new Date().toLocaleString("fr-CA"));
|
||
}, [db]);
|
||
|
||
const updateMilestoneStage = (pillarId, milestoneId, nextStage) => {
|
||
setDb((current) => ({
|
||
...current,
|
||
pillars: current.pillars.map((pillar) =>
|
||
pillar.id !== pillarId
|
||
? pillar
|
||
: {
|
||
...pillar,
|
||
milestones: pillar.milestones.map((milestone) =>
|
||
milestone.id === milestoneId ? { ...milestone, stage: nextStage } : milestone
|
||
),
|
||
}
|
||
),
|
||
}));
|
||
};
|
||
|
||
const updateEvidence = (evidenceId, patch) => {
|
||
setDb((current) => ({
|
||
...current,
|
||
evidence: current.evidence.map((item) =>
|
||
item.id === evidenceId ? { ...item, ...patch, updatedAt: patch.updatedAt || TODAY } : item
|
||
),
|
||
}));
|
||
};
|
||
|
||
const toggleEvidenceMilestoneLink = (evidenceId, pillarId, milestoneId) => {
|
||
setDb((current) => {
|
||
const evidence = current.evidence.find((item) => item.id === evidenceId);
|
||
if (!evidence) return current;
|
||
const hasLink = evidence.linkedMilestones.includes(milestoneId);
|
||
|
||
return {
|
||
...current,
|
||
evidence: current.evidence.map((item) =>
|
||
item.id !== evidenceId
|
||
? item
|
||
: {
|
||
...item,
|
||
linkedMilestones: hasLink
|
||
? item.linkedMilestones.filter((id) => id !== milestoneId)
|
||
: [...item.linkedMilestones, milestoneId],
|
||
updatedAt: TODAY,
|
||
}
|
||
),
|
||
pillars: current.pillars.map((pillar) =>
|
||
pillar.id !== pillarId
|
||
? pillar
|
||
: {
|
||
...pillar,
|
||
milestones: pillar.milestones.map((milestone) => {
|
||
if (milestone.id !== milestoneId) return milestone;
|
||
const alreadyLinked = milestone.evidenceRefs.includes(evidenceId);
|
||
return {
|
||
...milestone,
|
||
evidenceRefs: hasLink
|
||
? milestone.evidenceRefs.filter((id) => id !== evidenceId)
|
||
: alreadyLinked
|
||
? milestone.evidenceRefs
|
||
: [...milestone.evidenceRefs, evidenceId],
|
||
};
|
||
}),
|
||
}
|
||
),
|
||
};
|
||
});
|
||
};
|
||
|
||
const createEvidence = (selectedPillar) => {
|
||
const maxId = db.evidence
|
||
.map((item) => Number(String(item.id).replace("ev-", "")))
|
||
.filter((n) => Number.isFinite(n))
|
||
.reduce((max, n) => Math.max(max, n), 0);
|
||
|
||
const nextId = `ev-${String(maxId + 1).padStart(3, "0")}`;
|
||
const seed = {
|
||
id: nextId,
|
||
title: "Nouvelle preuve",
|
||
category: "General",
|
||
pillar: selectedPillar,
|
||
owner: "Ops",
|
||
freshness: "Brouillon",
|
||
updatedAt: TODAY,
|
||
auditorReady: false,
|
||
visibility: "internal",
|
||
source: "Source à définir",
|
||
tags: [],
|
||
linkedMilestones: [],
|
||
summary: "",
|
||
};
|
||
|
||
setDb((current) => ({ ...current, evidence: [seed, ...current.evidence] }));
|
||
return nextId;
|
||
};
|
||
|
||
const deleteEvidence = (evidenceId) => {
|
||
setDb((current) => ({
|
||
...current,
|
||
evidence: current.evidence.filter((item) => item.id !== evidenceId),
|
||
pillars: current.pillars.map((pillar) => ({
|
||
...pillar,
|
||
milestones: pillar.milestones.map((milestone) => ({
|
||
...milestone,
|
||
evidenceRefs: milestone.evidenceRefs.filter((ref) => ref !== evidenceId),
|
||
})),
|
||
})),
|
||
auditorPacks: current.auditorPacks.map((pack) => ({
|
||
...pack,
|
||
requiredEvidence: pack.requiredEvidence.filter((ref) => ref !== evidenceId),
|
||
})),
|
||
}));
|
||
};
|
||
|
||
const exportDb = () => {
|
||
const blob = new Blob([JSON.stringify(db, null, 2)], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = "chezlepro-soc2-cockpit.json";
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const copyDb = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(JSON.stringify(db, null, 2));
|
||
} catch {
|
||
// clipboard may be unavailable
|
||
}
|
||
};
|
||
|
||
const importDb = (payload) => {
|
||
setImportError("");
|
||
try {
|
||
const parsed = JSON.parse(payload);
|
||
const normalized = normalizeDb(parsed);
|
||
setDb(normalized);
|
||
return { ok: true, data: normalized };
|
||
} catch {
|
||
const error = "Import invalide : le JSON n’a pas pu être interprété.";
|
||
setImportError(error);
|
||
return { ok: false, error };
|
||
}
|
||
};
|
||
|
||
const resetDemo = () => {
|
||
setDb(clone(INITIAL_DB));
|
||
};
|
||
|
||
return {
|
||
db,
|
||
lastSavedAt,
|
||
importError,
|
||
updateMilestoneStage,
|
||
updateEvidence,
|
||
toggleEvidenceMilestoneLink,
|
||
createEvidence,
|
||
deleteEvidence,
|
||
exportDb,
|
||
copyDb,
|
||
importDb,
|
||
resetDemo,
|
||
};
|
||
}
|