First Commit. Duplicate bunext-sqlite preset

This commit is contained in:
2026-10-18 16:47:11 +01:00
commit 81e4a8aecc
718 changed files with 41695 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
import {
Database,
LayoutTemplate,
Palette,
Rocket,
ShieldCheck,
Workflow,
} from "lucide-react";
import type { FeatureItem } from "@/src/types";
export const FeaturesArray: FeatureItem[] = [
{
Icon: Rocket,
title: "Instant Start",
description:
"Zero-config Bun server with hot reload out of the box. Run bun run dev and ship on day one.",
},
{
Icon: Database,
title: "SQLite Out of the Box",
description:
"Schema-driven SQLite with generated types, migrations, backups, and an admin UI included.",
},
{
Icon: LayoutTemplate,
title: "Filesystem Routing",
description:
"Route files under src/pages with dynamic [params] and catch-all routes. No routing config to maintain.",
},
{
Icon: ShieldCheck,
title: "Auth & SSO Ready",
description:
"Magic-link SSO, CSRF protection, secure cookies, and role-gated admin areas wired up and working.",
},
{
Icon: Workflow,
title: "Server Functions",
description:
"Co-locate server logic in index.server.ts beside your pages for type-safe, tidy full-stack code.",
},
{
Icon: Palette,
title: "twui Design System",
description:
"A local, publishable component library of layouts, elements, forms and editors, styled with Tailwind v4 tokens.",
},
];
+11
View File
@@ -0,0 +1,11 @@
export type HeroSpec = {
label: string;
value: string;
};
export const HeroSpecs: HeroSpec[] = [
{ label: "Runtime", value: "Bun" },
{ label: "Database", value: "SQLite" },
{ label: "Routing", value: "Filesystem" },
{ label: "Interface", value: "Tailwind v4 + twui" },
];
+20
View File
@@ -0,0 +1,20 @@
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import type { HeroSpec } from "../(data)/hero-specs";
type Props = HeroSpec & {
index: number;
};
export default function HeroSpecItem({ label, value, index }: Props) {
return (
<Stack className="gap-2! py-6 pr-6 border-t border-slate-300 dark:border-white/20">
<Span className="text-xs text-slate-400">
{String(index + 1).padStart(2, "0")} / {label}
</Span>
<Span className="text-lg font-medium tracking-tight text-foreground-light dark:text-foreground-dark">
{value}
</Span>
</Stack>
);
}
+44
View File
@@ -0,0 +1,44 @@
import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container";
import Stack from "@moduletrace/twui/layout/Stack";
import Row from "@moduletrace/twui/layout/Row";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
import { ArrowRight } from "lucide-react";
import { useAuthStatus } from "@/src/hooks/use-auth-status";
export default function CTASection() {
const { is_logged_in } = useAuthStatus();
if (is_logged_in) {
return null;
}
return (
<Section className="pt-0!">
<Container>
<Row className="w-full items-end justify-between gap-10! rounded-default-xl bg-dark dark:bg-white/5 dark:ring-1 dark:ring-white/10 px-8 py-14 xl:px-16 xl:py-20">
<Stack className="gap-5! max-w-2xl">
<Span className="htag text-white/50!">Begin</Span>
<H2 className="text-white">
Start with a foundation that already feels{" "}
<Span className="text-white/40">
finished.
</Span>
</H2>
</Stack>
<Button
title="Get started"
color="white"
size="large"
href="/auth/signup"
afterIcon={<ArrowRight size={16} />}
>
Get started
</Button>
</Row>
</Container>
</Section>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container";
import Stack from "@moduletrace/twui/layout/Stack";
import Row from "@moduletrace/twui/layout/Row";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import { FeaturesArray } from "../(data)/features-data";
import FeatureGrid from "@/src/components/general/feature-grid";
export default function FeaturesSection() {
return (
<Section id="features">
<Container>
<Row className="w-full items-end justify-between gap-8! mb-14">
<Stack className="gap-6! max-w-2xl">
<Span className="htag">What's inside</Span>
<H2>
Everything considered, nothing superfluous.
</H2>
</Stack>
<Span className="text-slate-500 dark:text-slate-400 leading-relaxed max-w-sm">
Runtime, database, routing, auth and interface work
together from the first commit, so the decisions left
are the ones that matter to your product.
</Span>
</Row>
<FeatureGrid items={FeaturesArray} />
</Container>
</Section>
);
}
+68
View File
@@ -0,0 +1,68 @@
import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container";
import Stack from "@moduletrace/twui/layout/Stack";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import H1 from "@moduletrace/twui/layout/H1";
import Button from "@moduletrace/twui/layout/Button";
import { ArrowRight } from "lucide-react";
import { useAuthStatus } from "@/src/hooks/use-auth-status";
import { HeroSpecs } from "../(data)/hero-specs";
import HeroSpecItem from "../(partials)/hero-spec-item";
export default function Hero() {
const { is_logged_in } = useAuthStatus();
return (
<Section className="pt-20! pb-16! xl:pt-32! xl:pb-20! border-b border-slate-200/60 dark:border-white/10">
<Container>
<Stack className="w-full items-start gap-10!">
<Span className="htag">Bunext + SQLite preset</Span>
<H1 className="max-w-5xl">
Full-stack software, built in Bun.{" "}
<Span className="text-slate-400">
Shipped with intent.
</Span>
</H1>
<Row className="w-full items-end justify-between gap-10!">
<Span className="text-lg text-slate-600 dark:text-slate-400 leading-relaxed max-w-xl">
Routing, a schema-driven database, authentication
and a considered design system, assembled into one
starting point you can make your own.
</Span>
<Row className="flex-wrap items-center gap-3!">
{!is_logged_in ? (
<Button
title="Get started"
href="/auth/signup"
size="large"
afterIcon={<ArrowRight size={16} />}
>
Get started
</Button>
) : null}
<Button
title="Learn more"
variant="outlined"
color="gray"
size="large"
href="/about"
>
Learn more
</Button>
</Row>
</Row>
<div className="grid w-full grid-cols-2 lg:grid-cols-4 gap-x-6 mt-8">
{HeroSpecs.map((spec, index) => (
<HeroSpecItem
key={spec.label}
{...spec}
index={index}
/>
))}
</div>
</Stack>
</Container>
</Section>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import React from "react";
import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
import { Home } from "lucide-react";
import { SiteData } from "@/src/data/site-data";
export default function NotFoundPage() {
return (
<>
<Section className="min-h-[70vh] flex items-center">
<Container>
<Stack className="w-full items-center text-center gap-6">
<Span className="text-8xl font-bold text-primary dark:text-primary-dark">
404
</Span>
<H1 className="text-center">
Page Not Found
</H1>
<Span className="text-lg max-w-md text-center opacity-70 text-slate-500 dark:text-slate-400">
The page you are looking for does not exist or has
been moved.
</Span>
<Button
title="Go Home"
href="/"
beforeIcon={<Home size={17} />}
>
Back to Home
</Button>
</Stack>
</Container>
</Section>
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `404 | ${SiteData["SiteName"]}`,
description: "Page not found",
};
+50
View File
@@ -0,0 +1,50 @@
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import type { PagePropsType } from "../types";
import userAuth from "../functions/backend/auth/user-auth";
import grabLoginMethod from "../functions/backend/auth/grab-login-method";
const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
const { user, user_types } = await userAuth({ req });
const is_admin_page =
url.pathname.startsWith("/admin") || url.pathname.startsWith("/auth");
if (url.pathname.startsWith("/admin") && !user?.logged_in_status) {
return {
redirect: {
destination: "/auth/login",
permanent: false,
},
};
}
if (
url.pathname.startsWith("/auth") &&
!url.pathname.match(/logout/) &&
user?.logged_in_status
) {
return {
redirect: {
destination: "/admin",
permanent: false,
},
};
}
return {
props: {
environment: process.env.NODE_ENV,
envs: {
AUTH_LOGIN_METHOD: grabLoginMethod(),
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || "",
R2_PUBLIC_DOMAIN: process.env.R2_PUBLIC_DOMAIN || "",
PAYSTACK_PUBLIC_KEY: process.env.PAYSTACK_PUBLIC_KEY || "",
},
user,
user_types,
},
cache_page: is_admin_page ? false : true,
cache_expiry: 360,
};
};
export default server;
+114
View File
@@ -0,0 +1,114 @@
// @ts-ignore
import "../styles/main.css";
import React from "react";
import type {
BunextPageHeadFCProps,
BunextRootComponentProps,
} from "@moduletrace/bunext/types";
import type { AppContextObject, AppStateObject } from "@/src/types";
import { SiteData } from "@/src/data/site-data";
import Layout from "../layouts/main";
import AdminLayout from "../layouts/admin";
import AuthLayout from "../layouts/auth";
import ThemeInitScript from "../components/general/theme-init-script";
export const AppContext = React.createContext<AppContextObject>(
{} as AppContextObject,
);
/**
* # NextApp App Root
* @description Root component for NextApp App (Next.js)
*/
export default function App({
children,
props,
url,
query,
}: BunextRootComponentProps) {
const [refresh, setRefresh] = React.useState(0);
const [appState, setAppState] = React.useState<AppStateObject>({
likes: null,
});
const appContextObject: AppContextObject = {
user: null,
appState,
setAppState,
refresh,
setRefresh,
SITE_DATA: {
siteName: SiteData["SiteName"],
},
pageProps: { ...props, url },
query,
};
if (url?.pathname.startsWith("/admin")) {
return (
<AppContext.Provider value={appContextObject}>
<AdminLayout>{children}</AdminLayout>
</AppContext.Provider>
);
}
if (
url?.pathname.startsWith("/auth") &&
!url.pathname.startsWith("/auth/logout")
) {
return (
<AppContext.Provider value={appContextObject}>
<AuthLayout>{children}</AuthLayout>
</AppContext.Provider>
);
}
return (
<AppContext.Provider value={appContextObject}>
<Layout
// no_header_divider={
// url?.pathname ? ["/"].includes(url.pathname) : undefined
// }
blank={
url?.pathname
? ["/auth/logout"].includes(url.pathname)
: undefined
}
>
{children}
</Layout>
</AppContext.Provider>
);
}
export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
return (
<>
<ThemeInitScript />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin=""
/>
<link
href="https://fonts.googleapis.com/css2?family=Geist:[email protected]&family=Geist+Mono:[email protected]&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/favicon.ico" sizes="16x16 24x24 32x32 48x48" />
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
</>
);
}
+48
View File
@@ -0,0 +1,48 @@
import {
Braces,
Database,
LayoutTemplate,
Palette,
Rocket,
ShieldCheck,
} from "lucide-react";
import type { FeatureItem } from "@/src/types";
export const AboutItems: FeatureItem[] = [
{
Icon: Rocket,
title: "Bun",
description:
"A fast JavaScript runtime with batteries included: bundler, tests, and package manager built in.",
},
{
Icon: LayoutTemplate,
title: "Bunext",
description:
"Filesystem routing and SSR on top of Bun.serve, with server-side functions beside your pages.",
},
{
Icon: Database,
title: "SQLite",
description:
"Schema-driven persistence with generated TypeScript types, migrations, and an admin UI.",
},
{
Icon: Palette,
title: "Tailwind v4 + twui",
description:
"Token-driven styling with a local, publishable component library covering layouts, forms, and editors.",
},
{
Icon: ShieldCheck,
title: "Auth & Security",
description:
"SSO magic links, CSRF protection, hashed credentials, and role-based admin areas included.",
},
{
Icon: Braces,
title: "TypeScript end-to-end",
description:
"Everything from database rows to page props is typed, with zod validation where it counts.",
},
];
@@ -0,0 +1,32 @@
import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container";
import Stack from "@moduletrace/twui/layout/Stack";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import FeatureGrid from "@/src/components/general/feature-grid";
import { AboutItems } from "../(data)/about-items";
export default function AboutPresetSection() {
return (
<Section>
<Container>
<Stack className="w-full items-center gap-16!">
<Stack className="w-full items-center max-w-3xl mx-auto gap-4!">
<Span className="htag">About this template</Span>
<H2 className="text-center">
A deliberate starting point, not a blank canvas
</H2>
<Span className="text-center text-slate-500 dark:text-slate-400 max-w-2xl">
This preset ships the hard parts already solved -
server wiring, database tooling, authentication,
and a coherent design system - so you can jump
straight to building your product.
</Span>
</Stack>
<FeatureGrid items={AboutItems} />
</Stack>
</Container>
</Section>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import AboutPresetSection from "./(sections)/about-preset-section";
import InnerPageHeroSection from "@/src/components/general/inner-page-hero-section";
import CTASection from "../(sections)/cta-section";
import { SiteData } from "@/src/data/site-data";
export default function AboutPage() {
return (
<>
<InnerPageHeroSection
title="A modern starting point for full-stack apps"
sub_title="About"
description="Bun, Bunext, SQLite, Tailwind v4, and the twui design system, pre-wired and ready to customize."
/>
<AboutPresetSection />
<CTASection />
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `About | ${SiteData["SiteName"]}`,
description:
"Learn about the Bun + SQLite SaaS starter preset and the stack it ships with.",
};
@@ -0,0 +1,48 @@
import {
FileText,
Mail,
Receipt,
SquarePen,
Users,
type LucideIcon,
} from "lucide-react";
export type DashboardShortcut = {
title: string;
description: string;
url: string;
Icon: LucideIcon;
};
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: "People",
description: "Manage admin users, roles and access.",
url: "/admin/people",
Icon: Users,
},
];
@@ -0,0 +1,37 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import H3 from "@moduletrace/twui/layout/H3";
import { ArrowUpRight } from "lucide-react";
import type { DashboardShortcut } from "../(data)/dashboard-shortcuts";
type Props = DashboardShortcut;
export default function DashboardShortcutCard({
title,
description,
url,
Icon,
}: Props) {
return (
<a href={url} className="group block h-full">
<Stack className="h-full w-full gap-10! rounded-default-lg border border-slate-200 dark:border-white/10 bg-white dark:bg-white/5 p-5 transition-all hover:border-slate-300 dark:hover:border-white/20 hover:shadow-md hover:shadow-slate-900/5 dark:hover:shadow-none">
<Row className="w-full justify-between">
<Row className="size-9 justify-center rounded-default bg-slate-100 dark:bg-white/10 text-slate-700 dark:text-slate-300">
<Icon size={17} strokeWidth={1.75} />
</Row>
<ArrowUpRight
size={16}
className="text-slate-300 dark:text-slate-600 transition-colors group-hover:text-accent"
/>
</Row>
<Stack className="gap-1.5!">
<H3>{title}</H3>
<Span className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">
{description}
</Span>
</Stack>
</Stack>
</a>
);
}
@@ -0,0 +1,16 @@
import { useContext } from "react";
import AdminHero from "@/src/components/general/admin-hero";
import { AppContext } from "@/src/pages/__root";
import type { User } from "@/src/types";
export default function DashboardHero() {
const { pageProps } = useContext(AppContext);
const user = pageProps?.user as User | undefined;
return (
<AdminHero
title={user?.first_name ? `Welcome, ${user.first_name}` : "Dashboard"}
description="An overview of your workspace. Pick up where you left off."
/>
);
}
@@ -0,0 +1,23 @@
import Section from "@moduletrace/twui/layout/Section";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import { DashboardShortcuts } from "../(data)/dashboard-shortcuts";
import DashboardShortcutCard from "../(partials)/dashboard-shortcut-card";
export default function DashboardShortcutsSection() {
return (
<Section>
<Stack className="w-full items-stretch gap-5!">
<Span className="htag">Jump back in</Span>
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{DashboardShortcuts.map((shortcut) => (
<DashboardShortcutCard
key={shortcut.url}
{...shortcut}
/>
))}
</div>
</Stack>
</Section>
);
}
@@ -0,0 +1,12 @@
export type BackupsTableColumn = {
title: string;
className?: string;
hide_title?: boolean;
};
export const BackupsTableColumns: BackupsTableColumn[] = [
{ title: "Backup" },
{ title: "Created" },
{ title: "Size", className: "hidden md:table-cell" },
{ title: "Actions", className: "text-right", hide_title: true },
];
@@ -0,0 +1,19 @@
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
type Params = {
label: string;
description: string;
};
export default function backupsPageMeta({
label,
description,
}: Params): BunextPageModuleMetaFn {
return async () => {
return {
title: `${label} | ${SiteData["SiteName"]}`,
description,
};
};
}
@@ -0,0 +1,17 @@
import adminFetchHandler from "@/src/functions/frontend/admin-fetch-handler";
export default async function createBackup() {
if (!window.confirm(`Create a new database backup now?`)) {
return { success: false, cancelled: true };
}
const res = await adminFetchHandler({
url: "/api/admin/backups/create",
method: "POST",
});
return {
success: Boolean(res.success),
msg: res.success ? undefined : res.msg || `Couldn't create backup`,
};
}
@@ -0,0 +1,27 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import grabDbBackups from "@/src/functions/backend/backups/grab-db-backups";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
/**
* Shared server props for the `/admin/backups` sub-pages. Super admin only.
*/
export default async function grabBackupsServerProps({ req }: Params) {
const user = await grabSuperAdminUser({ req });
if (!user?.id) {
return {
redirect: {
destination: "/admin",
},
};
}
return {
props: {
backups: grabDbBackups(),
},
};
}
@@ -0,0 +1,28 @@
import adminFetchHandler from "@/src/functions/frontend/admin-fetch-handler";
import type { DbBackup } from "@/src/types";
import dayjs from "dayjs";
type Params = {
backup: DbBackup;
};
export default async function restoreBackup({ backup }: Params) {
const confirm_msg = `Restore the database from the backup of ${dayjs(backup.created_at).format("MMM D, YYYY HH:mm:ss")}?\n\nAll current data will be replaced with the data in this backup. This cannot be undone. Consider creating a fresh backup first.`;
if (!window.confirm(confirm_msg)) {
return { success: false, cancelled: true };
}
const res = await adminFetchHandler({
url: "/api/admin/backups/restore",
method: "POST",
body: {
backup_name: backup.name,
},
});
return {
success: Boolean(res.success),
msg: res.success ? undefined : res.msg || `Couldn't restore backup`,
};
}
@@ -0,0 +1,45 @@
import Span from "@moduletrace/twui/layout/Span";
import formatBytes from "@/src/utils/format-bytes";
import type { DbBackup } from "@/src/types";
import dayjs from "dayjs";
import type { BackupsTableMode } from "./backups-table";
import DownloadBackupButton from "./download-backup-button";
import RestoreBackupButton from "./restore-backup-button";
type Props = {
backup: DbBackup;
mode: BackupsTableMode;
};
export default function BackupTableRow({ backup, mode }: Props) {
return (
<tr className="border-t border-slate-200 dark:border-white/10">
<td className="px-4 py-2.5 max-w-0 w-full">
<Span
size="small"
className="block truncate font-mono"
title={backup.name}
>
{backup.name}
</Span>
</td>
<td className="px-4 py-2.5 whitespace-nowrap">
<Span size="small">
{dayjs(backup.created_at).format("MMM D, YYYY HH:mm:ss")}
</Span>
</td>
<td className="hidden md:table-cell px-4 py-2.5 whitespace-nowrap">
<Span size="small" variant="faded">
{formatBytes({ bytes: backup.size })}
</Span>
</td>
<td className="px-4 py-2 text-right whitespace-nowrap">
{mode == "restore" ? (
<RestoreBackupButton backup={backup} />
) : (
<DownloadBackupButton backup={backup} />
)}
</td>
</tr>
);
}
@@ -0,0 +1,21 @@
import { twMerge } from "tailwind-merge";
import Span from "@moduletrace/twui/layout/Span";
import type { BackupsTableColumn } from "../(data)/backups-table-columns";
export default function BackupsTableHeadCell({
title,
className,
hide_title,
}: BackupsTableColumn) {
return (
<th className={twMerge("px-4 py-2.5 text-left font-medium", className)}>
<Span
size="smaller"
variant="faded"
className={twMerge(hide_title && "sr-only")}
>
{title}
</Span>
</th>
);
}
@@ -0,0 +1,37 @@
import Paper from "@moduletrace/twui/elements/Paper";
import type { DbBackup } from "@/src/types";
import { BackupsTableColumns } from "../(data)/backups-table-columns";
import BackupTableRow from "./backup-table-row";
import BackupsTableHeadCell from "./backups-table-head-cell";
export type BackupsTableMode = "view" | "restore";
type Props = {
backups: DbBackup[];
mode: BackupsTableMode;
};
export default function BackupsTable({ backups, mode }: Props) {
return (
<Paper className="p-0 gap-0 overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-white/5">
<tr>
{BackupsTableColumns.map((column) => (
<BackupsTableHeadCell {...column} key={column.title} />
))}
</tr>
</thead>
<tbody>
{backups.map((backup) => (
<BackupTableRow
backup={backup}
mode={mode}
key={backup.name}
/>
))}
</tbody>
</table>
</Paper>
);
}
@@ -0,0 +1,43 @@
import Button from "@moduletrace/twui/layout/Button";
import Tag from "@moduletrace/twui/elements/Tag";
import Row from "@moduletrace/twui/layout/Row";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import createBackup from "../(functions)/create-backup";
export default function CreateBackupButton() {
const { loading, setLoading, status, setStatus } = useStatus();
const create_backup = async () => {
setStatus(undefined);
setLoading(true);
const res = await createBackup();
if (res.success) {
window.location.reload();
return;
}
setLoading(false);
if (res.msg) {
setStatus({ error: true, msg: res.msg });
}
};
return (
<Row className="gap-2! flex-nowrap">
{status?.error && <Tag color="error">{status.msg}</Tag>}
<Button
title="Create Backup"
beforeIcon="DatabaseBackup"
loading={loading}
onClick={() => {
create_backup();
}}
>
Create backup
</Button>
</Row>
);
}
@@ -0,0 +1,30 @@
import Button from "@moduletrace/twui/layout/Button";
import type { DbBackup } from "@/src/types";
import { Download } from "lucide-react";
type Props = {
backup: DbBackup;
};
export default function DownloadBackupButton({ backup }: Props) {
return (
<Button
title="Download Backup"
variant="outlined"
color="gray"
size="small"
beforeIcon={<Download size={20} />}
href={`/api/admin/backups/download?backup_name=${encodeURIComponent(backup.name)}`}
linkProps={{
download: backup.name,
onClick: (e) => {
if (!window.confirm(`Download backup ${backup.name}?`)) {
e.preventDefault();
}
},
}}
>
Download
</Button>
);
}
@@ -0,0 +1,52 @@
import Button from "@moduletrace/twui/layout/Button";
import Tag from "@moduletrace/twui/elements/Tag";
import Row from "@moduletrace/twui/layout/Row";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import type { DbBackup } from "@/src/types";
import restoreBackup from "../(functions)/restore-backup";
import { ArchiveRestore } from "lucide-react";
type Props = {
backup: DbBackup;
};
export default function RestoreBackupButton({ backup }: Props) {
const { loading, setLoading, status, setStatus } = useStatus();
const restore_backup = async () => {
setStatus(undefined);
setLoading(true);
const res = await restoreBackup({ backup });
if (res.success) {
window.location.reload();
return;
}
setLoading(false);
if (res.msg) {
setStatus({ error: true, msg: res.msg });
}
};
return (
<Row className="gap-2! flex-nowrap justify-end">
{status?.error && <Tag color="error">{status.msg}</Tag>}
<Button
title="Restore Backup"
variant="outlined"
color="warning"
size="small"
beforeIcon={<ArchiveRestore size={20} />}
loading={loading}
onClick={() => {
restore_backup();
}}
>
Restore
</Button>
</Row>
);
}
@@ -0,0 +1,32 @@
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
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 BackupsTable, { type BackupsTableMode } from "../(partials)/backups-table";
type Props = {
mode: BackupsTableMode;
};
export default function BackupsListSection({ mode }: Props) {
const { pageProps } = useContext(AppContext);
const backups = pageProps?.backups || [];
return (
<Section>
<Stack className="w-full items-stretch">
<Span size="small" variant="faded">
{`${backups.length} backup${backups.length == 1 ? "" : "s"}`}
</Span>
{backups[0] ? (
<BackupsTable backups={backups} mode={mode} />
) : (
<EmptyContent title="No backups yet" />
)}
</Stack>
</Section>
);
}
@@ -0,0 +1,12 @@
import AdminHero from "@/src/components/general/admin-hero";
import CreateBackupButton from "../(partials)/create-backup-button";
export default function Hero() {
return (
<AdminHero
title="Backups"
description="Snapshots of the database. Older backups are pruned automatically."
buttons={<CreateBackupButton />}
/>
);
}
+7
View File
@@ -0,0 +1,7 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBackupsServerProps from "./(functions)/grab-backups-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return await grabBackupsServerProps(params);
};
+17
View File
@@ -0,0 +1,17 @@
import Hero from "./(sections)/hero";
import BackupsListSection from "./(sections)/backups-list-section";
import backupsPageMeta from "./(functions)/backups-page-meta";
export default function AdminBackupsPage() {
return (
<>
<Hero />
<BackupsListSection mode="view" />
</>
);
}
export const meta = backupsPageMeta({
label: "Backups",
description: "Create and download database backups",
});
@@ -0,0 +1,37 @@
type Params = {
file: File;
};
/**
* Sends the file as multipart form data (not base64 JSON) so large dumps
* aren't inflated.
*/
export default async function uploadRestoreBackup({ file }: Params) {
const confirm_msg = `Restore the database from "${file.name}"?\n\nAll current data will be replaced with the data in this file. This cannot be undone. Consider creating a fresh backup first.`;
if (!window.confirm(confirm_msg)) {
return { success: false, cancelled: true };
}
try {
const form_data = new FormData();
form_data.append("file", file);
const res = await fetch("/api/admin/backups/upload-restore", {
method: "POST",
body: form_data,
});
const json = await res.json();
return {
success: Boolean(json.success),
msg: json.success ? undefined : json.msg || `Couldn't restore backup`,
};
} catch (error: any) {
return {
success: false,
msg: error.message || `Couldn't restore backup`,
};
}
}
@@ -0,0 +1,62 @@
import FileUpload from "@moduletrace/twui/form/FileUpload";
import Button from "@moduletrace/twui/layout/Button";
import Tag from "@moduletrace/twui/elements/Tag";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import { useState } from "react";
import uploadRestoreBackup from "../(functions)/upload-restore-backup";
export default function UploadRestoreForm() {
const { loading, setLoading, status, setStatus } = useStatus();
const [file, setFile] = useState<File>();
const upload_restore = async () => {
if (!file) return;
setStatus(undefined);
setLoading(true);
const res = await uploadRestoreBackup({ file });
if (res.success) {
window.location.reload();
return;
}
setLoading(false);
if (res.msg) {
setStatus({ error: true, msg: res.msg });
}
};
return (
<Stack className="w-full items-stretch gap-4">
<FileUpload
label="Click or drop a SQLite database file"
className="h-[220px]"
fileInputProps={{ accept: ".db,.sqlite,.sqlite3" }}
changeHandler={(file_data) => {
setStatus(undefined);
setFile(file_data?.file);
}}
/>
<Row className="w-full justify-end gap-2! flex-nowrap">
{status?.error && <Tag color="error">{status.msg}</Tag>}
<Button
title="Upload And Restore Backup"
color="warning"
beforeIcon="Upload"
loading={loading}
disabled={!file || loading}
onClick={() => {
upload_restore();
}}
>
Upload & restore
</Button>
</Row>
</Stack>
);
}
@@ -0,0 +1,10 @@
import AdminHero from "@/src/components/general/admin-hero";
export default function Hero() {
return (
<AdminHero
title="Restore"
description="Replace all current data with the contents of a saved or uploaded backup. This cannot be undone."
/>
);
}
@@ -0,0 +1,25 @@
import Paper from "@moduletrace/twui/elements/Paper";
import H3 from "@moduletrace/twui/layout/H3";
import Section from "@moduletrace/twui/layout/Section";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import UploadRestoreForm from "../(partials)/upload-restore-form";
export default function UploadRestoreSection() {
return (
<Section className="pb-0!">
<Paper>
<Stack className="w-full items-stretch gap-4">
<Stack className="gap-1!">
<H3>Restore from a file</H3>
<Span size="small" variant="faded">
Upload a SQLite database file. It is saved to the
backups list, then restored.
</Span>
</Stack>
<UploadRestoreForm />
</Stack>
</Paper>
</Section>
);
}
@@ -0,0 +1,7 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBackupsServerProps from "../(functions)/grab-backups-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return await grabBackupsServerProps(params);
};
+19
View File
@@ -0,0 +1,19 @@
import Hero from "./(sections)/hero";
import UploadRestoreSection from "./(sections)/upload-restore-section";
import BackupsListSection from "../(sections)/backups-list-section";
import backupsPageMeta from "../(functions)/backups-page-meta";
export default function AdminBackupsRestorePage() {
return (
<>
<Hero />
<UploadRestoreSection />
<BackupsListSection mode="restore" />
</>
);
}
export const meta = backupsPageMeta({
label: "Restore Backup",
description: "Restore the database from a backup",
});
@@ -0,0 +1,48 @@
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);
}
}
@@ -0,0 +1,36 @@
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,
};
}
@@ -0,0 +1,102 @@
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>
)}
</>
);
}
@@ -0,0 +1,56 @@
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>
);
}
@@ -0,0 +1,42 @@
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>
);
}
@@ -0,0 +1,13 @@
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>
);
}
@@ -0,0 +1,36 @@
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>
</>
}
/>
);
}
@@ -0,0 +1,76 @@
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>
);
}
@@ -0,0 +1,90 @@
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>
);
}
@@ -0,0 +1,36 @@
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>
);
}
@@ -0,0 +1,98 @@
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>
)}
</>
}
/>
</>
);
}
@@ -0,0 +1,18 @@
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>
);
}
@@ -0,0 +1,21 @@
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>
</>
}
/>
);
}
@@ -0,0 +1,41 @@
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,
},
};
};
@@ -0,0 +1,24 @@
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",
};
};
@@ -0,0 +1,38 @@
import grabEmail from "@/src/functions/backend/db/documents/grab-email";
import grabEmailAttachments from "@/src/functions/backend/db/documents/grab-email-attachments";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const email_id = params.query?.id;
if (!email_id) {
return {
redirect: {
destination: "/admin/emails",
},
};
}
const email_res = await grabEmail({ id: email_id });
if (!email_res.singleRes) {
return {
redirect: {
destination: "/admin/emails",
},
};
}
const email = email_res.singleRes;
const attachments_res = await grabEmailAttachments({ email_id });
const attachments = attachments_res.payload || [];
return {
props: {
email,
attachments,
},
};
};
+24
View File
@@ -0,0 +1,24 @@
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",
};
};
@@ -0,0 +1,106 @@
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);
}
}
@@ -0,0 +1,22 @@
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>
);
}
@@ -0,0 +1,78 @@
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>
);
}
@@ -0,0 +1,292 @@
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>
);
}
@@ -0,0 +1,30 @@
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>
);
}
@@ -0,0 +1,79 @@
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>
);
}
@@ -0,0 +1,171 @@
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>
</>
);
}
@@ -0,0 +1,10 @@
import Section from "@moduletrace/twui/layout/Section";
import EmailForm from "../(partials)/email-form";
export default function EmailFormSection() {
return (
<Section>
<EmailForm />
</Section>
);
}
@@ -0,0 +1,22 @@
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>
</>
}
/>
);
}
@@ -0,0 +1,9 @@
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;
};
@@ -0,0 +1,37 @@
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 {};
}
}
@@ -0,0 +1,17 @@
import type { PagePropsType } from "@/src/types";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const user_types = params.props?.user_types;
if (isReadOnlyUser({ user_types })) {
return {
redirect: {
destination: "/admin/emails",
},
};
}
return {};
};
+20
View File
@@ -0,0 +1,20 @@
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import Hero from "./(sections)/hero";
import EmailFormSection from "./(sections)/email-form-section";
export default function CreateEmailPage() {
return (
<>
<Hero />
<EmailFormSection />
</>
);
}
export const meta: BunextPageModuleMetaFn = async (params) => {
return {
title: `Create New Email | ${SiteData["SiteName"]}`,
description: "Compose a new email draft",
};
};
+20
View File
@@ -0,0 +1,20 @@
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",
};
};
@@ -0,0 +1,186 @@
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>
);
}
@@ -0,0 +1,20 @@
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>
);
}
@@ -0,0 +1,21 @@
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",
}}
/>
);
}
@@ -0,0 +1,34 @@
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>
);
}
@@ -0,0 +1,10 @@
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"
/>
);
}
@@ -0,0 +1,33 @@
import { renderToStaticMarkup } from "react-dom/server";
import path from "path";
import type { FC } from "react";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import EmailTemplateContent from "./(data)/email-template-content";
export const server: BunextPageServerFn<PagePropsType> = async () => {
try {
const now = Date.now();
const EmailRoot = (
await import(path.join(process.cwd(), `src/email/root?t=${now}`))
).default as FC<any>;
const html = renderToStaticMarkup(
<EmailRoot>
<EmailTemplateContent />
</EmailRoot>,
);
return {
props: {
email_template_html: html,
},
};
} catch (error: any) {
return {
props: {
email_template_html: "",
},
};
}
};
+20
View File
@@ -0,0 +1,20 @@
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",
};
};
+18
View File
@@ -0,0 +1,18 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import DashboardHero from "./(sections)/dashboard-hero";
import DashboardShortcutsSection from "./(sections)/dashboard-shortcuts-section";
export default function AdminDashboardPage() {
return (
<>
<DashboardHero />
<DashboardShortcutsSection />
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `Admin Dashboard | ${SiteData["SiteName"]}`,
description: `Admin dashboard for managing the ${SiteData["SiteName"]} website.`,
};
@@ -0,0 +1,34 @@
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;
};
@@ -0,0 +1,34 @@
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,
};
}
@@ -0,0 +1,118 @@
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>
);
}
@@ -0,0 +1,98 @@
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>
);
}
@@ -0,0 +1,36 @@
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>
);
}
@@ -0,0 +1,103 @@
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>
);
}
@@ -0,0 +1,26 @@
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>
)}
</>
}
/>
);
}
@@ -0,0 +1,13 @@
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>
);
}
@@ -0,0 +1,29 @@
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>
);
}
@@ -0,0 +1,38 @@
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>
</>
}
/>
);
}
@@ -0,0 +1,153 @@
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>
);
}
@@ -0,0 +1,49 @@
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>
);
}
@@ -0,0 +1,21 @@
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>
</>
}
/>
);
}
@@ -0,0 +1,50 @@
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 || [],
},
};
};
@@ -0,0 +1,24 @@
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",
};
};
@@ -0,0 +1,36 @@
import grabInvoice, {
grabInvoiceItems,
} from "@/src/functions/backend/db/invoices/grab-invoice";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const invoice_id = params.query?.id;
if (!invoice_id) {
return {
redirect: {
destination: "/admin/invoices",
},
};
}
const invoice_res = await grabInvoice({ id: invoice_id });
if (!invoice_res.singleRes) {
return {
redirect: {
destination: "/admin/invoices",
},
};
}
const items_res = await grabInvoiceItems({ invoice_id });
return {
props: {
invoice: invoice_res.singleRes,
invoice_items: items_res.payload || [],
},
};
};
+24
View File
@@ -0,0 +1,24 @@
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",
};
};
@@ -0,0 +1,164 @@
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);
}
}
@@ -0,0 +1,68 @@
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>
</>
);
}
@@ -0,0 +1,23 @@
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>
);
}
@@ -0,0 +1,64 @@
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>
);
}
@@ -0,0 +1,34 @@
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,
}))}
/>
);
}
@@ -0,0 +1,134 @@
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>
);
}
@@ -0,0 +1,23 @@
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
/>
);
}

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