WFDASH-WEBAPP/src/lib/sops.ts
Dave aed3f5c861 feat: implement discount functionality in POS system
Added support for discounts in the POS checkout process, allowing operators to apply either a fixed amount or a percentage off the gross total. Updated the state management to include discount details and modified the receipt and cart rendering to display pre- and post-discount values. Enhanced the checkout API to handle discount inputs and ensure proper calculations. Updated relevant tests to cover new discount logic.
2026-09-03 12:19:21 +02:00

192 lines
6 KiB
TypeScript

/**
* SOP (Standard Operating Procedure) loader.
*
* Reads markdown files from `docs/sops/` at request time on the server.
* The markdown is the source of truth; the `/sops` route in the app
* just renders it. Edit the .md files, push, redeploy — the in-app
* page updates automatically.
*
* Files in scope:
* docs/sops/<slug>.md
* where <slug> is the kebab-case role name (`quote-operator`, etc).
* `docs/sops/README.md` is intentionally excluded — it lives at the
* repo level and is rendered in the index page directly.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
const SOPS_DIR = path.join(process.cwd(), "docs", "sops");
const README_FILENAME = "README.md";
export type SopRole = {
/** URL slug, e.g. "quote-operator". */
slug: string;
/** Display title from the first `# heading` in the file. */
title: string;
/** One-line "Owns: ..." summary from the file's metadata block. */
owns: string;
/** First paragraph after the title — short blurb for the index card. */
blurb: string;
/** Ordered list of the 4 anatomy section headings, in file order. */
sections: string[];
};
export type SopDoc = {
role: SopRole;
/** Raw markdown body, sans the front-matter block we strip out. */
body: string;
};
/** Read every role SOP file in `docs/sops/` (excludes README). */
export async function listSops(): Promise<SopRole[]> {
let entries: string[];
try {
entries = await fs.readdir(SOPS_DIR);
} catch (err) {
// If the folder doesn't exist (fresh clone before any SOPs are
// written), surface a clean empty list rather than crashing the
// page.
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
throw err;
}
const mdFiles = entries
.filter((name) => name.endsWith(".md") && name !== README_FILENAME)
.sort();
const roles = await Promise.all(
mdFiles.map(async (filename) => {
const slug = filename.replace(/\.md$/, "");
const raw = await fs.readFile(path.join(SOPS_DIR, filename), "utf8");
return parseRole(slug, raw);
}),
);
return roles;
}
/** Read a single SOP by slug. Returns null if the file is missing. */
export async function getSop(slug: string): Promise<SopDoc | null> {
// Defensive: slugs only. No path traversal via ".." etc.
if (!/^[a-z0-9-]+$/.test(slug)) return null;
const filePath = path.join(SOPS_DIR, `${slug}.md`);
let raw: string;
try {
raw = await fs.readFile(filePath, "utf8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
throw err;
}
const role = parseRole(slug, raw);
return { role, body: extractBody(raw) };
}
/** Read every SOP as a full SopDoc (used by the combined print view). */
export async function getAllSops(): Promise<SopDoc[]> {
const roles = await listSops();
const docs = await Promise.all(roles.map((r) => getSop(r.slug)));
return docs.filter((d): d is SopDoc => d !== null);
}
/** Read the repo-level README that describes the SOPs as a set. */
export async function getSopsReadme(): Promise<string | null> {
try {
return await fs.readFile(path.join(SOPS_DIR, README_FILENAME), "utf8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
throw err;
}
}
// --- internals ------------------------------------------------------------
/**
* Parse a role .md file into the metadata used by the index page and
* the detail page header.
*
* Expected shape (written by the maintainer; no front-matter to keep
* the source readable in plain editors):
*
* # <Title> — SOP
*
* **Owns:** <one line>.
* **Tools:** <comma-separated list>.
* **Handoffs:** <bulleted list>.
*
* <one paragraph blurb>
*
* ---
*
* ## 1. <Section A>
* ...
* ## 2. <Section B>
* ...
*/
function parseRole(slug: string, raw: string): SopRole {
const lines = raw.split("\n");
// 1. Title — first H1.
const titleLine = lines.find((l) => l.startsWith("# "));
const title = titleLine
? titleLine.replace(/^#\s+/, "").replace(/\s+—\s*SOP\s*$/, "").trim()
: slug;
// 2. "Owns:" line — first **Owns:** ... line.
const ownsLine = lines.find((l) => l.startsWith("**Owns:**"));
const owns = ownsLine
? ownsLine.replace(/^\*\*Owns:\*\*\s*/, "").replace(/\.?\s*$/, "").trim()
: "";
// 3. Blurb — first non-empty paragraph after the metadata block,
// before the first `---` horizontal rule.
const hrIndex = lines.findIndex((l) => l.trim() === "---");
const metaEnd = hrIndex === -1 ? lines.length : hrIndex;
const metaSlice = lines.slice(0, metaEnd);
// Skip the title line and any "**Foo:** bar" metadata lines.
const blurbLines: string[] = [];
let pastMeta = false;
for (const line of metaSlice) {
if (!pastMeta) {
if (line.startsWith("# ")) continue;
if (line.startsWith("**") && line.includes(":**")) continue;
if (line.trim() === "") continue;
pastMeta = true;
}
if (line.trim() === "") {
if (blurbLines.length > 0) break;
continue;
}
blurbLines.push(line);
}
const blurb = blurbLines.join(" ").trim();
// 4. Section headings — every "## N. <name>" or "## <name>" line.
const sections = lines
.filter((l) => /^##\s+/.test(l))
.map((l) => l.replace(/^##\s+/, "").trim());
return { slug, title, owns, blurb, sections };
}
/**
* Strip the front-matter block (title + **Owns/Tools/Handoffs** +
* blurb + leading `---`) so `react-markdown` doesn't render it twice.
* Keep the body content (sections 1-4 onward) untouched.
*/
function extractBody(raw: string): string {
const lines = raw.split("\n");
const hrIndices = lines
.map((l, i) => ({ l, i }))
.filter(({ l }) => l.trim() === "---")
.map(({ i }) => i);
// The first `---` separates the front matter from the body. If there
// isn't one, return the whole document (caller renders the title
// header from the role metadata instead).
if (hrIndices.length === 0) return raw;
const bodyStart = hrIndices[0] + 1;
return lines.slice(bodyStart).join("\n").trimStart();
}