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:
+10
-1
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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.`,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 {};
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -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: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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 || [],
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
-23
@@ -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 & 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 {};
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user