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.
192 lines
6 KiB
TypeScript
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();
|
|
}
|