Build Cobble project structure from the handoff
- Remove the preset document suite (letters, emails, invoices, quotes, PDF generation, line items) with its routes, API handlers and tables - Rebrand to Cobble: site data, Lato, handoff design tokens (light and dark) in main.css, brand assets in public/brand, ember stone logo and regenerated favicons - Add bots, bot_messages, bot_tasks and bot_task_steps tables, regenerate DB types (BUN_SQLITE_COBBLE_*) and add a sample crew seed (seed:bots) - Add the chat shell at /admin/bots: rail with pinned coordinator and user popover, bottom-anchored conversation with composer, working-on panel, and a send-message API scoped to the session user - Add BotStone and Wordmark components, crew status and relative time utils with tests, and update AGENTS.md Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import { StoneEyes } from "@/src/data/stone-geometry";
|
||||
import StoneEyeDot from "./stone-eye-dot";
|
||||
import StoneEyeLidded from "./stone-eye-lidded";
|
||||
import StoneEyeArc from "./stone-eye-arc";
|
||||
import StoneEyeFlat from "./stone-eye-flat";
|
||||
|
||||
type Props = {
|
||||
face: BotFace;
|
||||
/**
|
||||
* Fill class for solid eyes.
|
||||
*/
|
||||
fill_class: string;
|
||||
/**
|
||||
* Stroke class for arc eyes.
|
||||
*/
|
||||
stroke_class: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The face is the status. Geometry follows the handoff face states:
|
||||
* idle dots, working half-lidded, done upward arcs, asleep one flattened
|
||||
* dot, needs-you dot plus arc.
|
||||
*/
|
||||
export default function BotStoneFace({ face, fill_class, stroke_class }: Props) {
|
||||
const { left, right, centre } = StoneEyes;
|
||||
|
||||
switch (face) {
|
||||
case "working":
|
||||
return (
|
||||
<>
|
||||
<StoneEyeLidded {...left} className={fill_class} />
|
||||
<StoneEyeLidded {...right} className={fill_class} />
|
||||
</>
|
||||
);
|
||||
case "done":
|
||||
return (
|
||||
<>
|
||||
<StoneEyeArc {...left} className={stroke_class} />
|
||||
<StoneEyeArc {...right} className={stroke_class} />
|
||||
</>
|
||||
);
|
||||
case "asleep":
|
||||
return <StoneEyeFlat {...centre} className={fill_class} />;
|
||||
case "needs-you":
|
||||
return (
|
||||
<>
|
||||
<StoneEyeDot {...left} className={fill_class} />
|
||||
<StoneEyeArc {...right} className={stroke_class} />
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<StoneEyeDot {...left} className={fill_class} />
|
||||
<StoneEyeDot {...right} className={fill_class} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import { BotHueFillClasses } from "@/src/dict/bot-hue-classes-dict";
|
||||
import {
|
||||
CoordinatorBodyPath,
|
||||
StoneBodyPaths,
|
||||
StoneViewBox,
|
||||
} from "@/src/data/stone-geometry";
|
||||
import BotStoneFace from "./bot-stone-face";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The bot's identity hue. Omit (or `null`) for the coordinator, which
|
||||
* renders hue-less: ink body, paper eyes, inverted in dark mode.
|
||||
*/
|
||||
hue?: BotHue | null;
|
||||
face?: BotFace;
|
||||
size?: number;
|
||||
/**
|
||||
* Accessible name. Leave empty when a visible bot name sits beside the
|
||||
* stone (the default), so the stone is decorative.
|
||||
*/
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Bot Stone
|
||||
* @description A Cobble bot avatar: a stone with exactly one flat cut and two
|
||||
* eyes. Never put it in a circle, ring or badge, or add an outline or shadow.
|
||||
*/
|
||||
export default function BotStone({
|
||||
hue,
|
||||
face = "idle",
|
||||
size = 32,
|
||||
label,
|
||||
className,
|
||||
}: Props) {
|
||||
const is_coordinator = !hue;
|
||||
const body_path = hue ? StoneBodyPaths[hue] : CoordinatorBodyPath;
|
||||
const body_class = hue ? BotHueFillClasses[hue] : "fill-ink";
|
||||
const eye_fill = is_coordinator ? "fill-paper" : "fill-dark";
|
||||
const eye_stroke = is_coordinator ? "stroke-paper" : "stroke-dark";
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={StoneViewBox}
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label || undefined}
|
||||
aria-hidden={label ? undefined : true}
|
||||
className={twMerge("shrink-0", className)}
|
||||
>
|
||||
<path d={body_path} className={body_class} />
|
||||
<BotStoneFace
|
||||
face={face}
|
||||
fill_class={eye_fill}
|
||||
stroke_class={eye_stroke}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
|
||||
import type { TableType } from "@/src/types";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Card from "@moduletrace/twui/elements/Card";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
||||
import H4 from "@moduletrace/twui/layout/H4";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import dayjs from "dayjs";
|
||||
import twuiNumberfy from "@moduletrace/twui/utils/numberfy";
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
|
||||
|
||||
export type CopyableRecord = {
|
||||
id?: number | "";
|
||||
title?: string;
|
||||
subject?: string;
|
||||
};
|
||||
|
||||
type Props<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS> = {
|
||||
table: TableType;
|
||||
title: string;
|
||||
onSelect?: (record: T) => void;
|
||||
onClear?: () => void;
|
||||
};
|
||||
|
||||
export default function CopyFromExistingSelect<
|
||||
T extends BUN_SQLITE_BSQLITE_ALL_TYPEDEFS =
|
||||
BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
|
||||
>({ table, title, onSelect, onClear }: Props<T>) {
|
||||
const { res: records, ready } = useAdminCrudGet<T>({
|
||||
table,
|
||||
});
|
||||
|
||||
const { open, setOpen } = useStatus();
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Button
|
||||
title="Clear Form"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClear?.();
|
||||
}}
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
target={
|
||||
<Button
|
||||
title="Duplicate Existing Record"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Create from an Existing Record
|
||||
</Button>
|
||||
}
|
||||
className="xl:p-6"
|
||||
>
|
||||
<Stack className="gap-6 w-full">
|
||||
<Stack className="gap-1">
|
||||
<H3 className="text-2xl! font-semibold">
|
||||
{title || "Select Record"}
|
||||
</H3>
|
||||
<Span>Select a record to duplicate</Span>
|
||||
</Stack>
|
||||
|
||||
<Stack className="w-full items-stretch">
|
||||
{records?.[0] ? (
|
||||
records?.map((rec, index) => {
|
||||
const date = dayjs(
|
||||
twuiNumberfy(rec.created_at),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
onClick={() => {
|
||||
onSelect?.(rec);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
<Row className="w-full justify-between">
|
||||
<Row>
|
||||
<LucideIcon
|
||||
name="Database"
|
||||
size={17}
|
||||
opacity={0.5}
|
||||
/>
|
||||
<H4 className="text-base! font-semibold">
|
||||
{rec.title}
|
||||
</H4>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Span
|
||||
size="smaller"
|
||||
variant="faded"
|
||||
className="text-nowrap"
|
||||
>
|
||||
{date
|
||||
.toString()
|
||||
.substring(0, 16)}
|
||||
</Span>
|
||||
</Row>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<EmptyContent title="No Records Found!" />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,22 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import BotStone from "./bot-stone";
|
||||
|
||||
type Props = {
|
||||
icon_size?: number;
|
||||
img_props?: Pick<ComponentProps<"svg">, "className">;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Cobble mark: the ember stone. Source of truth is
|
||||
* `public/logos/logo.svg` (copied from `public/brand/icons/ember.svg`).
|
||||
*/
|
||||
export default function LogoIcon({ icon_size = 40, img_props }: Props) {
|
||||
return (
|
||||
<svg
|
||||
width={icon_size}
|
||||
height={icon_size}
|
||||
viewBox="0 0 512 512"
|
||||
role="img"
|
||||
aria-label="Logo Icon"
|
||||
className={twMerge("shrink-0", img_props?.className)}
|
||||
>
|
||||
<rect
|
||||
x="16"
|
||||
y="16"
|
||||
width="480"
|
||||
height="480"
|
||||
rx="120"
|
||||
className="fill-slate-900 dark:fill-slate-800"
|
||||
/>
|
||||
<path
|
||||
d="M290 100 L144 286 H244 L222 412 L368 226 H268 Z"
|
||||
strokeWidth="28"
|
||||
strokeLinejoin="round"
|
||||
className="fill-white stroke-white"
|
||||
/>
|
||||
</svg>
|
||||
<BotStone
|
||||
hue="ember"
|
||||
size={icon_size}
|
||||
label="Logo Icon"
|
||||
className={img_props?.className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { ComponentProps, PropsWithChildren } from "react";
|
||||
import type Span from "@moduletrace/twui/layout/Span";
|
||||
import type { ComponentProps } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import LogoIcon from "./logo-icon";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import Wordmark from "./wordmark";
|
||||
|
||||
type Props = {
|
||||
icon_size?: number;
|
||||
text_wrapper_props?: ComponentProps<typeof Stack>;
|
||||
/**
|
||||
* Only `className` applies: it sets the wordmark colour.
|
||||
*/
|
||||
text_props?: ComponentProps<typeof Span>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Horizontal lockup: the ember stone beside the `cobble` wordmark.
|
||||
*/
|
||||
export default function Logo({
|
||||
icon_size = 36,
|
||||
text_props,
|
||||
@@ -19,38 +25,18 @@ export default function Logo({
|
||||
}: Props) {
|
||||
return (
|
||||
<a href="/" className="logo-link">
|
||||
<Row className="gap-3! flex-nowrap items-center">
|
||||
<Row className="gap-2.5! flex-nowrap items-center">
|
||||
<LogoIcon icon_size={icon_size} />
|
||||
<Stack
|
||||
{...text_wrapper_props}
|
||||
className={twMerge("gap-1!", text_wrapper_props?.className)}
|
||||
className={twMerge("gap-0!", text_wrapper_props?.className)}
|
||||
>
|
||||
<Stack className="gap-0!">
|
||||
<HeroText text_props={text_props}>
|
||||
{SiteData["SiteName"]}
|
||||
</HeroText>
|
||||
</Stack>
|
||||
<Wordmark
|
||||
height={Math.round(icon_size * 0.5)}
|
||||
className={text_props?.className}
|
||||
/>
|
||||
</Stack>
|
||||
</Row>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroText({
|
||||
text_props,
|
||||
children,
|
||||
}: PropsWithChildren & {
|
||||
text_props?: ComponentProps<typeof Span>;
|
||||
}) {
|
||||
return (
|
||||
<Span
|
||||
{...text_props}
|
||||
className={twMerge(
|
||||
"text-[17px] font-semibold tracking-tight text-dark dark:text-white heading leading-[24px] whitespace-nowrap",
|
||||
text_props?.className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Center from "@moduletrace/twui/layout/Center";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
body: any;
|
||||
download_name?: string;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export default function PdfPreview({
|
||||
url,
|
||||
body,
|
||||
download_name,
|
||||
height = 1200,
|
||||
}: Props) {
|
||||
const [blob_url, setBlobUrl] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let object_url: string | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
setBlobUrl(undefined);
|
||||
setError(undefined);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
|
||||
const content_type = res.headers.get("Content-Type") || "";
|
||||
|
||||
if (content_type.includes("application/json")) {
|
||||
const json = await res.json();
|
||||
throw new Error(json.msg || "Couldn't generate PDF");
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
object_url = URL.createObjectURL(blob);
|
||||
setBlobUrl(object_url);
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
setError(error.message || "Couldn't generate PDF preview");
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
if (object_url) {
|
||||
URL.revokeObjectURL(object_url);
|
||||
}
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center className="py-10">
|
||||
<Span className="text-warning">{error}</Span>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!blob_url) {
|
||||
return (
|
||||
<Center className="py-10">
|
||||
<Loading />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2 items-end">
|
||||
{download_name ? (
|
||||
<a
|
||||
href={blob_url}
|
||||
download={download_name}
|
||||
className="text-sm text-link-500 hover:opacity-70 underline"
|
||||
>
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
<iframe
|
||||
src={blob_url}
|
||||
title="PDF Preview"
|
||||
className="w-full rounded-default border border-slate-300 dark:border-white/20 bg-white"
|
||||
style={{ height }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Border from "@moduletrace/twui/elements/Border";
|
||||
import { formatNaira } from "@/src/utils/money";
|
||||
import twuiNumberfy from "@moduletrace/twui/utils/numberfy";
|
||||
|
||||
export type SortableLineItemFormObject = {
|
||||
description?: string;
|
||||
quantity?: string;
|
||||
/** Naira amount as a plain number string (converted to kobo on submit). */
|
||||
unit_price?: string;
|
||||
/** Client-side row identity for stable React keys during reorder. */
|
||||
_key?: string;
|
||||
};
|
||||
|
||||
type Props<T extends SortableLineItemFormObject> = {
|
||||
item: T;
|
||||
index: number;
|
||||
placeholder: string;
|
||||
is_dragging: boolean;
|
||||
is_drop_target: boolean;
|
||||
/** Whether a drag is currently in progress anywhere in the list. */
|
||||
drag_active: boolean;
|
||||
can_move_up: boolean;
|
||||
can_move_down: boolean;
|
||||
onChange: (item: T) => void;
|
||||
onRemove: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onDragStart: () => void;
|
||||
onDragOver: () => void;
|
||||
onDragLeave: () => void;
|
||||
/** `drop_after` is true when dropped on the lower half of this row. */
|
||||
onDrop: (drop_after: boolean) => void;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
|
||||
export default function SortableLineItemRow<
|
||||
T extends SortableLineItemFormObject,
|
||||
>({
|
||||
item,
|
||||
index,
|
||||
placeholder,
|
||||
is_dragging,
|
||||
is_drop_target,
|
||||
drag_active,
|
||||
can_move_up,
|
||||
can_move_down,
|
||||
onChange,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}: Props<T>) {
|
||||
const line_total_kobo =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0) * 100;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className={[
|
||||
"w-full gap-3!",
|
||||
is_dragging && "opacity-60",
|
||||
is_drop_target && "ring-2 ring-primary/70",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onDragOver={(e) => {
|
||||
if (!drag_active) return;
|
||||
e.preventDefault();
|
||||
onDragOver();
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (drag_active) onDragLeave();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!drag_active) return;
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
onDrop(e.clientY - rect.top > rect.height / 2);
|
||||
}}
|
||||
>
|
||||
<Row className="w-full justify-between items-center gap-2!">
|
||||
<Row className="gap-2! items-center">
|
||||
<Span
|
||||
title="Drag to reorder"
|
||||
className="cursor-grab select-none p-1 -m-1!"
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<LucideIcon name="GripVertical" size={16} />
|
||||
</Span>
|
||||
<Span className="text-xs font-semibold opacity-60">
|
||||
Item #{index + 1}
|
||||
</Span>
|
||||
</Row>
|
||||
<Row className="gap-1! items-center">
|
||||
<Button
|
||||
title="Move Up"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
disabled={!can_move_up}
|
||||
onClick={onMoveUp}
|
||||
>
|
||||
<LucideIcon name="ArrowUp" size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
title="Move Down"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
disabled={!can_move_down}
|
||||
onClick={onMoveDown}
|
||||
>
|
||||
<LucideIcon name="ArrowDown" size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
title="Remove Line Item"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="smaller"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<LucideIcon name="Trash2" size={14} />
|
||||
</Button>
|
||||
</Row>
|
||||
</Row>
|
||||
|
||||
<Input
|
||||
title="Description"
|
||||
placeholder={placeholder}
|
||||
defaultValue={item.description}
|
||||
onChange={(e) => {
|
||||
onChange({ ...item, description: e.target.value });
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Row className="w-full grid xl:grid-cols-3 items-stretch">
|
||||
<Input
|
||||
title="Quantity"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={item.quantity}
|
||||
onChange={(e) => {
|
||||
onChange({ ...item, quantity: e.target.value });
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Input
|
||||
title="Unit Price (₦)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
defaultValue={item.unit_price}
|
||||
onChange={(e) => {
|
||||
onChange({
|
||||
...item,
|
||||
unit_price: Math.round(
|
||||
twuiNumberfy(e.target.value),
|
||||
).toString(),
|
||||
});
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Border className="py-1 px-4">
|
||||
<Span className="text-sm font-semibold">
|
||||
{formatNaira(line_total_kobo)}
|
||||
</Span>
|
||||
</Border>
|
||||
</Row>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { useEffect, useState } from "react";
|
||||
import { formatNaira, sumInvoiceItemsKobo } from "@/src/utils/money";
|
||||
import SortableLineItemRow, {
|
||||
type SortableLineItemFormObject,
|
||||
} from "./sortable-line-item-row";
|
||||
|
||||
let row_key_counter = 0;
|
||||
|
||||
export const makeRowKey = (): string =>
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `row-${Date.now()}-${row_key_counter++}`;
|
||||
|
||||
type Props<T extends SortableLineItemFormObject> = {
|
||||
items: T[];
|
||||
empty_item: T;
|
||||
item_placeholder: string;
|
||||
onChange: (items: T[]) => void;
|
||||
};
|
||||
|
||||
export default function SortableLineItemsEditor<
|
||||
T extends SortableLineItemFormObject,
|
||||
>({ items, empty_item, item_placeholder, onChange }: Props<T>) {
|
||||
const [drag_source, set_drag_source] = useState<number | undefined>();
|
||||
const [drag_target, set_drag_target] = useState<number | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (items.some((item) => !item._key)) {
|
||||
onChange(
|
||||
items.map((item) => ({ ...item, _key: makeRowKey() })),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const display_items = items.length ? items : [empty_item];
|
||||
const can_remove = items.length > 1;
|
||||
|
||||
const setItems = (next_items: T[]) => onChange(next_items);
|
||||
|
||||
const endDrag = () => {
|
||||
set_drag_source(undefined);
|
||||
set_drag_target(undefined);
|
||||
};
|
||||
|
||||
const moveTo = (from: number | undefined, target: number, drop_after: boolean) => {
|
||||
if (from === undefined || from === target) return;
|
||||
|
||||
let to = drop_after ? target + 1 : target;
|
||||
if (from < to) to -= 1;
|
||||
if (to === from) return;
|
||||
|
||||
const next_items = items.length ? [...items] : [{ ...empty_item }];
|
||||
const moved_item: T = next_items[from] as T;
|
||||
next_items.splice(from, 1);
|
||||
next_items.splice(to, 0, moved_item);
|
||||
setItems(next_items);
|
||||
};
|
||||
|
||||
const moveBy = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= (items.length ? items.length : 1)) return;
|
||||
moveTo(index, target, delta === 1);
|
||||
};
|
||||
|
||||
const total_kobo = sumInvoiceItemsKobo(
|
||||
items.map((item) => ({
|
||||
quantity: item.quantity,
|
||||
unit_price_kobo: (Number(item.unit_price) || 0) * 100,
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-4">
|
||||
<H3 className="!mb-0">Line Items</H3>
|
||||
|
||||
{display_items.map((item, index) => (
|
||||
<SortableLineItemRow
|
||||
key={item._key || index}
|
||||
item={item}
|
||||
index={index}
|
||||
placeholder={item_placeholder}
|
||||
is_dragging={drag_source === index}
|
||||
is_drop_target={
|
||||
drag_source !== undefined &&
|
||||
drag_source !== index &&
|
||||
drag_target === index
|
||||
}
|
||||
drag_active={drag_source !== undefined}
|
||||
can_move_up={index > 0}
|
||||
can_move_down={index < items.length - 1}
|
||||
onChange={(next_item) => {
|
||||
const next_items = items.length
|
||||
? [...items]
|
||||
: [{ ...empty_item }];
|
||||
next_items[index] = next_item;
|
||||
setItems(next_items);
|
||||
}}
|
||||
onRemove={() => {
|
||||
if (!can_remove) return;
|
||||
setItems(items.filter((_, i) => i !== index));
|
||||
}}
|
||||
onMoveUp={() => moveBy(index, -1)}
|
||||
onMoveDown={() => moveBy(index, 1)}
|
||||
onDragStart={() => {
|
||||
if (items.length < 2 || drag_source !== undefined) return;
|
||||
set_drag_source(index);
|
||||
}}
|
||||
onDragOver={() => {
|
||||
if (
|
||||
drag_source === undefined ||
|
||||
drag_source === index ||
|
||||
drag_target === index
|
||||
) return;
|
||||
set_drag_target(index);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (drag_target === index) {
|
||||
set_drag_target(undefined);
|
||||
}
|
||||
}}
|
||||
onDrop={(drop_after) => {
|
||||
moveTo(drag_source, index, drop_after);
|
||||
endDrag();
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Row className="w-full justify-between items-center">
|
||||
<Button
|
||||
title="Add Line Item"
|
||||
color="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setItems([
|
||||
...items,
|
||||
{ ...empty_item, _key: makeRowKey() },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add Line Item
|
||||
</Button>
|
||||
|
||||
<Span className="text-base font-semibold">
|
||||
Total: {formatNaira(total_kobo)}
|
||||
</Span>
|
||||
</Row>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { StoneEyes } from "@/src/data/stone-geometry";
|
||||
|
||||
type Props = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upward arc eye, drawn as a stroke in the eye colour.
|
||||
*/
|
||||
export default function StoneEyeArc({ cx, cy, className }: Props) {
|
||||
const r = StoneEyes.radius;
|
||||
const base = cy + r * 0.4;
|
||||
|
||||
return (
|
||||
<path
|
||||
d={`M ${cx - r} ${base} A ${r} ${r} 0 0 1 ${cx + r} ${base}`}
|
||||
fill="none"
|
||||
strokeWidth={2.6}
|
||||
strokeLinecap="round"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StoneEyes } from "@/src/data/stone-geometry";
|
||||
|
||||
type Props = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function StoneEyeDot({ cx, cy, className }: Props) {
|
||||
return <circle cx={cx} cy={cy} r={StoneEyes.radius} className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { StoneEyes } from "@/src/data/stone-geometry";
|
||||
|
||||
type Props = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattened dot, used once and centred for the asleep face.
|
||||
*/
|
||||
export default function StoneEyeFlat({ cx, cy, className }: Props) {
|
||||
return (
|
||||
<ellipse
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
rx={StoneEyes.radius + 1}
|
||||
ry={1.8}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { StoneEyes } from "@/src/data/stone-geometry";
|
||||
|
||||
type Props = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Half-lidded eye: the lower half of the dot, flat edge on top.
|
||||
*/
|
||||
export default function StoneEyeLidded({ cx, cy, className }: Props) {
|
||||
const r = StoneEyes.radius;
|
||||
|
||||
return (
|
||||
<path
|
||||
d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 0 ${cx + r} ${cy} Z`}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import { WordmarkPaths, WordmarkViewBox } from "@/src/data/wordmark-paths";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Rendered height in px. The plain wordmark is for 16 to 48 px.
|
||||
*/
|
||||
height?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const [, , VIEWBOX_WIDTH, VIEWBOX_HEIGHT] = WordmarkViewBox.split(" ").map(
|
||||
Number,
|
||||
) as [number, number, number, number];
|
||||
|
||||
/**
|
||||
* # Wordmark
|
||||
* @description The `cobble` wordmark as vector paths, never live text. Takes
|
||||
* the current text colour.
|
||||
*/
|
||||
export default function Wordmark({ height = 15, className }: Props) {
|
||||
const width = (height * VIEWBOX_WIDTH) / VIEWBOX_HEIGHT;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={WordmarkViewBox}
|
||||
role="img"
|
||||
aria-label={SiteData["SiteName"]}
|
||||
className={twMerge("shrink-0 fill-current text-ink", className)}
|
||||
>
|
||||
{WordmarkPaths.map((d) => (
|
||||
<WordmarkPath key={d.slice(0, 32)} d={d} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WordmarkPath({ d }: { d: string }) {
|
||||
return <path d={d} />;
|
||||
}
|
||||
Reference in New Issue
Block a user