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:
2026-10-18 17:10:36 +01:00
co-authored by Claude Opus 5.5
parent 81e4a8aecc
commit 3286b80029
340 changed files with 3161 additions and 12060 deletions
+10 -1
View File
@@ -12,6 +12,7 @@ import { SiteData } from "@/src/data/site-data";
import Layout from "../layouts/main";
import AdminLayout from "../layouts/admin";
import AuthLayout from "../layouts/auth";
import BotsLayout from "../layouts/bots";
import ThemeInitScript from "../components/general/theme-init-script";
export const AppContext = React.createContext<AppContextObject>(
@@ -46,6 +47,14 @@ export default function App({
query,
};
if (url?.pathname.startsWith("/admin/bots")) {
return (
<AppContext.Provider value={appContextObject}>
<BotsLayout>{children}</BotsLayout>
</AppContext.Provider>
);
}
if (url?.pathname.startsWith("/admin")) {
return (
<AppContext.Provider value={appContextObject}>
@@ -94,7 +103,7 @@ export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
crossOrigin=""
/>
<link
href="https://fonts.googleapis.com/css2?family=Geist:[email protected]&family=Geist+Mono:wght@400..600&display=swap"
href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700;900&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/favicon.ico" sizes="16x16 24x24 32x32 48x48" />
+5 -30
View File
@@ -1,11 +1,4 @@
import {
FileText,
Mail,
Receipt,
SquarePen,
Users,
type LucideIcon,
} from "lucide-react";
import { Bot, Users, type LucideIcon } from "lucide-react";
export type DashboardShortcut = {
title: string;
@@ -16,28 +9,10 @@ export type DashboardShortcut = {
export const DashboardShortcuts: DashboardShortcut[] = [
{
title: "Invoices",
description: "Bill clients, track payment status and export PDFs.",
url: "/admin/invoices",
Icon: Receipt,
},
{
title: "Quotes",
description: "Draft priced proposals and convert them to invoices.",
url: "/admin/quotes",
Icon: SquarePen,
},
{
title: "Letters",
description: "Compose letterhead documents ready to print or send.",
url: "/admin/letters",
Icon: FileText,
},
{
title: "Emails",
description: "Write, attach and send branded email from one place.",
url: "/admin/emails",
Icon: Mail,
title: "Bots",
description: "Talk to your crew, see what they are working on and answer what needs you.",
url: "/admin/bots",
Icon: Bot,
},
{
title: "People",
@@ -0,0 +1,18 @@
import Center from "@moduletrace/twui/layout/Center";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import BotStone from "@/src/components/general/bot-stone";
export default function EmptyCrew() {
return (
<Center className="grow h-full">
<Stack className="items-center gap-3!">
<BotStone face="asleep" size={64} />
<Span className="text-sm font-bold text-ink">No bots yet</Span>
<Span className="text-sm text-ink-secondary">
Add a bot and give it a job.
</Span>
</Stack>
</Center>
);
}
@@ -0,0 +1,17 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { SendBotMessageRequest } from "@/src/schemas/bot-schema";
export default async function sendBotMessage({
bot_id,
text,
}: SendBotMessageRequest) {
return await fetchApi<
SendBotMessageRequest,
APIResponseObject<BUN_SQLITE_COBBLE_BOT_MESSAGES>
>(`/api/admin/bots/send-message`, {
method: "POST",
body: { bot_id, text },
});
}
@@ -0,0 +1,39 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import BotStone from "@/src/components/general/bot-stone";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import type { MessageGroup } from "../(utils)/group-messages";
import BotMessageLine from "./bot-message-line";
import GroupTimestamp from "./group-timestamp";
type Props = {
bot: BotWithLastMessage;
group: MessageGroup;
};
/**
* Plain text, no bubble. A 28px stone sits on the group's first line only.
*/
export default function BotMessageGroup({ bot, group }: Props) {
return (
<Row className="w-full gap-3! flex-nowrap items-start">
<BotStone
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={28}
className="-mt-1"
/>
<Stack className="min-w-0 grow gap-1! items-start">
{group.messages.map((message) => (
<BotMessageLine
key={message.id}
text={message.text || ""}
needs_you={Boolean(message.needs_you)}
/>
))}
<GroupTimestamp at={group.at} />
</Stack>
</Row>
);
}
@@ -0,0 +1,19 @@
import Stack from "@moduletrace/twui/layout/Stack";
import P from "@moduletrace/twui/layout/P";
import NeedsYouChip from "./needs-you-chip";
type Props = {
text: string;
needs_you?: boolean;
};
export default function BotMessageLine({ text, needs_you }: Props) {
return (
<Stack className="gap-1.5! items-start max-w-full">
<P noMargin className="text-sm leading-5 text-ink whitespace-pre-wrap break-words">
{text}
</P>
{needs_you ? <NeedsYouChip /> : null}
</Stack>
);
}
@@ -0,0 +1,19 @@
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
/**
* Attachments are not designed yet; the control is present but inert.
*/
export default function ComposerAttachButton() {
return (
<Button
title="Attachments are not available yet"
variant="ghost"
color="text"
aria-disabled="true"
className="shrink-0 p-1! -m-1 text-ink dark:text-ink cursor-not-allowed rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
>
<Plus size={16} aria-hidden />
</Button>
);
}
@@ -0,0 +1,26 @@
import Button from "@moduletrace/twui/layout/Button";
import { ArrowUp } from "lucide-react";
type Props = {
disabled?: boolean;
loading?: boolean;
onClick: () => void;
};
/**
* 32px ink send button with a paper arrow.
*/
export default function ComposerSendButton({ disabled, loading, onClick }: Props) {
return (
<Button
title="Send"
disabled={disabled}
loading={loading}
loadingIconSize="smaller"
onClick={onClick}
className="shrink-0 size-8 p-0! rounded-default focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<ArrowUp size={16} aria-hidden />
</Button>
);
}
@@ -0,0 +1,75 @@
import { useState, type KeyboardEvent } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Textarea from "@moduletrace/twui/form/Textarea";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import sendBotMessage from "../(functions)/send-bot-message";
import ComposerAttachButton from "./composer-attach-button";
import ComposerSendButton from "./composer-send-button";
type Props = {
bot: BotWithLastMessage;
onSent: (params: { message: BUN_SQLITE_COBBLE_BOT_MESSAGES }) => void;
};
/**
* Enter sends, Shift+Enter adds a line. Grows to five lines, then scrolls.
* Its top rule lines up with the rail's user-row rule.
*/
export default function Composer({ bot, onSent }: Props) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
const can_send = Boolean(text.trim()) && !sending;
async function send() {
if (!can_send || !bot.id) return;
setSending(true);
setError(undefined);
const res = await sendBotMessage({ bot_id: bot.id, text });
setSending(false);
if (!res.success || !res.singleRes) {
setError(res.msg || "Message not sent");
return;
}
setText("");
onSent({ message: res.singleRes });
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
send();
}
}
return (
<Row className="h-[88px] shrink-0 gap-4! flex-nowrap items-center px-6 border-0 border-t border-solid border-hairline">
<ComposerAttachButton />
<Textarea
name="message"
aria-label={`Message ${bot.name}`}
placeholder={`Message ${bot.name}`}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
aria-invalid={error ? true : undefined}
title={error}
className="field-sizing-content max-h-[100px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
wrapperWrapperProps={{ className: "grow min-w-0" }}
wrapperProps={{
className:
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
}}
/>
<ComposerSendButton disabled={!can_send} loading={sending} onClick={send} />
</Row>
);
}
@@ -0,0 +1,41 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span";
import BotStone from "@/src/components/general/bot-stone";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import { crewStatusSentence } from "@/src/utils/crew-status";
type Props = {
bot: BotWithLastMessage;
bots: BotWithLastMessage[];
};
/**
* 64px header. The coordinator's status is the crew sentence.
*/
export default function ConversationHeader({ bot, bots }: Props) {
const status =
bot.kind === "coordinator"
? crewStatusSentence({ bots })
: bot.status_line || "";
return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
<BotStone
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={40}
/>
<Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate">
{bot.name}
</H1>
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate">
{status}
</Span>
</Stack>
</Row>
);
}
@@ -0,0 +1,10 @@
import Span from "@moduletrace/twui/layout/Span";
import relativeTime from "@/src/utils/relative-time";
export default function GroupTimestamp({ at }: { at: number }) {
return (
<Span className="text-[11px] leading-4 text-ink-secondary">
{relativeTime({ at })}
</Span>
);
}
@@ -0,0 +1,17 @@
import type { BotWithLastMessage } from "@/src/types";
import type { MessageGroup } from "../(utils)/group-messages";
import UserMessageGroup from "./user-message-group";
import BotMessageGroup from "./bot-message-group";
type Props = {
bot: BotWithLastMessage;
group: MessageGroup;
};
export default function MessageGroupBlock({ bot, group }: Props) {
if (group.author === "user") {
return <UserMessageGroup group={group} />;
}
return <BotMessageGroup bot={bot} group={group} />;
}
@@ -0,0 +1,33 @@
import Stack from "@moduletrace/twui/layout/Stack";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import groupMessages from "../(utils)/group-messages";
import MessageGroupBlock from "./message-group-block";
type Props = {
bot: BotWithLastMessage;
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
};
/**
* Bottom-anchored: the stack sits 24px above the composer rule and grows
* upward. Empty space belongs at the top. New messages announce politely.
*/
export default function MessageThread({ bot, messages }: Props) {
const groups = groupMessages({ messages });
return (
<Stack className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
<Stack
role="log"
aria-live="polite"
aria-label={`Conversation with ${bot.name}`}
className="w-full gap-4! items-stretch px-6 pt-6 pb-6"
>
{groups.map((group) => (
<MessageGroupBlock key={group.key} bot={bot} group={group} />
))}
</Stack>
</Stack>
);
}
@@ -0,0 +1,12 @@
import Span from "@moduletrace/twui/layout/Span";
/**
* Belongs to the message, never the avatar.
*/
export default function NeedsYouChip() {
return (
<Span className="inline-flex h-5 items-center px-3 rounded-default-xs bg-sunken text-[11px] font-bold leading-4 text-ink">
Needs you
</Span>
);
}
@@ -0,0 +1,19 @@
import Button from "@moduletrace/twui/layout/Button";
/**
* Opens the machine the bot is working on. Disabled until the task has one.
*/
export default function OpenComputerButton({ url }: { url?: string | null }) {
return (
<Button
title="Open computer"
color="gray"
href={url || undefined}
target="_blank"
disabled={!url}
className="w-full h-9 rounded-default text-[13px] font-bold bg-surface dark:bg-surface hover:bg-sunken hover:dark:bg-sunken text-ink dark:text-ink border border-solid border-hairline-strong transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
Open computer
</Button>
);
}
@@ -0,0 +1,28 @@
import Span from "@moduletrace/twui/layout/Span";
import { Check } from "lucide-react";
/**
* 14px step mark. Pending has no mark; its label is muted.
*/
export default function TaskStepMark({ state }: { state: string }) {
if (state === "done") {
return (
<Check
size={14}
strokeWidth={1.6 * (24 / 14)}
className="shrink-0 text-ink"
aria-label="Done"
/>
);
}
if (state === "active") {
return (
<Span className="size-3.5 shrink-0 flex items-center justify-center" aria-label="In progress" role="img">
<Span className="size-1.5 rounded-full bg-ink" />
</Span>
);
}
return <Span className="size-3.5 shrink-0" aria-hidden />;
}
@@ -0,0 +1,34 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import TaskStepMark from "./task-step-mark";
type Props = {
label: string;
state: string;
};
/**
* 28px pitch. Done is a tick, active a dot with a bold label.
*/
export default function TaskStepRow({ label, state }: Props) {
const is_active = state === "active";
return (
<Row
className="h-7 gap-2.5! flex-nowrap items-center"
aria-current={is_active ? "step" : undefined}
>
<TaskStepMark state={state} />
<Span
className={twMerge(
"text-[13px] leading-5 truncate",
is_active ? "font-bold text-ink" : "text-ink-secondary",
state === "pending" && "text-ink-disabled",
)}
>
{label}
</Span>
</Row>
);
}
@@ -0,0 +1,12 @@
import P from "@moduletrace/twui/layout/P";
/**
* Ink bubble with paper text; inverts in dark mode.
*/
export default function UserBubble({ text }: { text: string }) {
return (
<P noMargin className="max-w-[80%] rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 whitespace-pre-wrap break-words">
{text}
</P>
);
}
@@ -0,0 +1,18 @@
import Stack from "@moduletrace/twui/layout/Stack";
import type { MessageGroup } from "../(utils)/group-messages";
import UserBubble from "./user-bubble";
import GroupTimestamp from "./group-timestamp";
/**
* Right-aligned ink bubbles, one timestamp under the group.
*/
export default function UserMessageGroup({ group }: { group: MessageGroup }) {
return (
<Stack className="w-full gap-1! items-end">
{group.messages.map((message) => (
<UserBubble key={message.id} text={message.text || ""} />
))}
<GroupTimestamp at={group.at} />
</Stack>
);
}
@@ -0,0 +1,34 @@
import { useContext, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import { AppContext } from "@/src/pages/__root";
import ConversationHeader from "../(partials)/conversation-header";
import MessageThread from "../(partials)/message-thread";
import Composer from "../(partials)/composer";
/**
* The 900px conversation column: header, bottom-anchored thread, composer.
*/
export default function Conversation() {
const { pageProps } = useContext(AppContext);
const bot = pageProps?.bot;
const [messages, setMessages] = useState<BUN_SQLITE_COBBLE_BOT_MESSAGES[]>(
pageProps?.bot_messages || [],
);
if (!bot?.id) return null;
return (
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<ConversationHeader bot={bot} bots={pageProps?.bots || []} />
<MessageThread bot={bot} messages={messages} />
<Composer
bot={bot}
onSent={({ message }) =>
setMessages((prev) => [...prev, message])
}
/>
</Stack>
);
}
@@ -0,0 +1,49 @@
import { useContext } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import H2 from "@moduletrace/twui/layout/H2";
import { AppContext } from "@/src/pages/__root";
import TaskStepRow from "../(partials)/task-step-row";
import OpenComputerButton from "../(partials)/open-computer-button";
/**
* 280px panel listing the steps the bot is taking. Hidden below 1280px.
*/
export default function WorkingOnPanel() {
const { pageProps } = useContext(AppContext);
const task = pageProps?.bot_task;
return (
<Stack
className="hidden xl:flex w-[280px] shrink-0 h-full gap-0! items-stretch px-6 border-0 border-l border-solid border-hairline"
aria-label="Working on"
>
<Span className="pt-[34px] text-xs font-bold leading-4 tracking-[0.06em] uppercase text-ink-secondary">
Working on
</Span>
{task ? (
<>
<H2 className="mt-2.5 text-sm! leading-5! tracking-normal! font-bold text-ink">
{task.title}
</H2>
<Stack className="mt-4 gap-0! items-stretch">
{task.steps.map((step) => (
<TaskStepRow
key={step.id}
label={step.label || ""}
state={step.state || "pending"}
/>
))}
</Stack>
</>
) : (
<Span className="mt-2.5 text-[13px] leading-5 text-ink-secondary">
Nothing in progress.
</Span>
)}
<Stack className="mt-auto h-[88px] shrink-0 gap-0! items-stretch justify-center">
{task ? <OpenComputerButton url={task.computer_url} /> : null}
</Stack>
</Stack>
);
}
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import groupMessages from "./group-messages";
const MIN = 60 * 1000;
describe("groupMessages", () => {
test("groups consecutive messages by author", () => {
const groups = groupMessages({
messages: [
{ id: 1, author: "user", text: "a", created_at: 0 },
{ id: 2, author: "bot", text: "b", created_at: MIN },
{ id: 3, author: "bot", text: "c", created_at: 2 * MIN },
{ id: 4, author: "user", text: "d", created_at: 3 * MIN },
],
});
expect(groups.map((g) => [g.author, g.messages.length])).toEqual([
["user", 1],
["bot", 2],
["user", 1],
]);
expect(groups[1]!.at).toBe(2 * MIN);
});
test("splits a long pause from the same author", () => {
const groups = groupMessages({
messages: [
{ id: 1, author: "bot", text: "a", created_at: 0 },
{ id: 2, author: "bot", text: "b", created_at: 10 * MIN },
],
});
expect(groups).toHaveLength(2);
});
});
@@ -0,0 +1,50 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
export type MessageGroup = {
key: string;
author: "bot" | "user";
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
/**
* Time of the group's last message, shown once per group.
*/
at: number;
};
type Params = {
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
/**
* Consecutive messages from one author within this gap share a group.
*/
gap_ms?: number;
};
/**
* Splits a conversation (oldest first) into author groups.
*/
export default function groupMessages({
messages,
gap_ms = 5 * 60 * 1000,
}: Params): MessageGroup[] {
const groups: MessageGroup[] = [];
for (const message of messages) {
const author = message.author === "user" ? "user" : "bot";
const at = Number(message.created_at || 0);
const last = groups[groups.length - 1];
if (last && last.author === author && at - last.at <= gap_ms) {
last.messages.push(message);
last.at = at;
continue;
}
groups.push({
key: String(message.id),
author,
messages: [message],
at,
});
}
return groups;
}
@@ -0,0 +1,50 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query;
const { user } = await userAuth({ req: params.req });
if (!user?.id) {
return {
redirect: {
destination: "/auth/login",
},
};
}
const bots = await grabBots({ user_id: user.id });
const bot = bots.find((b) => String(b.id) === String(query.bot_id));
if (!bot?.id) {
return {
redirect: {
destination: "/admin/bots",
},
};
}
if (bot.unread) {
await markBotRead({ bot_id: bot.id, user_id: user.id });
bot.unread = 0;
}
const [bot_messages, bot_task] = await Promise.all([
grabBotMessages({ bot_id: bot.id }),
grabActiveBotTask({ bot_id: bot.id }),
]);
return {
props: {
bots,
bot,
bot_messages,
bot_task,
},
};
};
+23
View File
@@ -0,0 +1,23 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import Conversation from "./(sections)/conversation";
import WorkingOnPanel from "./(sections)/working-on-panel";
export default function BotConversationPage() {
return (
<>
<Conversation />
<WorkingOnPanel />
</>
);
}
export const meta: BunextPageModuleMetaFn = async (params) => {
const bot = (params.serverRes?.props as PagePropsType | undefined)?.bot;
return {
title: `${bot?.name || "Bots"} | ${SiteData["SiteName"]}`,
description: bot?.role || `Your crew of bots.`,
};
};
+37
View File
@@ -0,0 +1,37 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* Opens the coordinator's conversation. With no crew yet, renders the empty
* shell.
*/
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const { user } = await userAuth({ req });
if (!user?.id) {
return {
redirect: {
destination: "/auth/login",
},
};
}
const bots = await grabBots({ user_id: user.id });
const first_bot = bots[0];
if (first_bot?.id) {
return {
redirect: {
destination: `/admin/bots/${first_bot.id}`,
},
};
}
return {
props: {
bots,
},
};
};
+12
View File
@@ -0,0 +1,12 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import EmptyCrew from "./(sections)/empty-crew";
export default function BotsPage() {
return <EmptyCrew />;
}
export const meta: BunextPageModuleMeta = {
title: `Bots | ${SiteData["SiteName"]}`,
description: `Your crew of bots.`,
};
@@ -1,48 +0,0 @@
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
import type { TWUIStatusType } from "@moduletrace/twui/hooks/useStatus";
import { sendEmailRecord } from "@/src/functions/frontend/document-actions";
import type { Dispatch, SetStateAction } from "react";
type Params = {
email: BUN_SQLITE_BSQLITE_EMAILS;
setLoading: Dispatch<SetStateAction<boolean>>;
setStatus?: Dispatch<SetStateAction<TWUIStatusType | undefined>>;
refresh?: () => void;
};
export default async function clientSendEmail({
email,
setLoading,
setStatus,
refresh,
}: Params) {
setStatus?.(undefined);
if (!window.confirm(`Send this email now?`)) {
return;
}
setLoading(true);
const res = await sendEmailRecord({
url: "/api/admin/emails/send-email",
body: {
email_id: email.id,
},
});
if (res.success) {
setStatus?.({
error: false,
success: true,
msg: res.msg || "Email sent",
});
refresh?.();
} else {
setStatus?.({
error: true,
msg: res.msg || "Couldn't send email",
});
setLoading(false);
}
}
@@ -1,36 +0,0 @@
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
type Props = {};
export default function useEmailsListInit(_props?: Props) {
const {
res: emails,
setRes,
count,
page,
setPage,
query,
setQuery,
limit,
setLimit,
ready,
setReady,
} = useAdminCrudGet<BUN_SQLITE_BSQLITE_EMAILS>({
table: "emails",
});
return {
emails,
setRes,
count,
page,
setPage,
query,
setQuery,
limit,
setLimit,
ready,
setReady,
};
}
@@ -1,102 +0,0 @@
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
import Tag from "@moduletrace/twui/elements/Tag";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import { sendEmailRecord } from "@/src/functions/frontend/document-actions";
import clientSendEmail from "../(functions)/client-send-email";
import { AppContext } from "@/src/pages/__root";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import { useContext } from "react";
type Props = {
email: BUN_SQLITE_BSQLITE_EMAILS;
refresh?: () => void;
is_single_email_page?: boolean;
};
export default function EmailActions({
email,
refresh,
is_single_email_page,
}: Props) {
const { pageProps } = useContext(AppContext);
const is_read_only = isReadOnlyUser({
user_types: pageProps?.user_types,
});
const { loading, setLoading, status, setStatus } = useStatus();
const duplicate_url = `/admin/emails/create?from=${email.id}`;
const view_url = `/admin/emails/${email.id}`;
const edit_url = `/admin/emails/${email.id}/edit`;
return (
<>
<Row className="w-full justify-end gap-2!">
{is_single_email_page ? null : (
<Button
title="View Email"
variant="ghost"
color="gray"
href={view_url}
>
<LucideIcon name="Eye" size={16} />
</Button>
)}
{!is_read_only && email.status != "sent" ? (
<Button
title="Send Email"
variant="ghost"
color="secondary"
loading={loading}
onClick={() => {
clientSendEmail({
email,
setLoading,
refresh,
setStatus,
});
}}
>
<LucideIcon name="Send" size={16} />
</Button>
) : null}
{is_read_only ? null : (
<>
<Button
title="Create a New Email from this Record"
variant="ghost"
color="gray"
href={duplicate_url}
>
<LucideIcon name="Copy" size={16} />
</Button>
<Button
title="Edit Email"
variant="ghost"
color="gray"
href={edit_url}
>
<LucideIcon name="Edit2" size={16} />
</Button>
</>
)}
</Row>
{status?.error && (
<Row className="w-full justify-end">
<Tag color="error">{status.msg}</Tag>
</Row>
)}
{status?.success && !loading && (
<Row className="w-full justify-end">
<Tag color="success">{status.msg}</Tag>
</Row>
)}
</>
);
}
@@ -1,56 +0,0 @@
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
import Card from "@moduletrace/twui/elements/Card";
import Tag from "@moduletrace/twui/elements/Tag";
import H3 from "@moduletrace/twui/layout/H3";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import dayjs from "dayjs";
import EmailActions from "./email-actions";
import Link from "@moduletrace/twui/layout/Link";
type Props = {
email: BUN_SQLITE_BSQLITE_EMAILS;
refresh: () => void;
};
export default function EmailCard({ email, refresh }: Props) {
const status_title = email.status == "sent" ? "Sent" : "Draft";
const date_created = dayjs(Number(email.sent_at || email.created_at));
const view_url = `/admin/emails/${email.id}`;
const edit_url = `/admin/emails/${email.id}/edit`;
return (
<Card>
<Link href={view_url} className="text-foreground-light! dark:text-foreground-dark!">
<Row className="w-full justify-between items-start">
<Stack className="gap-2">
<H3>
{email.title || email.subject || "Untitled Email"}
</H3>
<Row className="gap-2!">
<Tag
color={
email.status == "sent"
? "success"
: "secondary"
}
>
{status_title}
</Tag>
{email.subject ? (
<Tag color="gray">{email.subject}</Tag>
) : null}
{email.to_emails ? (
<Tag color="gray">{email.to_emails}</Tag>
) : null}
<Tag color="gray">{date_created.toString()}</Tag>
</Row>
</Stack>
</Row>
</Link>
<EmailActions email={email} refresh={refresh} />
</Card>
);
}
@@ -1,42 +0,0 @@
import Loading from "@moduletrace/twui/elements/Loading";
import Paper from "@moduletrace/twui/elements/Paper";
import Center from "@moduletrace/twui/layout/Center";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import useEmailsListInit from "../(hooks)/use-emails-list-init";
import EmailCard from "./email-card";
export default function EmailsList() {
const init = useEmailsListInit({});
const { emails, setRes } = init;
return (
<Stack className="w-full items-stretch">
{!emails ? (
<Paper>
<Center className="p-20">
<Loading />
</Center>
</Paper>
) : !emails[0] ? (
<Paper>
<Center className="p-20">
<Span>No Emails Found!</Span>
</Center>
</Paper>
) : (
emails.map((email, index) => {
return (
<EmailCard
email={email}
key={index}
refresh={() => {
setRes(undefined);
}}
/>
);
})
)}
</Stack>
);
}
@@ -1,13 +0,0 @@
import Section from "@moduletrace/twui/layout/Section";
import Stack from "@moduletrace/twui/layout/Stack";
import EmailsList from "../(partials)/emails-list";
export default function EmailsListSection() {
return (
<Section>
<Stack className="w-full items-stretch">
<EmailsList />
</Stack>
</Section>
);
}
@@ -1,36 +0,0 @@
import AdminHero from "@/src/components/general/admin-hero";
import Button from "@moduletrace/twui/layout/Button";
import { AppContext } from "@/src/pages/__root";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import { useContext } from "react";
export default function Hero() {
const { pageProps } = useContext(AppContext);
const is_read_only = isReadOnlyUser({ user_types: pageProps?.user_types });
return (
<AdminHero
title="Emails"
description="Compose email HTML and send to recipients - with optional letter, invoice and file attachments"
buttons={
<>
{is_read_only ? null : (
<Button
title="Create New Email"
href="/admin/emails/create"
>
Create New Email
</Button>
)}
<Button
variant="outlined"
title="View Email Template"
href="/admin/emails/template"
>
View Email Template
</Button>
</>
}
/>
);
}
@@ -1,76 +0,0 @@
import type { EmailAttachmentRecord } from "@/src/functions/backend/db/documents/grab-email-attachments";
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import Row from "@moduletrace/twui/layout/Row";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
type Props = {
attachment: EmailAttachmentRecord;
};
export default function EmailAttachmentRow({ attachment }: Props) {
if (attachment.type == "letter" && attachment.letter_id) {
return (
<Link
href={`/admin/letters/${attachment.letter_id}`}
className="text-sm"
target="_blank"
title={attachment.letter_title}
>
<Row className="gap-2!">
<LucideIcon name="FileBox" size={15} />
<Span className="text-sm">
{attachment.letter_title ||
`Letter #${attachment.letter_id}`}
</Span>
</Row>
</Link>
);
}
if (attachment.type == "invoice" && attachment.invoice_id) {
return (
<Link
href={`/admin/invoices/${attachment.invoice_id}`}
target="_blank"
className="text-sm"
title={attachment.invoice_title}
>
<Row className="gap-2!">
<LucideIcon name="Banknote" size={15} />
<Span className="text-sm">
{attachment.invoice_title ||
attachment.invoice_number ||
`Invoice #${attachment.invoice_id}`}
</Span>
</Row>
</Link>
);
}
if (attachment.type == "quote" && attachment.quote_id) {
return (
<Link
href={`/admin/quotes/${attachment.quote_id}`}
target="_blank"
className="text-sm"
title={attachment.quote_title}
>
<Row className="gap-2!">
<LucideIcon name="Calculator" size={15} />
<Span className="text-sm">
{attachment.quote_title ||
attachment.quote_number ||
`Quote #${attachment.quote_id}`}
</Span>
</Row>
</Link>
);
}
return (
<Span className="text-sm">
{attachment.file_name || `File #${attachment.media_id}`}
</Span>
);
}
@@ -1,90 +0,0 @@
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
import Tag from "@moduletrace/twui/elements/Tag";
import Divider from "@moduletrace/twui/layout/Divider";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { EmailAttachmentRecord } from "@/src/functions/backend/db/documents/grab-email-attachments";
import dayjs from "dayjs";
import EmailAttachmentRow from "./email-attachment-row";
type Props = {
email: BUN_SQLITE_BSQLITE_EMAILS;
attachments?: EmailAttachmentRecord[] | null;
};
export default function EmailDetails({
email,
attachments,
}: Props) {
const status_title = email.status == "sent" ? "Sent" : "Draft";
const date_created = dayjs(Number(email.created_at)).toString();
const sent_date = email.sent_at
? dayjs(Number(email.sent_at)).toString()
: null;
return (
<Stack className="w-full gap-4">
<Row className="w-full gap-2!">
<Tag
color={
email.status == "sent" ? "success" : "secondary"
}
>
{status_title}
</Tag>
</Row>
<Stack className="gap-1">
<Span className="text-sm opacity-60">
Created {date_created}
</Span>
{sent_date ? (
<Span className="text-sm opacity-60">
Sent {sent_date}
</Span>
) : null}
</Stack>
<Divider />
<Row className="w-full grid xl:grid-cols-2 gap-6">
<Stack className="gap-1">
<Span className="text-xs opacity-50">
Recipients
</Span>
<Span className="whitespace-pre-line">
{email.to_emails || "-"}
</Span>
</Stack>
<Stack className="gap-1">
<Span className="text-xs opacity-50">
Attachments
</Span>
{attachments?.[0] ? (
<Stack className="gap-1">
{attachments.map((attachment, index) => (
<EmailAttachmentRow
key={index}
attachment={attachment}
/>
))}
</Stack>
) : (
<Span className="opacity-60">None</Span>
)}
</Stack>
</Row>
<Divider />
<div
className="w-full text-sm leading-relaxed"
dangerouslySetInnerHTML={{
__html: email.body_html || "",
}}
/>
</Stack>
);
}
@@ -1,36 +0,0 @@
import Card from "@moduletrace/twui/elements/Card";
import H3 from "@moduletrace/twui/layout/H3";
import Section from "@moduletrace/twui/layout/Section";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
import EmailDetails from "../(partials)/email-details";
import EmailActions from "../../(partials)/email-actions";
export default function EmailViewSection() {
const { pageProps } = useContext(AppContext);
const email = pageProps?.email;
const attachments = pageProps?.attachments;
if (!email) return null;
return (
<Section>
<Card className="flex-col items-stretch w-full">
<Row className="w-full justify-between items-start">
<EmailActions
email={email}
refresh={() => {
window.location.reload();
}}
is_single_email_page
/>
</Row>
<EmailDetails email={email} attachments={attachments} />
</Card>
</Section>
);
}
@@ -1,98 +0,0 @@
import AdminHero from "@/src/components/general/admin-hero";
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import Button from "@moduletrace/twui/layout/Button";
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
import clientSendEmail from "../../(functions)/client-send-email";
import Tag from "@moduletrace/twui/elements/Tag";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
export default function Hero() {
const { pageProps } = useContext(AppContext);
const email = pageProps?.email;
const is_read_only = isReadOnlyUser({
user_types: pageProps?.user_types,
});
const {
loading: sendEmailLoading,
setLoading: setSendEmailLoading,
status,
setStatus,
} = useStatus();
if (!email?.id) {
return <EmptyContent title="Email Record no found!" />;
}
return (
<>
<AdminHero
title={email.title || email.subject || "Untitled Email"}
description={
<>
{status?.msg ? (
<Tag
color={
status.error
? "error"
: status.success
? "success"
: undefined
}
>
{status.msg}
</Tag>
) : null}
</>
}
buttons={
<>
<Button
title="All Emails"
href="/admin/emails"
variant="outlined"
color="gray"
>
All Emails
</Button>
{is_read_only ? null : (
<Button
title="Edit Email"
href={`/admin/emails/${email.id}/edit`}
variant="outlined"
>
Edit Email
</Button>
)}
{is_read_only || email.status == "sent" ? null : (
<Button
title="Edit Email"
color="secondary"
loading={sendEmailLoading}
onClick={() => {
clientSendEmail({
email,
setLoading: setSendEmailLoading,
refresh() {
setSendEmailLoading(false);
},
setStatus,
});
}}
loadingProps={{
size: "small",
}}
>
Send Email
</Button>
)}
</>
}
/>
</>
);
}
@@ -1,18 +0,0 @@
import Section from "@moduletrace/twui/layout/Section";
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
import EmailForm from "../../../create/(partials)/email-form";
export default function EditEmailFormSection() {
const { pageProps } = useContext(AppContext);
const email = pageProps?.email;
if (!email) return null;
return (
<Section>
<EmailForm existing_email={email} />
</Section>
);
}
@@ -1,21 +0,0 @@
import AdminHero from "@/src/components/general/admin-hero";
import Button from "@moduletrace/twui/layout/Button";
export default function Hero() {
return (
<AdminHero
title="Edit Email"
buttons={
<>
<Button
title="All Emails"
href="/admin/emails"
variant="outlined"
>
All Emails
</Button>
</>
}
/>
);
}
@@ -1,41 +0,0 @@
import grabEmail from "@/src/functions/backend/db/documents/grab-email";
import type { PagePropsType } from "@/src/types";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const email_id = params.query?.id;
const user_types = params.props?.user_types;
if (isReadOnlyUser({ user_types })) {
return {
redirect: {
destination: "/admin/emails",
},
};
}
if (!email_id) {
return {
redirect: {
destination: "/admin/emails",
},
};
}
const email_res = await grabEmail({ id: email_id });
if (!email_res.singleRes) {
return {
redirect: {
destination: "/admin/emails",
},
};
}
return {
props: {
email: email_res.singleRes,
},
};
};
@@ -1,24 +0,0 @@
import type { PagePropsType } from "@/src/types";
import { SiteData } from "@/src/data/site-data";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import Hero from "./(sections)/hero";
import EditEmailFormSection from "./(sections)/edit-email-form-section";
export default function EditEmailPage() {
return (
<>
<Hero />
<EditEmailFormSection />
</>
);
}
export const meta: BunextPageModuleMetaFn = async (params) => {
const email = (params.serverRes?.props as PagePropsType | undefined)
?.email;
return {
title: `Edit ${email?.title || email?.subject || "Email"} | ${SiteData["SiteName"]}`,
description: "Edit email",
};
};
@@ -1,38 +0,0 @@
import grabEmail from "@/src/functions/backend/db/documents/grab-email";
import grabEmailAttachments from "@/src/functions/backend/db/documents/grab-email-attachments";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const email_id = params.query?.id;
if (!email_id) {
return {
redirect: {
destination: "/admin/emails",
},
};
}
const email_res = await grabEmail({ id: email_id });
if (!email_res.singleRes) {
return {
redirect: {
destination: "/admin/emails",
},
};
}
const email = email_res.singleRes;
const attachments_res = await grabEmailAttachments({ email_id });
const attachments = attachments_res.payload || [];
return {
props: {
email,
attachments,
},
};
};
-24
View File
@@ -1,24 +0,0 @@
import type { PagePropsType } from "@/src/types";
import { SiteData } from "@/src/data/site-data";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import Hero from "./(sections)/hero";
import EmailViewSection from "./(sections)/email-view-section";
export default function EmailViewPage() {
return (
<>
<Hero />
<EmailViewSection />
</>
);
}
export const meta: BunextPageModuleMetaFn = async (params) => {
const email = (params.serverRes?.props as PagePropsType | undefined)
?.email;
return {
title: `${email?.title || email?.subject || "Email"} | ${SiteData["SiteName"]}`,
description: "Email details",
};
};
@@ -1,106 +0,0 @@
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
import type useFormInit from "@/src/hooks/use-form-init";
import type { EmailAttachmentDraft } from "../(utils)/email-attachment-draft";
import _ from "lodash";
import type { APIResponseObject } from "@moduletrace/bunext/types";
export default async function submitEmail({
setLoading,
setStatus,
form,
existing_full,
clearLocalForm,
attachments,
}: ReturnType<typeof useFormInit<BUN_SQLITE_BSQLITE_EMAILS>> & {
attachments: EmailAttachmentDraft[];
}) {
try {
if (!form.title?.trim() && !form.subject?.trim()) {
throw new Error(`Please provide a title or subject`);
}
if (!form.to_emails?.trim()) {
throw new Error(
`Please provide a recipient email address`,
);
}
const confirm_msg = existing_full?.id
? `Update this email?`
: `Create new email draft?`;
if (!window.confirm(confirm_msg)) {
return;
}
const new_email_data = _.pick<
BUN_SQLITE_BSQLITE_EMAILS,
keyof BUN_SQLITE_BSQLITE_EMAILS
>(form, [
"title",
"subject",
"body_html",
"to_emails",
]);
if (existing_full?.id) {
new_email_data.status = "draft";
new_email_data.sent_at = undefined;
}
setLoading(true);
const create_or_update_res = await adminCrudHandler({
action: existing_full?.id ? "update" : "insert",
table: "emails",
insert_data: [new_email_data],
update_data: new_email_data,
id: existing_full?.id,
});
if (!create_or_update_res.success) {
throw new Error(
create_or_update_res.msg || "Email Save Failed",
);
}
const email_id =
create_or_update_res.singleRes?.id || existing_full?.id;
if (email_id && (attachments[0] || existing_full?.id)) {
const sync_res = await fetchApi<
{ email_id: number | string; attachments: EmailAttachmentDraft[] },
APIResponseObject
>(`/api/admin/emails/sync-attachments`, {
method: "POST",
body: {
email_id,
attachments,
},
});
if (!sync_res.success) {
throw new Error(
sync_res.msg || "Couldn't save attachments",
);
}
}
clearLocalForm();
if (existing_full?.id) {
window.location.reload();
} else {
window.location.pathname = "/admin/emails";
}
} catch (error: any) {
setStatus({
error: true,
msg: error.message || "Email Form Error",
});
setLoading(false);
}
}
@@ -1,22 +0,0 @@
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
import Button from "@moduletrace/twui/layout/Button";
import useFormInit from "@/src/hooks/use-form-init";
export default function EmailFormAction({
loading,
existing_full,
}: ReturnType<typeof useFormInit<BUN_SQLITE_BSQLITE_EMAILS>>) {
return (
<Button
title={
existing_full?.id
? "Update Email"
: "Create Email Draft"
}
type="submit"
loading={loading}
>
{existing_full?.id ? "Update Email" : "Create Email Draft"}
</Button>
);
}
@@ -1,78 +0,0 @@
import Tag from "@moduletrace/twui/elements/Tag";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import type { EmailAttachmentDraft } from "../../(utils)/email-attachment-draft";
type Props = {
attachment: EmailAttachmentDraft;
onRemove: () => void;
};
export default function EmailFormAttachmentRow({
attachment,
onRemove,
}: Props) {
const is_letter = attachment.type == "letter";
const is_invoice = attachment.type == "invoice";
const is_quote = attachment.type == "quote";
const label = attachment.file_name ||
(is_letter
? `Letter #${attachment.letter_id}`
: is_invoice
? `Invoice #${attachment.invoice_id}`
: is_quote
? `Quote #${attachment.quote_id}`
: `File #${attachment.media_id}`);
const type_title = is_letter
? "Letter"
: is_invoice
? "Invoice"
: is_quote
? "Quote"
: "File";
return (
<Row className="w-full items-center gap-2!">
<LucideIcon
name={
is_letter
? "Paperclip"
: is_invoice
? "Receipt"
: is_quote
? "Calculator"
: "File"
}
size={16}
/>
<Span className="text-sm min-w-0 flex-1 truncate">
{label}
</Span>
<Tag
color={
is_letter
? "secondary"
: is_invoice
? "primary"
: is_quote
? "accent"
: "gray"
}
>
{type_title}
</Tag>
<Button
title="Remove Attachment"
variant="ghost"
color="gray"
onClick={onRemove}
>
<LucideIcon name="Trash" size={16} />
</Button>
</Row>
);
}
@@ -1,292 +0,0 @@
import type {
BUN_SQLITE_BSQLITE_INVOICES,
BUN_SQLITE_BSQLITE_LETTERS,
BUN_SQLITE_BSQLITE_QUOTES,
} from "@/db/types/db";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
import Select from "@moduletrace/twui/form/Select";
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 clientUploadMediaHandler from "@/src/functions/frontend/client-upload-media-handler";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import { useRef, useState, type Dispatch, type SetStateAction } from "react";
import type { EmailAttachmentDraft } from "../../(utils)/email-attachment-draft";
import fileToDataUrl from "../../(utils)/file-to-data-url";
import EmailFormAttachmentRow from "./email-form-attachment-row";
type Props = {
attachments: EmailAttachmentDraft[];
setAttachments: Dispatch<SetStateAction<EmailAttachmentDraft[]>>;
user_id?: number | string;
setStatus: (status: { error: boolean; msg?: string } | undefined) => void;
};
export default function EmailFormAttachments({
attachments,
setAttachments,
user_id,
setStatus,
}: Props) {
const { res: letters } =
useAdminCrudGet<BUN_SQLITE_BSQLITE_LETTERS>({
table: "letters",
});
const { res: invoices } =
useAdminCrudGet<BUN_SQLITE_BSQLITE_INVOICES>({
table: "invoices",
});
const { res: quotes } =
useAdminCrudGet<BUN_SQLITE_BSQLITE_QUOTES>({
table: "quotes",
});
const file_input_ref = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const attached_letter_ids = attachments
.filter((a) => a.type == "letter" && a.letter_id)
.map((a) => Number(a.letter_id));
const attached_invoice_ids = attachments
.filter((a) => a.type == "invoice" && a.invoice_id)
.map((a) => Number(a.invoice_id));
const attached_quote_ids = attachments
.filter((a) => a.type == "quote" && a.quote_id)
.map((a) => Number(a.quote_id));
const available_letters = (letters || []).filter(
(letter) => !attached_letter_ids.includes(Number(letter.id)),
);
const letter_options = [
{
title: "- Choose a letter -",
value: "",
default: true,
},
...available_letters.map((letter) => ({
title: letter.title || letter.subject || `#${letter.id}`,
value: `${letter.id}`,
})),
];
const available_invoices = (invoices || []).filter(
(invoice) => !attached_invoice_ids.includes(Number(invoice.id)),
);
const invoice_options = [
{
title: "- Choose an invoice -",
value: "",
default: true,
},
...available_invoices.map((invoice) => ({
title: invoice.invoice_number || invoice.title || `#${invoice.id}`,
value: `${invoice.id}`,
})),
];
const available_quotes = (quotes || []).filter(
(quote) => !attached_quote_ids.includes(Number(quote.id)),
);
const quote_options = [
{
title: "- Choose a quote -",
value: "",
default: true,
},
...available_quotes.map((quote) => ({
title: quote.quote_number || quote.title || `#${quote.id}`,
value: `${quote.id}`,
})),
];
const add_letter = (letter_id: number) => {
if (attached_letter_ids.includes(letter_id)) return;
setAttachments((prev) => [
...prev,
{
type: "letter",
letter_id,
},
]);
};
const add_invoice = (invoice_id: number) => {
if (attached_invoice_ids.includes(invoice_id)) return;
setAttachments((prev) => [
...prev,
{
type: "invoice",
invoice_id,
},
]);
};
const add_quote = (quote_id: number) => {
if (attached_quote_ids.includes(quote_id)) return;
setAttachments((prev) => [
...prev,
{
type: "quote",
quote_id,
},
]);
};
const upload_files = async (files: FileList | File[]) => {
if (!user_id) {
setStatus({
error: true,
msg: `Sign in required to upload attachments`,
});
return;
}
setUploading(true);
for (const file of Array.from(files)) {
const { dataUrl, mimeType, name } = await fileToDataUrl({
file,
});
if (!dataUrl) {
setStatus({
error: true,
msg: `Couldn't read file: ${file.name}`,
});
continue;
}
const upload_res = await clientUploadMediaHandler({
user_id,
media_type: "document",
media_base_64_data_url: dataUrl,
media_name: (name || file.name).replace(/\.[^.]+$/, ""),
});
if (!upload_res.success || !upload_res.singleRes?.id) {
setStatus({
error: true,
msg: upload_res.msg || `Couldn't upload file: ${file.name}`,
});
continue;
}
const attachment: EmailAttachmentDraft = {
type: "file",
media_id: Number(upload_res.singleRes.id),
file_name: name || file.name,
mime_type: mimeType || file.type,
};
setAttachments((prev) => [...prev, attachment]);
}
setUploading(false);
if (file_input_ref.current) {
file_input_ref.current.value = "";
}
};
return (
<Stack className="w-full gap-4">
<H3 className="!mb-0">Attachments</H3>
{attachments[0] ? (
<Stack className="w-full gap-2">
{attachments.map((attachment, index) => (
<EmailFormAttachmentRow
key={index}
attachment={attachment}
onRemove={() => {
setAttachments((prev) =>
prev.filter((_a, i) => i !== index),
);
}}
/>
))}
</Stack>
) : (
<Span className="text-sm opacity-60">
No attachments - add a letter, invoice, quote, or upload
files below.
</Span>
)}
<Row className="w-full items-center gap-2!">
<Select
key={1}
title="Attach Letter"
options={letter_options}
showLabel
wrapperWrapperProps={{ className: "flex-1" }}
changeHandler={(value) => {
if (!value) return;
add_letter(Number(value));
}}
/>
<Select
key={2}
title="Attach Invoice"
options={invoice_options}
showLabel
wrapperWrapperProps={{ className: "flex-1" }}
changeHandler={(value) => {
if (!value) return;
add_invoice(Number(value));
}}
/>
<Select
key={3}
title="Attach Quote"
options={quote_options}
showLabel
wrapperWrapperProps={{ className: "flex-1" }}
changeHandler={(value) => {
if (!value) return;
add_quote(Number(value));
}}
/>
<input
ref={file_input_ref}
type="file"
multiple
hidden
onChange={(e) => {
if (e.target.files?.[0]) {
upload_files(e.target.files);
}
}}
/>
<Button
title="Upload Files"
variant="ghost"
color="secondary"
loading={uploading}
onClick={(e) => {
e.preventDefault();
file_input_ref.current?.click();
}}
>
<LucideIcon name="Upload" size={16} />
</Button>
</Row>
</Stack>
);
}
@@ -1,30 +0,0 @@
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
import TinyMCEEditor from "@moduletrace/twui/editors/TinyMCE";
import H3 from "@moduletrace/twui/layout/H3";
import Stack from "@moduletrace/twui/layout/Stack";
import useFormInit from "@/src/hooks/use-form-init";
export default function EmailFormBody({
setForm,
form,
}: ReturnType<typeof useFormInit<BUN_SQLITE_BSQLITE_EMAILS>>) {
return (
<Stack className="w-full gap-4">
<H3 className="!mb-0">Email Body</H3>
<TinyMCEEditor
defaultValue={form.body_html}
changeHandler={(content) => {
setForm((prev) => ({
...prev,
body_html: content,
}));
}}
placeholder="Write the HTML body of the email here"
options={{ height: 400 }}
name="Email Body"
showLabel
/>
</Stack>
);
}
@@ -1,79 +0,0 @@
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
import Input from "@moduletrace/twui/form/Input";
import Textarea from "@moduletrace/twui/form/Textarea";
import H3 from "@moduletrace/twui/layout/H3";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import useFormInit from "@/src/hooks/use-form-init";
import type { Dispatch, SetStateAction } from "react";
import type { EmailAttachmentDraft } from "../../(utils)/email-attachment-draft";
import EmailFormAttachments from "./email-form-attachments";
type Props = ReturnType<typeof useFormInit<BUN_SQLITE_BSQLITE_EMAILS>> & {
attachments: EmailAttachmentDraft[];
setAttachments: Dispatch<SetStateAction<EmailAttachmentDraft[]>>;
};
export default function EmailFormDetails({
setForm,
form,
setStatus,
attachments,
setAttachments,
app_context,
}: Props) {
return (
<Stack className="w-full gap-4">
<H3 className="!mb-0">Email Details</H3>
<Row className="w-full grid xl:grid-cols-2">
<Input
title="Title"
placeholder="Eg. AGM Notice Email"
onChange={(e) => {
setForm((prev) => ({
...prev,
title: e.target.value,
}));
}}
defaultValue={form.title}
showLabel
/>
<Input
title="Email Subject"
placeholder="Eg. Notice of Annual General Meeting"
onChange={(e) => {
setForm((prev) => ({
...prev,
subject: e.target.value,
}));
}}
defaultValue={form.subject}
showLabel
/>
</Row>
<Textarea
title="Recipients"
placeholder={"Eg. [email protected]\nSeparate multiple recipients with commas"}
onChange={(e) => {
setForm((prev) => ({
...prev,
to_emails: e.target.value,
}));
}}
defaultValue={form.to_emails}
showLabel
rows={2}
/>
<EmailFormAttachments
attachments={attachments}
setAttachments={setAttachments}
user_id={app_context?.user?.id}
setStatus={setStatus}
/>
</Stack>
);
}
@@ -1,171 +0,0 @@
import type {
BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS,
BUN_SQLITE_BSQLITE_EMAILS,
BUN_SQLITE_BSQLITE_LETTERS,
} from "@/db/types/db";
import CopyFromExistingSelect from "@/src/components/general/copy-from-existing-select";
import LoadingOverlay from "@moduletrace/twui/elements/LoadingOverlay";
import Tag from "@moduletrace/twui/elements/Tag";
import Form from "@moduletrace/twui/form/Form";
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
import useFormInit from "@/src/hooks/use-form-init";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import { useEffect, useState } from "react";
import submitEmail from "../../(functions)/submit-email";
import type { EmailAttachmentDraft } from "../../(utils)/email-attachment-draft";
import EmailFormAction from "./email-form-action";
import EmailFormBody from "./email-form-body";
import EmailFormDetails from "./email-form-details";
import LoadingRectangleBlock from "@moduletrace/twui/layout/LoadingRectangleBlock";
type Props = {
existing_email?: BUN_SQLITE_BSQLITE_EMAILS;
};
export default function EmailForm({ existing_email }: Props) {
const init = useFormInit<BUN_SQLITE_BSQLITE_EMAILS>({
existing: existing_email,
existing_full: existing_email,
title: "Email",
});
const { loading, status, ready, setForm } = init;
const [attachments, setAttachments] = useState<EmailAttachmentDraft[]>([]);
const is_existing = Boolean(existing_email?.id);
const { res: attachment_rows } =
useAdminCrudGet<BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS>({
table: "email_attachments",
ready: is_existing ? undefined : false,
sql_query: is_existing
? { query: { email_id: { value: existing_email?.id } } }
: undefined,
});
useEffect(() => {
if (attachment_rows === undefined) return;
if (attachment_rows[0]) {
if (attachments[0]) return;
setAttachments(
attachment_rows.map((row) => ({
type: row.type as "letter" | "invoice" | "file",
letter_id: row.letter_id
? Number(row.letter_id)
: undefined,
invoice_id: row.invoice_id
? Number(row.invoice_id)
: undefined,
media_id: row.media_id ? Number(row.media_id) : undefined,
file_name: row.file_name || undefined,
mime_type: row.mime_type || undefined,
})),
);
return;
}
}, [attachment_rows]);
useEffect(() => {
if (!ready) return;
if (existing_email?.id) return;
const params = new URLSearchParams(window.location.search);
const from_id = params.get("from");
const from_letter_id = params.get("from_letter");
if (!from_id && !from_letter_id) return;
if (from_id) {
adminCrudHandler<BUN_SQLITE_BSQLITE_EMAILS>({
action: "get",
table: "emails",
id: from_id,
}).then((res) => {
const record = res.payload?.[0] || res.singleRes;
if (record) {
setForm(record);
}
});
}
if (from_letter_id) {
adminCrudHandler<BUN_SQLITE_BSQLITE_LETTERS>({
action: "get",
table: "letters",
id: from_letter_id,
}).then((res) => {
const record = res.payload?.[0] || res.singleRes;
if (record) {
setForm((prev) => ({
...prev,
subject: prev.subject || record.subject,
title: prev.title || record.title,
}));
setAttachments((prev) =>
prev.some(
(a) =>
a.type == "letter" && a.letter_id == record.id,
)
? prev
: [
...prev,
{
type: "letter",
letter_id: record.id
? Number(record.id)
: undefined,
},
],
);
}
});
}
}, [ready]);
return (
<>
<Form
className="w-full gap-6"
onSubmit={() => {
submitEmail({ ...init, attachments });
}}
>
{status?.error && <Tag color="error">{status.msg}</Tag>}
{loading && <LoadingOverlay />}
{!existing_email?.id ? (
<CopyFromExistingSelect
table="emails"
title="Copy from Existing Email"
onSelect={(record) => {
setForm(
record as BUN_SQLITE_BSQLITE_EMAILS,
);
}}
/>
) : null}
{ready ? (
<>
<EmailFormDetails
{...init}
attachments={attachments}
setAttachments={setAttachments}
/>
<EmailFormBody {...init} />
<EmailFormAction {...init} />
</>
) : (
<LoadingRectangleBlock className="h-[700px]" />
)}
</Form>
</>
);
}
@@ -1,10 +0,0 @@
import Section from "@moduletrace/twui/layout/Section";
import EmailForm from "../(partials)/email-form";
export default function EmailFormSection() {
return (
<Section>
<EmailForm />
</Section>
);
}
@@ -1,22 +0,0 @@
import AdminHero from "@/src/components/general/admin-hero";
import Button from "@moduletrace/twui/layout/Button";
export default function Hero() {
return (
<AdminHero
title="Create New Email"
description="Compose an email draft, or start from an existing email or letter"
buttons={
<>
<Button
title="All Emails"
href="/admin/emails"
variant="outlined"
>
All Emails
</Button>
</>
}
/>
);
}
@@ -1,9 +0,0 @@
export type EmailAttachmentDraft = {
type: "letter" | "invoice" | "quote" | "file";
letter_id?: number;
invoice_id?: number;
quote_id?: number;
media_id?: number;
file_name?: string;
mime_type?: string;
};
@@ -1,37 +0,0 @@
export type FileToDataUrlResult = {
dataUrl?: string;
file?: File;
mimeType?: string;
name?: string;
};
export default async function fileToDataUrl({
file,
}: {
file: File;
}): Promise<FileToDataUrlResult> {
try {
const reader = new FileReader();
const dataUrl = await new Promise<string>((res, rej) => {
reader.onload = () => {
res(reader.result as string);
};
reader.onerror = () => {
rej(new Error(`Couldn't read file: ${file.name}`));
};
reader.readAsDataURL(file);
});
return {
dataUrl,
file,
mimeType: file.type,
name: file.name,
};
} catch (error: any) {
console.log(`File To Data URL Error! => ${error.message}`);
return {};
}
}
@@ -1,17 +0,0 @@
import type { PagePropsType } from "@/src/types";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const user_types = params.props?.user_types;
if (isReadOnlyUser({ user_types })) {
return {
redirect: {
destination: "/admin/emails",
},
};
}
return {};
};
-20
View File
@@ -1,20 +0,0 @@
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import Hero from "./(sections)/hero";
import EmailFormSection from "./(sections)/email-form-section";
export default function CreateEmailPage() {
return (
<>
<Hero />
<EmailFormSection />
</>
);
}
export const meta: BunextPageModuleMetaFn = async (params) => {
return {
title: `Create New Email | ${SiteData["SiteName"]}`,
description: "Compose a new email draft",
};
};
-20
View File
@@ -1,20 +0,0 @@
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import Hero from "./(sections)/hero";
import EmailsListSection from "./(sections)/emails-list-section";
export default function EmailsPage() {
return (
<>
<Hero />
<EmailsListSection />
</>
);
}
export const meta: BunextPageModuleMetaFn = async (params) => {
return {
title: `Emails | ${SiteData["SiteName"]}`,
description: "Email drafts and sent emails",
};
};
@@ -1,186 +0,0 @@
import { CompanyLetterheadDefaults } from "@/src/dict/company-dict";
import { SiteData } from "@/src/data/site-data";
import { ThemeColors } from "@/src/functions/backend/theme/theme-colors";
import EmailTemplateDetailRow from "../(partials)/email-template-detail-row";
const dummy_data = {
recipient_name: "Amara",
inviter_name: "Daniel Mensah",
workspace_name: "Northwind Labs",
role: "Editor",
plan: "Pro (annual)",
invite_code: "K7Q-4XM",
expires_in: "7 days",
} as const;
const detail_rows = [
{ label: "Workspace", value: dummy_data.workspace_name, strong: true },
{ label: "Invited by", value: dummy_data.inviter_name },
{ label: "Your role", value: dummy_data.role },
{ label: "Plan", value: dummy_data.plan },
];
const title_bar = (
<div
style={{
fontSize: "13px",
fontWeight: "600",
color: ThemeColors.ink,
}}
>
{SiteData["SiteName"]}
</div>
);
const divider = (
<hr
style={{
border: "none",
borderTop: `1px solid ${ThemeColors.hairline}`,
}}
/>
);
export default function EmailTemplateContent() {
return (
<div>
{title_bar}
{divider}
<h1
style={{
fontSize: "18px",
margin: "0 0 6px",
}}
>
You've been invited to {dummy_data.workspace_name}
</h1>
<div
style={{
fontSize: "13px",
lineHeight: "1.7",
color: ThemeColors.body,
}}
>
Hi {dummy_data.recipient_name},
<br />
<strong>{dummy_data.inviter_name}</strong> has invited you to
collaborate in the <strong>{dummy_data.workspace_name}</strong>{" "}
workspace on {SiteData["SiteName"]}.
</div>
{divider}
<table
width="100%"
cellPadding="0"
cellSpacing="0"
style={{ fontSize: "13px", borderCollapse: "collapse" }}
>
<tbody>
{detail_rows.map((row) => (
<EmailTemplateDetailRow key={row.label} {...row} />
))}
</tbody>
</table>
{divider}
<div
style={{
fontSize: "13px",
lineHeight: "1.7",
color: ThemeColors.body,
}}
>
Accept the invitation with the button below, or enter this
invite code after signing in:
</div>
<table
width="100%"
cellPadding="0"
cellSpacing="0"
role="presentation"
style={{ margin: "14px 0 18px" }}
>
<tbody>
<tr>
<td align="center">
<table
role="presentation"
cellPadding="0"
cellSpacing="0"
style={{ background: ThemeColors.subtle }}
bgcolor={ThemeColors.subtle}
>
<tbody>
<tr>
<td
align="center"
style={{
padding: "12px 24px",
fontSize: "20px",
fontWeight: "700",
letterSpacing: "6px",
color: ThemeColors.ink,
borderRadius: "10px",
}}
>
{dummy_data.invite_code}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table
width="100%"
cellPadding="0"
cellSpacing="0"
role="presentation"
>
<tbody>
<tr>
<td align="center">
<a
href={CompanyLetterheadDefaults.website}
style={{
background: ThemeColors.ink,
color: ThemeColors.surface,
display: "inline-block",
padding: "11px 26px",
borderRadius: "8px",
fontSize: "13px",
fontWeight: "600",
textDecoration: "none",
}}
>
Accept Invitation
</a>
</td>
</tr>
</tbody>
</table>
{divider}
<div
style={{
fontSize: "12px",
lineHeight: "1.7",
color: ThemeColors.muted,
}}
>
This invitation expires in {dummy_data.expires_in}. If you
weren't expecting it, you can safely ignore this email. Need
help? Contact us at{" "}
<strong>{CompanyLetterheadDefaults.email}</strong>.
</div>
</div>
);
}
@@ -1,20 +0,0 @@
import { ThemeColors } from "@/src/functions/backend/theme/theme-colors";
type Props = {
label: string;
value: string;
strong?: boolean;
};
export default function EmailTemplateDetailRow({ label, value, strong }: Props) {
return (
<tr>
<td style={{ padding: "4px 0", color: ThemeColors.muted }}>
{label}
</td>
<td style={{ padding: "4px 0" }} align="right">
{strong ? <strong>{value}</strong> : value}
</td>
</tr>
);
}
@@ -1,21 +0,0 @@
type Props = {
srcDoc: string;
};
export default function EmailTemplatePreview({ srcDoc }: Props) {
return (
<iframe
title="Email Template Preview"
srcDoc={srcDoc}
sandbox=""
loading="lazy"
width="100%"
height="850"
style={{
border: "none",
background: "#fff",
borderRadius: "5px",
}}
/>
);
}
@@ -1,34 +0,0 @@
import Paper from "@moduletrace/twui/elements/Paper";
import Center from "@moduletrace/twui/layout/Center";
import Section from "@moduletrace/twui/layout/Section";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
import EmailTemplatePreview from "../(partials)/email-template-preview";
export default function EmailTemplateSection() {
const { pageProps } = useContext(AppContext);
const html = pageProps?.email_template_html;
if (!html) {
return (
<Section>
<Paper>
<Center className="p-20">
<Span>Failed to render email template.</Span>
</Center>
</Paper>
</Section>
);
}
return (
<Section>
<Stack className="w-full items-stretch">
<EmailTemplatePreview srcDoc={html} />
</Stack>
</Section>
);
}
@@ -1,10 +0,0 @@
import AdminHero from "@/src/components/general/admin-hero";
export default function Hero() {
return (
<AdminHero
title="Email Template"
description="Live preview of the email layout wrapped around every outgoing email"
/>
);
}
@@ -1,33 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import path from "path";
import type { FC } from "react";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import EmailTemplateContent from "./(data)/email-template-content";
export const server: BunextPageServerFn<PagePropsType> = async () => {
try {
const now = Date.now();
const EmailRoot = (
await import(path.join(process.cwd(), `src/email/root?t=${now}`))
).default as FC<any>;
const html = renderToStaticMarkup(
<EmailRoot>
<EmailTemplateContent />
</EmailRoot>,
);
return {
props: {
email_template_html: html,
},
};
} catch (error: any) {
return {
props: {
email_template_html: "",
},
};
}
};
-20
View File
@@ -1,20 +0,0 @@
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import Hero from "./(sections)/hero";
import EmailTemplateSection from "./(sections)/email-template-section";
export default function EmailTemplatePage() {
return (
<>
<Hero />
<EmailTemplateSection />
</>
);
}
export const meta: BunextPageModuleMetaFn = async (params) => {
return {
title: `Email Template | ${SiteData["SiteName"]}`,
description: "Live preview of the email template",
};
};
@@ -1,34 +0,0 @@
import type { InvoiceStatus } from "@/src/dict/document-dict";
export type InvoiceItemFormObject = {
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;
};
export type QuoteLinkFormObject = {
quote_id?: number | "";
/** Naira amount as a plain number string (converted to kobo on submit). */
amount?: string;
};
export type InvoiceFormObject = {
id?: number | "";
title?: string;
short_description?: string;
client_name?: string;
client_email?: string;
client_address?: string;
notes?: string;
/**
* Unix timestamps in ms. `""` when not set.
*/
issue_date?: number | "";
due_date?: number | "";
status?: InvoiceStatus | "";
items?: InvoiceItemFormObject[];
quote_link?: QuoteLinkFormObject;
};
@@ -1,34 +0,0 @@
import type { BUN_SQLITE_BSQLITE_INVOICES } from "@/db/types/db";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
type Props = {};
export default function useInvoicesListInit(_props?: Props) {
const {
res: invoices,
count,
page,
setPage,
query,
setQuery,
limit,
setLimit,
ready,
setReady,
} = useAdminCrudGet<BUN_SQLITE_BSQLITE_INVOICES>({
table: "invoices",
});
return {
invoices,
count,
page,
setPage,
query,
setQuery,
limit,
setLimit,
ready,
setReady,
};
}
@@ -1,118 +0,0 @@
import type { BUN_SQLITE_BSQLITE_INVOICES } from "@/db/types/db";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import { useState } from "react";
import { downloadGeneratedPdf } from "@/src/functions/frontend/document-actions";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import Tag from "@moduletrace/twui/elements/Tag";
import SendInvoiceEmailModal from "./send-invoice-email-modal";
import { AppContext } from "@/src/pages/__root";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import { useContext } from "react";
type Props = {
invoice: BUN_SQLITE_BSQLITE_INVOICES;
is_view_invoice_page?: boolean;
};
export default function InvoiceActions({
invoice,
is_view_invoice_page,
}: Props) {
const { pageProps } = useContext(AppContext);
const is_read_only = isReadOnlyUser({
user_types: pageProps?.user_types,
});
const { loading, setLoading, status, setStatus } = useStatus();
const [email_open, setEmailOpen] = useState(false);
const edit_invoice_url = `/admin/invoices/${invoice.id}/edit`;
const download_pdf = async () => {
setStatus(undefined);
setLoading(true);
const res = await downloadGeneratedPdf({
url: "/api/admin/invoices/generate-pdf",
body: {
invoice_id: invoice.id,
},
fallback_filename: `${invoice.invoice_number || "invoice"}.pdf`,
});
if (res.success) {
setStatus({
error: false,
success: true,
msg: "Invoice downloaded",
});
} else {
setStatus({
error: true,
msg: res.msg || "Couldn't download invoice",
});
}
setLoading(false);
};
return (
<Row className="justify-end gap-2!">
{status?.error && <Tag color="error">{status.msg}</Tag>}
{status?.success && <Tag color="success">{status.msg}</Tag>}
{is_view_invoice_page ? null : (
<Button
title="View Invoice"
variant="ghost"
color="gray"
href={`/admin/invoices/${invoice.id}`}
>
<LucideIcon name="Eye" size={16} />
</Button>
)}
<Button
title="Download Invoice PDF"
variant="ghost"
color="gray"
loading={loading}
onClick={() => {
download_pdf();
}}
>
<LucideIcon name="Download" size={16} />
</Button>
{is_read_only ? null : (
<>
<Button
title="Send Invoice by Email"
variant="ghost"
color="secondary"
onClick={() => {
setEmailOpen(true);
}}
>
<LucideIcon name="MailPlus" size={16} />
</Button>
<Button
title="Edit Invoice"
variant="ghost"
color="gray"
href={edit_invoice_url}
>
<LucideIcon name="Edit2" size={16} />
</Button>
</>
)}
<SendInvoiceEmailModal
invoice={invoice}
open={email_open}
setOpen={setEmailOpen}
/>
</Row>
);
}
@@ -1,98 +0,0 @@
import type {
BUN_SQLITE_BSQLITE_INVOICE_ITEMS,
BUN_SQLITE_BSQLITE_INVOICES,
} from "@/db/types/db";
import Card from "@moduletrace/twui/elements/Card";
import Tag from "@moduletrace/twui/elements/Tag";
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 { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import dayjs from "dayjs";
import { formatNaira, sumInvoiceItemsKobo } from "@/src/utils/money";
import InvoiceActions from "./invoice-actions";
import Link from "@moduletrace/twui/layout/Link";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
type Props = {
invoice: BUN_SQLITE_BSQLITE_INVOICES;
};
const StatusColors = {
draft: "gray",
issued: "secondary",
paid: "success",
cancelled: "error",
} as const;
export default function InvoiceCard({ invoice }: Props) {
const { res: items } =
useAdminCrudGet<BUN_SQLITE_BSQLITE_INVOICE_ITEMS>({
table: "invoice_items",
sql_query: {
query: {
invoice_id: {
value: invoice.id,
},
},
},
});
const total_kobo = sumInvoiceItemsKobo(items || []);
const status = invoice.status || "draft";
const status_color =
StatusColors[status as keyof typeof StatusColors] || "gray";
const date_created = dayjs(Number(invoice.created_at));
const view_url = `/admin/invoices/${invoice.id}`;
const edit_url = `/admin/invoices/${invoice.id}/edit`;
return (
<Card>
<Stack className="w-full gap-2">
<Link href={view_url} className="text-foreground-light! dark:text-foreground-dark! w-full">
<Row className="w-full justify-between items-start">
<Stack className="gap-3">
<H3>{invoice.title || "Invoice"}</H3>
<Row className="gap-1!">
<LucideIcon
name="Building"
size={15}
opacity={0.5}
/>
<Span size="small" variant="faded">
{invoice.client_name
? `${invoice.client_name}`
: "--"}
</Span>
</Row>
<Row className="gap-2!">
<Tag color={status_color}>
{status.toUpperCase()}
</Tag>
<Tag color="gray">
{date_created.toString()}
</Tag>
</Row>
</Stack>
<Stack className="gap-1">
<Span variant="faded" className="text-xs">
Total
</Span>
<Span className="font-semibold text-lg!">
{formatNaira(total_kobo)}
</Span>
</Stack>
</Row>
</Link>
<Row className="w-full justify-end">
<InvoiceActions invoice={invoice} />
</Row>
</Stack>
</Card>
);
}
@@ -1,36 +0,0 @@
import Loading from "@moduletrace/twui/elements/Loading";
import Paper from "@moduletrace/twui/elements/Paper";
import Center from "@moduletrace/twui/layout/Center";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import InvoiceCard from "./invoice-card";
import useInvoicesListInit from "../(hooks)/use-invoices-list-init";
export default function InvoicesList() {
const init = useInvoicesListInit({});
const { invoices } = init;
return (
<Stack className="w-full items-stretch">
{!invoices ? (
<Paper>
<Center className="p-20">
<Loading />
</Center>
</Paper>
) : !invoices[0] ? (
<Paper>
<Center className="p-20">
<Span>No Invoices Found!</Span>
</Center>
</Paper>
) : (
invoices.map((invoice, index) => {
return (
<InvoiceCard invoice={invoice} key={index} />
);
})
)}
</Stack>
);
}
@@ -1,103 +0,0 @@
import type { BUN_SQLITE_BSQLITE_INVOICES } from "@/db/types/db";
import Modal from "@moduletrace/twui/elements/Modal";
import Tag from "@moduletrace/twui/elements/Tag";
import Input from "@moduletrace/twui/form/Input";
import Button from "@moduletrace/twui/layout/Button";
import Stack from "@moduletrace/twui/layout/Stack";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import { sendEmailRecord } from "@/src/functions/frontend/document-actions";
import { useState } from "react";
type Props = {
invoice: BUN_SQLITE_BSQLITE_INVOICES;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function SendInvoiceEmailModal({
invoice,
open,
setOpen,
}: Props) {
const { loading, setLoading, status, setStatus } = useStatus();
const [to_email, setToEmail] = useState(
invoice.client_email || "",
);
const send_email = async () => {
setStatus(undefined);
if (!to_email.trim()) {
setStatus({
error: true,
msg: "Please provide a recipient email address",
});
return;
}
setLoading(true);
const res = await sendEmailRecord({
url: "/api/admin/invoices/send-email",
body: {
invoice_id: invoice.id,
to_email,
},
});
if (res.success) {
setStatus({
error: false,
success: true,
msg: res.msg || "Invoice emailed",
});
} else {
setStatus({
error: true,
msg: res.msg || "Couldn't send invoice",
});
}
setLoading(false);
};
return (
<Modal open={open} setOpen={setOpen}>
<Stack className="w-full gap-4">
<h3>Send Invoice by Email</h3>
<p className="text-sm opacity-60">
{invoice.invoice_number || "Invoice"}
{invoice.client_name
? ` - ${invoice.client_name}`
: ""}
</p>
{status?.error && <Tag color="error">{status.msg}</Tag>}
{status?.success && <Tag color="success">{status.msg}</Tag>}
<Input
title="Recipient Email"
type="email"
placeholder="Eg. [email protected]"
defaultValue={to_email}
onChange={(e) => {
setToEmail(e.target.value);
}}
showLabel
required
/>
<Button
title="Send Invoice Email"
loading={loading}
onClick={() => {
send_email();
}}
>
Send Invoice Email
</Button>
</Stack>
</Modal>
);
}
@@ -1,26 +0,0 @@
import AdminHero from "@/src/components/general/admin-hero";
import Button from "@moduletrace/twui/layout/Button";
import { AppContext } from "@/src/pages/__root";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import { useContext } from "react";
export default function Hero() {
const { pageProps } = useContext(AppContext);
const is_read_only = isReadOnlyUser({ user_types: pageProps?.user_types });
return (
<AdminHero
title="Invoices"
description="Generate branded invoices priced in Naira - download as PDF or send by email"
buttons={
<>
{is_read_only ? null : (
<Button title="Create New Invoice" href="/admin/invoices/create">
Create New Invoice
</Button>
)}
</>
}
/>
);
}
@@ -1,13 +0,0 @@
import Section from "@moduletrace/twui/layout/Section";
import Stack from "@moduletrace/twui/layout/Stack";
import InvoicesList from "../(partials)/invoices-list";
export default function InvoicesListSection() {
return (
<Section>
<Stack className="w-full items-stretch">
<InvoicesList />
</Stack>
</Section>
);
}
@@ -1,29 +0,0 @@
import type { BUN_SQLITE_BSQLITE_INVOICE_ITEMS } from "@/db/types/db";
import Span from "@moduletrace/twui/layout/Span";
import { formatNaira } from "@/src/utils/money";
type Props = {
item: BUN_SQLITE_BSQLITE_INVOICE_ITEMS;
};
export default function InvoiceItemRow({ item }: Props) {
const amount_kobo =
(Number(item.quantity) || 0) *
(Number(item.unit_price_kobo) || 0);
return (
<tr className="border-b border-slate-200 dark:border-white/10">
<td className="py-2.5 pr-3">
<Span>{item.description}</Span>
</td>
<td className="py-2.5 pr-3 text-right">
<Span>{Number(item.quantity) || 0}</Span>
</td>
<td className="py-2.5 text-right">
<Span className="font-semibold">
{formatNaira(amount_kobo)}
</Span>
</td>
</tr>
);
}
@@ -1,38 +0,0 @@
import AdminHero from "@/src/components/general/admin-hero";
import Button from "@moduletrace/twui/layout/Button";
import { AppContext } from "@/src/pages/__root";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import { useContext } from "react";
export default function Hero() {
const { pageProps } = useContext(AppContext);
const is_read_only = isReadOnlyUser({
user_types: pageProps?.user_types,
});
return (
<AdminHero
title={`${pageProps?.invoice?.title || "-"}`}
buttons={
<>
{is_read_only ? null : (
<Button
title="All Invoices"
href={`/admin/invoices/${pageProps?.invoice?.id}/edit`}
>
Edit Invoice
</Button>
)}
<Button
title="All Invoices"
href="/admin/invoices"
variant="outlined"
>
All Invoices
</Button>
</>
}
/>
);
}
@@ -1,153 +0,0 @@
import Tag from "@moduletrace/twui/elements/Tag";
import Section from "@moduletrace/twui/layout/Section";
import Divider from "@moduletrace/twui/layout/Divider";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
import dayjs from "dayjs";
import { formatNaira, sumInvoiceItemsKobo } from "@/src/utils/money";
import InvoiceItemRow from "../(partials)/invoice-item-row";
import InvoiceActions from "../../(partials)/invoice-actions";
import PdfPreview from "@/src/components/general/pdf-preview";
import textToSafeHtml from "@/src/utils/text-to-html";
const StatusColors = {
draft: "gray",
issued: "secondary",
paid: "success",
cancelled: "error",
} as const;
export default function InvoiceViewSection() {
const { pageProps } = useContext(AppContext);
const invoice = pageProps?.invoice;
const items = pageProps?.invoice_items;
if (!invoice) return null;
const total_kobo = sumInvoiceItemsKobo(items || []);
const status = invoice.status || "draft";
const status_color =
StatusColors[status as keyof typeof StatusColors] || "gray";
const edit_url = `/admin/invoices/${invoice.id}/edit`;
return (
<Section>
<Stack className="w-full items-stretch">
<Row className="w-full justify-between items-start">
<Stack className="gap-2">
<Row className="gap-2!">
<Tag color={status_color}>
{status.toUpperCase()}
</Tag>
{invoice.client_name ? (
<Tag color="gray">{invoice.client_name}</Tag>
) : null}
</Row>
</Stack>
<InvoiceActions invoice={invoice} is_view_invoice_page />
</Row>
<Divider />
<Row className="w-full grid xl:grid-cols-2 gap-6">
<Stack className="gap-1">
<Span className="text-xs opacity-50">
Client
</Span>
{invoice.client_name ? (
<Span className="font-semibold">
{invoice.client_name}
</Span>
) : null}
{invoice.client_email ? (
<Span>{invoice.client_email}</Span>
) : null}
{invoice.client_address ? (
<Span className="whitespace-pre-line">
{invoice.client_address}
</Span>
) : null}
</Stack>
<Stack className="gap-1">
<Span className="text-xs opacity-50">
Dates
</Span>
{invoice.issue_date ? (
<Span>
Issued{" "}
{dayjs(Number(invoice.issue_date)).toString()}
</Span>
) : null}
{invoice.due_date ? (
<Span>
Due {dayjs(Number(invoice.due_date)).toString()}
</Span>
) : null}
</Stack>
</Row>
<Divider />
{items?.[0] ? (
<Stack className="w-full gap-2">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-300 dark:border-white/20">
<th className="py-2 pr-3 text-left font-semibold">
Description
</th>
<th className="py-2 pr-3 text-right font-semibold">
Qty
</th>
<th className="py-2 text-right font-semibold">
Amount
</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<InvoiceItemRow item={item} key={index} />
))}
</tbody>
</table>
<Row className="w-full justify-end pt-2">
<Span className="font-semibold text-lg!">
Total: {formatNaira(total_kobo)}
</Span>
</Row>
</Stack>
) : null}
{invoice.notes ? (
<>
<Divider />
<Stack className="gap-1">
<Span className="text-xs opacity-50">
Notes
</Span>
<div
className="w-full text-sm leading-relaxed"
dangerouslySetInnerHTML={{
__html: textToSafeHtml(invoice.notes),
}}
/>
</Stack>
</>
) : null}
</Stack>
<PdfPreview
url="/api/admin/invoices/generate-pdf"
body={{ invoice_id: invoice.id }}
download_name={`${invoice.invoice_number || "invoice"}.pdf`}
/>
</Section>
);
}
@@ -1,49 +0,0 @@
import Section from "@moduletrace/twui/layout/Section";
import { makeRowKey } from "@/src/components/general/sortable-line-items-editor";
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
import { koboToNairaString } from "@/src/utils/money";
import InvoiceForm from "../../../create/(partials)/invoice-form";
import type {
InvoiceItemFormObject,
QuoteLinkFormObject,
} from "../../../(data)/invoice-form-types";
export default function EditInvoiceFormSection() {
const { pageProps } = useContext(AppContext);
const invoice = pageProps?.invoice;
const invoice_items = pageProps?.invoice_items;
const quote_links = pageProps?.invoice_quote_links;
if (!invoice) return null;
const existing_items: InvoiceItemFormObject[] = (
invoice_items || []
).map((item) => ({
description: item.description,
quantity: String(item.quantity || "1"),
unit_price: koboToNairaString(item.unit_price_kobo),
_key: makeRowKey(),
}));
const first_quote_link = quote_links?.[0];
const existing_quote_link: QuoteLinkFormObject | undefined =
first_quote_link
? {
quote_id: first_quote_link.quote_id || "",
amount: koboToNairaString(first_quote_link.amount_kobo),
}
: undefined;
return (
<Section>
<InvoiceForm
existing_invoice={invoice}
existing_items={existing_items}
existing_quote_link={existing_quote_link}
/>
</Section>
);
}
@@ -1,21 +0,0 @@
import AdminHero from "@/src/components/general/admin-hero";
import Button from "@moduletrace/twui/layout/Button";
export default function Hero() {
return (
<AdminHero
title="Edit Invoice"
buttons={
<>
<Button
title="All Invoices"
href="/admin/invoices"
variant="outlined"
>
All Invoices
</Button>
</>
}
/>
);
}
@@ -1,50 +0,0 @@
import grabInvoice, {
grabInvoiceItems,
grabInvoiceQuoteLinks,
} from "@/src/functions/backend/db/invoices/grab-invoice";
import type { PagePropsType } from "@/src/types";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const invoice_id = params.query?.id;
const user_types = params.props?.user_types;
if (isReadOnlyUser({ user_types })) {
return {
redirect: {
destination: "/admin/invoices",
},
};
}
if (!invoice_id) {
return {
redirect: {
destination: "/admin/invoices",
},
};
}
const invoice_res = await grabInvoice({ id: invoice_id });
if (!invoice_res.singleRes) {
return {
redirect: {
destination: "/admin/invoices",
},
};
}
const items_res = await grabInvoiceItems({ invoice_id });
const quote_links_res = await grabInvoiceQuoteLinks({ invoice_id });
return {
props: {
invoice: invoice_res.singleRes,
invoice_items: items_res.payload || [],
invoice_quote_links: quote_links_res.payload || [],
},
};
};
@@ -1,24 +0,0 @@
import type { PagePropsType } from "@/src/types";
import { SiteData } from "@/src/data/site-data";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import Hero from "./(sections)/hero";
import EditInvoiceFormSection from "./(sections)/edit-invoice-form-section";
export default function EditInvoicePage() {
return (
<>
<Hero />
<EditInvoiceFormSection />
</>
);
}
export const meta: BunextPageModuleMetaFn = async (params) => {
const invoice = (params.serverRes?.props as PagePropsType | undefined)
?.invoice;
return {
title: `Edit Invoice ${invoice?.invoice_number || ""} | ${SiteData["SiteName"]}`,
description: "Invoice generator",
};
};
@@ -1,36 +0,0 @@
import grabInvoice, {
grabInvoiceItems,
} from "@/src/functions/backend/db/invoices/grab-invoice";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const invoice_id = params.query?.id;
if (!invoice_id) {
return {
redirect: {
destination: "/admin/invoices",
},
};
}
const invoice_res = await grabInvoice({ id: invoice_id });
if (!invoice_res.singleRes) {
return {
redirect: {
destination: "/admin/invoices",
},
};
}
const items_res = await grabInvoiceItems({ invoice_id });
return {
props: {
invoice: invoice_res.singleRes,
invoice_items: items_res.payload || [],
},
};
};
-24
View File
@@ -1,24 +0,0 @@
import type { PagePropsType } from "@/src/types";
import { SiteData } from "@/src/data/site-data";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import Hero from "./(sections)/hero";
import InvoiceViewSection from "./(sections)/invoice-view-section";
export default function InvoiceViewPage() {
return (
<>
<Hero />
<InvoiceViewSection />
</>
);
}
export const meta: BunextPageModuleMetaFn = async (params) => {
const invoice = (params.serverRes?.props as PagePropsType | undefined)
?.invoice;
return {
title: `Invoice ${invoice?.invoice_number || ""} | ${SiteData["SiteName"]}`,
description: "Invoice details",
};
};
@@ -1,164 +0,0 @@
import type {
BUN_SQLITE_BSQLITE_INVOICE_ITEMS,
BUN_SQLITE_BSQLITE_INVOICES,
BUN_SQLITE_BSQLITE_INVOICE_QUOTES,
} from "@/db/types/db";
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
import type useFormInit from "@/src/hooks/use-form-init";
import { nairaToKobo } from "@/src/utils/money";
import type { InvoiceFormObject } from "../../(data)/invoice-form-types";
export default async function submitInvoiceForm({
setLoading,
setStatus,
form,
existing_full,
clearLocalForm,
}: ReturnType<typeof useFormInit<InvoiceFormObject>>) {
try {
if (!form.client_name?.trim()) {
throw new Error(`Please provide a client name`);
}
const items = (form.items || []).filter((item) => {
return (
item.description?.trim() ||
Number(item.quantity) ||
Number(item.unit_price)
);
});
if (!items.length) {
throw new Error(`Add at least one line item`);
}
const confirm_msg = existing_full?.id
? `Update this invoice?`
: `Create new invoice?`;
if (!window.confirm(confirm_msg)) {
return;
}
const invoice_row: BUN_SQLITE_BSQLITE_INVOICES = {
client_name: form.client_name,
client_email: form.client_email,
client_address: form.client_address,
notes: form.notes,
issue_date: form.issue_date || Date.now(),
due_date: form.due_date,
status: form.status || "draft",
title: form.title,
short_description: form.short_description,
};
setLoading(true);
const create_or_update_res = await adminCrudHandler({
action: existing_full?.id ? "update" : "insert",
table: "invoices",
insert_data: [invoice_row],
update_data: invoice_row,
id: existing_full?.id,
});
if (!create_or_update_res.success) {
throw new Error(create_or_update_res.msg || "Invoice Save Failed");
}
const invoice_id =
existing_full?.id || create_or_update_res.singleRes?.id;
if (!invoice_id) {
throw new Error(`Invoice ID not found`);
}
if (existing_full?.id) {
const delete_items_res = await adminCrudHandler({
action: "delete",
table: "invoice_items",
sql_query: {
query: {
invoice_id: {
value: invoice_id,
},
},
},
});
if (!delete_items_res.success) {
throw new Error(`Couldn't update line items`);
}
}
const new_items: BUN_SQLITE_BSQLITE_INVOICE_ITEMS[] =
items.map((item, index) => ({
invoice_id,
description: item.description,
quantity: Number(item.quantity) || 1,
unit_price_kobo: nairaToKobo(item.unit_price),
sort_order: index,
}));
const add_items_res = await adminCrudHandler({
action: "insert",
table: "invoice_items",
insert_data: new_items,
});
if (!add_items_res.success) {
throw new Error(add_items_res.msg || "Couldn't save line items");
}
const delete_quote_link_res = await adminCrudHandler({
action: "delete",
table: "invoice_quotes",
sql_query: {
query: {
invoice_id: {
value: invoice_id,
},
},
},
});
// if (!delete_quote_link_res.success) {
// throw new Error(`Couldn't update quote link`);
// }
if (form.quote_link?.quote_id) {
const quote_link_row: BUN_SQLITE_BSQLITE_INVOICE_QUOTES = {
invoice_id,
quote_id: Number(form.quote_link.quote_id),
amount_kobo: nairaToKobo(form.quote_link.amount),
};
const add_quote_link_res = await adminCrudHandler({
action: "insert",
table: "invoice_quotes",
insert_data: [quote_link_row],
});
if (!add_quote_link_res.success) {
throw new Error(
add_quote_link_res.msg || "Couldn't save quote link",
);
}
}
clearLocalForm();
if (existing_full?.id) {
window.location.reload();
} else {
window.location.pathname = "/admin/invoices";
}
} catch (error: any) {
setStatus({
error: true,
msg: error.message || "Invoice Form Error",
});
setLoading(false);
}
}
@@ -1,68 +0,0 @@
import type { BUN_SQLITE_BSQLITE_INVOICES } from "@/db/types/db";
import Form from "@moduletrace/twui/form/Form";
import LoadingOverlay from "@moduletrace/twui/elements/LoadingOverlay";
import Tag from "@moduletrace/twui/elements/Tag";
import useFormInit from "@/src/hooks/use-form-init";
import submitInvoiceForm from "../../(functions)/submit-invoice-form";
import type {
InvoiceFormObject,
InvoiceItemFormObject,
} from "../../../(data)/invoice-form-types";
import InvoiceFormAction from "./invoice-form-action";
import InvoiceFormClient from "./invoice-form-client";
import InvoiceFormItems from "./invoice-form-items";
import InvoiceFormStatusNotes from "./invoice-form-status-notes";
import InvoiceFormTitle from "./invoice-form-title";
import InvoiceFormShortDescription from "./invoice-form-short-description";
import InvoiceFormQuoteLink from "./invoice-form-quote-link";
import type { QuoteLinkFormObject } from "../../../(data)/invoice-form-types";
type Props = {
existing_invoice?: BUN_SQLITE_BSQLITE_INVOICES;
existing_items?: InvoiceItemFormObject[];
existing_quote_link?: QuoteLinkFormObject;
};
export default function InvoiceForm({
existing_invoice,
existing_items,
existing_quote_link,
}: Props) {
const initial_form: InvoiceFormObject = {
...(existing_invoice as InvoiceFormObject | undefined),
items: existing_items || undefined,
quote_link: existing_quote_link,
};
const init = useFormInit<InvoiceFormObject>({
existing: initial_form,
existing_full: existing_invoice as
| BUN_SQLITE_BSQLITE_INVOICES
| undefined,
title: "Invoice",
});
const { loading, status } = init;
return (
<>
<Form
className="w-full gap-8"
onSubmit={() => {
submitInvoiceForm(init);
}}
>
{status?.error && <Tag color="error">{status.msg}</Tag>}
{loading && <LoadingOverlay />}
<InvoiceFormTitle {...init} />
<InvoiceFormShortDescription {...init} />
<InvoiceFormClient {...init} />
<InvoiceFormItems {...init} />
<InvoiceFormQuoteLink {...init} />
<InvoiceFormStatusNotes {...init} />
<InvoiceFormAction {...init} />
</Form>
</>
);
}
@@ -1,23 +0,0 @@
import type { BUN_SQLITE_BSQLITE_INVOICES } from "@/db/types/db";
import Button from "@moduletrace/twui/layout/Button";
import useFormInit from "@/src/hooks/use-form-init";
import type { InvoiceFormObject } from "../../../(data)/invoice-form-types";
export default function InvoiceFormAction({
loading,
existing_full,
}: ReturnType<typeof useFormInit<InvoiceFormObject>>) {
return (
<Button
title={
existing_full?.id
? "Update Invoice"
: "Create Invoice"
}
type="submit"
loading={loading}
>
{existing_full?.id ? "Update Invoice" : "Create Invoice"}
</Button>
);
}
@@ -1,64 +0,0 @@
import type { BUN_SQLITE_BSQLITE_INVOICES } from "@/db/types/db";
import { SiteData } from "@/src/data/site-data";
import Input from "@moduletrace/twui/form/Input";
import Textarea from "@moduletrace/twui/form/Textarea";
import H3 from "@moduletrace/twui/layout/H3";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import useFormInit from "@/src/hooks/use-form-init";
import type { InvoiceFormObject } from "../../../(data)/invoice-form-types";
export default function InvoiceFormClient({
setForm,
form,
}: ReturnType<typeof useFormInit<InvoiceFormObject>>) {
return (
<Stack className="w-full gap-4">
<H3 className="!mb-0">Client (Bill To)</H3>
<Row className="w-full grid xl:grid-cols-2">
<Input
title="Client Name"
placeholder="Eg. Resident One"
onChange={(e) => {
setForm((prev) => ({
...prev,
client_name: e.target.value,
}));
}}
defaultValue={form.client_name}
showLabel
required
/>
<Input
title="Client Email"
type="email"
placeholder="Eg. [email protected]"
onChange={(e) => {
setForm((prev) => ({
...prev,
client_email: e.target.value,
}));
}}
defaultValue={form.client_email}
showLabel
/>
</Row>
<Textarea
title="Client Address"
placeholder={`Eg. Block A, Flat 1\n${SiteData["SiteName"]} Estate\nSagamu, Lagos State`}
onChange={(e) => {
setForm((prev) => ({
...prev,
client_address: e.target.value,
}));
}}
defaultValue={form.client_address}
showLabel
rows={3}
/>
</Stack>
);
}
@@ -1,34 +0,0 @@
import SortableLineItemsEditor, {
makeRowKey,
} from "@/src/components/general/sortable-line-items-editor";
import useFormInit from "@/src/hooks/use-form-init";
import type {
InvoiceFormObject,
InvoiceItemFormObject,
} from "../../../(data)/invoice-form-types";
export default function InvoiceFormItems({
setForm,
form,
}: ReturnType<typeof useFormInit<InvoiceFormObject>>) {
const items = form.items || ([] as InvoiceItemFormObject[]);
const empty_item: InvoiceItemFormObject = {
description: "",
quantity: "1",
unit_price: "",
_key: makeRowKey(),
};
return (
<SortableLineItemsEditor
items={items}
empty_item={empty_item}
item_placeholder="Eg. Estate service charge - July 2026"
onChange={(next_items) =>
setForm((prev) => ({
...prev,
items: next_items,
}))}
/>
);
}
@@ -1,134 +0,0 @@
import type { BUN_SQLITE_BSQLITE_QUOTES } from "@/db/types/db";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
import Input from "@moduletrace/twui/form/Input";
import Select from "@moduletrace/twui/form/Select";
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 twuiNumberfy from "@moduletrace/twui/utils/numberfy";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import useFormInit from "@/src/hooks/use-form-init";
import type {
InvoiceFormObject,
QuoteLinkFormObject,
} from "../../../(data)/invoice-form-types";
import LoadingRectangleBlock from "@moduletrace/twui/layout/LoadingRectangleBlock";
export default function InvoiceFormQuoteLink({
setForm,
form,
}: ReturnType<typeof useFormInit<InvoiceFormObject>>) {
const {
res: quotes,
ready,
setReady,
} = useAdminCrudGet<BUN_SQLITE_BSQLITE_QUOTES>({
table: "quotes",
});
const quote_link: QuoteLinkFormObject = form.quote_link || {
quote_id: "",
amount: "",
};
const setQuoteLink = (next: QuoteLinkFormObject) => {
setForm((prev) => ({
...prev,
quote_link: next,
}));
};
const quote_options = [
{
title: "- No linked quote -",
value: "",
default: !quote_link.quote_id,
},
...(quotes || []).map((quote) => ({
title: quote.quote_number || quote.title || `#${quote.id}`,
value: `${quote.id}`,
default: quote_link.quote_id == quote.id,
})),
];
return (
<Stack className="w-full gap-4">
<H3 className="!mb-0">Linked Quote</H3>
<Span className="text-sm opacity-60">
Optionally link this invoice to a quote - e.g. a deposit or
milestone invoice covering part of a quotation.
</Span>
{ready ? (
<Row className="w-full grid xl:grid-cols-2 items-stretch">
<Select
key={1}
title="Quote"
options={quote_options}
showLabel
changeHandler={(value) => {
if (!value) {
setQuoteLink({ quote_id: "", amount: "" });
return;
}
setReady(false);
setQuoteLink({
quote_id: Number(value),
amount: quote_link.amount,
});
setTimeout(() => {
setReady(true);
}, 200);
}}
/>
<Input
title="Portion (₦)"
type="number"
step="0.01"
min={0}
placeholder="Eg. 150000"
defaultValue={Number(quote_link.amount)}
onChange={(e) => {
setQuoteLink({
quote_id: quote_link.quote_id,
amount: Math.round(
twuiNumberfy(e.target.value),
).toString(),
});
}}
showLabel
/>
</Row>
) : (
<LoadingRectangleBlock className="w-full h-12" />
)}
{quote_link.quote_id ? (
<Button
title="Unlink Quote"
variant="ghost"
color="error"
size="smaller"
onClick={(e) => {
e.preventDefault();
setReady(false);
setQuoteLink({ quote_id: "", amount: "" });
setTimeout(() => {
setReady(true);
}, 200);
}}
>
<LucideIcon name="Trash2" size={14} />
Unlink Quote
</Button>
) : null}
</Stack>
);
}
@@ -1,23 +0,0 @@
import type { BUN_SQLITE_BSQLITE_INVOICES } from "@/db/types/db";
import Textarea from "@moduletrace/twui/form/Textarea";
import useFormInit from "@/src/hooks/use-form-init";
export default function InvoiceFormShortDescription({
setForm,
form,
}: ReturnType<typeof useFormInit<BUN_SQLITE_BSQLITE_INVOICES>>) {
return (
<Textarea
title="Short Description"
placeholder="Eg. Short description about this invoice. Optional."
onChange={(e) => {
setForm((prev) => ({
...prev,
short_description: e.target.value,
}));
}}
defaultValue={form.short_description}
showLabel
/>
);
}
@@ -1,94 +0,0 @@
import dayjs from "dayjs";
import { InvoiceStatuses, type InvoiceStatus } from "@/src/dict/document-dict";
import Input from "@moduletrace/twui/form/Input";
import Select from "@moduletrace/twui/form/Select";
import TinyMCEEditor from "@moduletrace/twui/editors/TinyMCE";
import H3 from "@moduletrace/twui/layout/H3";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import useFormInit from "@/src/hooks/use-form-init";
import type { InvoiceFormObject } from "../../../(data)/invoice-form-types";
export default function InvoiceFormStatusNotes({
setForm,
form,
}: ReturnType<typeof useFormInit<InvoiceFormObject>>) {
return (
<Stack className="w-full gap-4">
<H3 className="!mb-0">Dates, Status &amp; Notes</H3>
<Row className="w-full grid xl:grid-cols-3">
<Input
title="Issue Date"
type="date"
defaultValue={
form.issue_date
? dayjs(Number(form.issue_date)).format(
"YYYY-MM-DD",
)
: dayjs().format("YYYY-MM-DD")
}
onChange={(e) => {
setForm((prev) => ({
...prev,
issue_date: e.target.value
? new Date(e.target.value).getTime()
: "",
}));
}}
showLabel
/>
<Input
title="Due Date"
type="date"
defaultValue={
form.due_date
? dayjs(Number(form.due_date)).format("YYYY-MM-DD")
: undefined
}
onChange={(e) => {
setForm((prev) => ({
...prev,
due_date: e.target.value
? new Date(e.target.value).getTime()
: "",
}));
}}
showLabel
/>
<Select
title="Status"
options={InvoiceStatuses.map((invoice_status) => ({
title: invoice_status.title,
value: invoice_status.value,
default:
invoice_status.value == (form.status || "draft"),
}))}
onChange={(e) => {
setForm((prev) => ({
...prev,
status: e.target.value as InvoiceStatus,
}));
}}
showLabel
/>
</Row>
<TinyMCEEditor
name="Notes"
defaultValue={form.notes}
changeHandler={(content) => {
setForm((prev) => ({
...prev,
notes: content,
}));
}}
placeholder="Eg. Payment is due within 14 days. Please make all transfers to the company bank account."
options={{ height: 300 }}
showLabel
/>
</Stack>
);
}
@@ -1,25 +0,0 @@
import type { BUN_SQLITE_BSQLITE_INVOICES } from "@/db/types/db";
import Input from "@moduletrace/twui/form/Input";
import useFormInit from "@/src/hooks/use-form-init";
export default function InvoiceFormTitle({
setForm,
form,
}: ReturnType<typeof useFormInit<BUN_SQLITE_BSQLITE_INVOICES>>) {
return (
<Input
title="Title"
placeholder="Eg. Deposit Invoice"
onChange={(e) => {
setForm((prev) => ({
...prev,
title: e.target.value,
}));
}}
className="text-xl"
defaultValue={form.title}
required
showLabel
/>
);
}
@@ -1,23 +0,0 @@
import AdminHero from "@/src/components/general/admin-hero";
import { SiteData } from "@/src/data/site-data";
import Button from "@moduletrace/twui/layout/Button";
export default function Hero() {
return (
<AdminHero
title="Create Invoice"
description={`Generate a branded ${SiteData["SiteName"]} invoice priced in Naira`}
buttons={
<>
<Button
title="All Invoices"
href="/admin/invoices"
variant="outlined"
>
All Invoices
</Button>
</>
}
/>
);
}
@@ -1,10 +0,0 @@
import Section from "@moduletrace/twui/layout/Section";
import InvoiceForm from "../(partials)/invoice-form";
export default function InvoiceFormSection() {
return (
<Section>
<InvoiceForm />
</Section>
);
}
@@ -1,17 +0,0 @@
import type { PagePropsType } from "@/src/types";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const user_types = params.props?.user_types;
if (isReadOnlyUser({ user_types })) {
return {
redirect: {
destination: "/admin/invoices",
},
};
}
return {};
};
-27
View File
@@ -1,27 +0,0 @@
import type { PagePropsType } from "@/src/types";
import { SiteData } from "@/src/data/site-data";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import Hero from "./(sections)/hero";
import InvoiceFormSection from "./(sections)/invoice-form-section";
export default function CreateInvoicePage() {
return (
<>
<Hero />
<InvoiceFormSection />
</>
);
}
export const meta: BunextPageModuleMetaFn = async (params) => {
const existing_invoice = (
params.serverRes?.props as PagePropsType | undefined
)?.invoice;
return {
title: `${
existing_invoice?.id ? "Edit" : "Create"
} Invoice | ${SiteData["SiteName"]}`,
description: "Invoice generator",
};
};
-20
View File
@@ -1,20 +0,0 @@
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import Hero from "./(sections)/hero";
import InvoicesListSection from "./(sections)/invoices-list-section";
export default function InvoicesPage() {
return (
<>
<Hero />
<InvoicesListSection />
</>
);
}
export const meta: BunextPageModuleMetaFn = async (params) => {
return {
title: `Invoices | ${SiteData["SiteName"]}`,
description: "Invoice generator",
};
};
@@ -1,34 +0,0 @@
import type { BUN_SQLITE_BSQLITE_LETTERS } from "@/db/types/db";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
type Props = {};
export default function useLettersListInit(_props?: Props) {
const {
res: letters,
count,
page,
setPage,
query,
setQuery,
limit,
setLimit,
ready,
setReady,
} = useAdminCrudGet<BUN_SQLITE_BSQLITE_LETTERS>({
table: "letters",
});
return {
letters,
count,
page,
setPage,
query,
setQuery,
limit,
setLimit,
ready,
setReady,
};
}
@@ -1,120 +0,0 @@
import dayjs from "dayjs";
import type { BUN_SQLITE_BSQLITE_LETTERS } from "@/db/types/db";
import Modal from "@moduletrace/twui/elements/Modal";
import Tag from "@moduletrace/twui/elements/Tag";
import Input from "@moduletrace/twui/form/Input";
import Textarea from "@moduletrace/twui/form/Textarea";
import Button from "@moduletrace/twui/layout/Button";
import Stack from "@moduletrace/twui/layout/Stack";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import { downloadGeneratedPdf } from "@/src/functions/frontend/document-actions";
import { useState } from "react";
type Props = {
letter: BUN_SQLITE_BSQLITE_LETTERS;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function DownloadLetterModal({
letter,
open,
setOpen,
}: Props) {
const { loading, setLoading, status, setStatus } = useStatus();
const [to_name, setToName] = useState(
letter.to_name || "",
);
const [to_address, setToAddress] = useState(
letter.to_address || "",
);
const [letter_date, setLetterDate] = useState(
dayjs().format("YYYY-MM-DD"),
);
const download_pdf = async () => {
setStatus(undefined);
setLoading(true);
const res = await downloadGeneratedPdf({
url: "/api/admin/letters/generate-pdf",
body: {
letter_id: letter.id,
to_name,
to_address,
letter_date: new Date(letter_date).getTime(),
},
fallback_filename: `${letter.title || "letter"}.pdf`,
});
if (res.success) {
setStatus({
error: false,
success: true,
msg: "Letter downloaded",
});
} else {
setStatus({
error: true,
msg: res.msg || "Couldn't download letter",
});
}
setLoading(false);
};
return (
<Modal open={open} setOpen={setOpen}>
<Stack className="w-full gap-4">
<h3>Download Letter PDF</h3>
<p className="text-sm opacity-60">
{letter.title}
</p>
{status?.error && <Tag color="error">{status.msg}</Tag>}
{status?.success && <Tag color="success">{status.msg}</Tag>}
<Input
title="Recipient Name"
defaultValue={to_name}
onChange={(e) => {
setToName(e.target.value);
}}
showLabel
/>
<Textarea
title="Recipient Address"
defaultValue={to_address}
onChange={(e) => {
setToAddress(e.target.value);
}}
showLabel
rows={3}
/>
<Input
title="Letter Date"
type="date"
defaultValue={letter_date}
onChange={(e) => {
setLetterDate(e.target.value);
}}
showLabel
/>
<Button
title="Download PDF"
loading={loading}
onClick={() => {
download_pdf();
}}
>
Download PDF
</Button>
</Stack>
</Modal>
);
}
@@ -1,89 +0,0 @@
import type { BUN_SQLITE_BSQLITE_LETTERS } from "@/db/types/db";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import { AppContext } from "@/src/pages/__root";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import { useContext, useState } from "react";
import DownloadLetterModal from "./download-letter-modal";
type Props = {
letter: BUN_SQLITE_BSQLITE_LETTERS;
is_view_letter_page?: boolean;
};
export default function LetterActions({ letter, is_view_letter_page }: Props) {
const { pageProps } = useContext(AppContext);
const is_read_only = isReadOnlyUser({
user_types: pageProps?.user_types,
});
const [download_open, setDownloadOpen] = useState(false);
const view_url = `/admin/letters/${letter.id}`;
const edit_url = `/admin/letters/${letter.id}/edit`;
const duplicate_url = `/admin/letters/create?from=${letter.id}`;
const new_email_url = `/admin/emails/create?from_letter=${letter.id}`;
return (
<Row className="w-full justify-end gap-2!">
{is_view_letter_page ? null : (
<Button
title="View Letter"
variant="ghost"
color="gray"
href={view_url}
>
<LucideIcon name="Eye" size={16} />
</Button>
)}
<Button
title="Download Letter PDF"
variant="ghost"
color="gray"
onClick={() => {
setDownloadOpen(true);
}}
>
<LucideIcon name="Download" size={16} />
</Button>
{is_read_only ? null : (
<>
<Button
title="Create a New Email from this Letter"
variant="ghost"
color="secondary"
href={new_email_url}
>
<LucideIcon name="MailPlus" size={16} />
</Button>
<Button
title="Create a New Letter from this Record"
variant="ghost"
color="gray"
href={duplicate_url}
>
<LucideIcon name="Copy" size={16} />
</Button>
<Button
title="Edit Letter"
variant="ghost"
color="gray"
href={edit_url}
>
<LucideIcon name="Edit2" size={16} />
</Button>
</>
)}
<DownloadLetterModal
letter={letter}
open={download_open}
setOpen={setDownloadOpen}
/>
</Row>
);
}
@@ -1,44 +0,0 @@
import type { BUN_SQLITE_BSQLITE_LETTERS } from "@/db/types/db";
import Card from "@moduletrace/twui/elements/Card";
import Tag from "@moduletrace/twui/elements/Tag";
import H3 from "@moduletrace/twui/layout/H3";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import dayjs from "dayjs";
import LetterActions from "./letter-actions";
import Link from "@moduletrace/twui/layout/Link";
type Props = {
letter: BUN_SQLITE_BSQLITE_LETTERS;
};
export default function LetterCard({ letter }: Props) {
const date_created = dayjs(Number(letter.created_at));
const view_url = `/admin/letters/${letter.id}`;
const edit_url = `/admin/letters/${letter.id}/edit`;
return (
<Card>
<Link href={view_url} className="text-foreground-light! dark:text-foreground-dark!">
<Row className="w-full justify-between items-start">
<Stack className="gap-2">
<H3>{letter.title}</H3>
<Row className="gap-2!">
<Tag color="gray">Letter</Tag>
{letter.subject ? (
<Tag color="gray">{letter.subject}</Tag>
) : null}
{letter.to_name ? (
<Tag color="gray">{letter.to_name}</Tag>
) : null}
<Tag color="gray">{date_created.toString()}</Tag>
</Row>
</Stack>
</Row>
</Link>
<LetterActions letter={letter} />
</Card>
);
}

Some files were not shown because too many files have changed in this diff Show More