First Commit. Duplicate bunext-sqlite preset
This commit is contained in:
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 {};
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -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: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -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 || [],
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+23
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import dayjs from "dayjs";
|
||||
import { InvoiceStatuses, type InvoiceStatus } from "@/src/dict/document-dict";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import TinyMCEEditor from "@moduletrace/twui/editors/TinyMCE";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import useFormInit from "@/src/hooks/use-form-init";
|
||||
import type { InvoiceFormObject } from "../../../(data)/invoice-form-types";
|
||||
|
||||
export default function InvoiceFormStatusNotes({
|
||||
setForm,
|
||||
form,
|
||||
}: ReturnType<typeof useFormInit<InvoiceFormObject>>) {
|
||||
return (
|
||||
<Stack className="w-full gap-4">
|
||||
<H3 className="!mb-0">Dates, Status & Notes</H3>
|
||||
|
||||
<Row className="w-full grid xl:grid-cols-3">
|
||||
<Input
|
||||
title="Issue Date"
|
||||
type="date"
|
||||
defaultValue={
|
||||
form.issue_date
|
||||
? dayjs(Number(form.issue_date)).format(
|
||||
"YYYY-MM-DD",
|
||||
)
|
||||
: dayjs().format("YYYY-MM-DD")
|
||||
}
|
||||
onChange={(e) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
issue_date: e.target.value
|
||||
? new Date(e.target.value).getTime()
|
||||
: "",
|
||||
}));
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Input
|
||||
title="Due Date"
|
||||
type="date"
|
||||
defaultValue={
|
||||
form.due_date
|
||||
? dayjs(Number(form.due_date)).format("YYYY-MM-DD")
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
due_date: e.target.value
|
||||
? new Date(e.target.value).getTime()
|
||||
: "",
|
||||
}));
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Select
|
||||
title="Status"
|
||||
options={InvoiceStatuses.map((invoice_status) => ({
|
||||
title: invoice_status.title,
|
||||
value: invoice_status.value,
|
||||
default:
|
||||
invoice_status.value == (form.status || "draft"),
|
||||
}))}
|
||||
onChange={(e) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
status: e.target.value as InvoiceStatus,
|
||||
}));
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<TinyMCEEditor
|
||||
name="Notes"
|
||||
defaultValue={form.notes}
|
||||
changeHandler={(content) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
notes: content,
|
||||
}));
|
||||
}}
|
||||
placeholder="Eg. Payment is due within 14 days. Please make all transfers to the company bank account."
|
||||
options={{ height: 300 }}
|
||||
showLabel
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BUN_SQLITE_BSQLITE_INVOICES } from "@/db/types/db";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import useFormInit from "@/src/hooks/use-form-init";
|
||||
|
||||
export default function InvoiceFormTitle({
|
||||
setForm,
|
||||
form,
|
||||
}: ReturnType<typeof useFormInit<BUN_SQLITE_BSQLITE_INVOICES>>) {
|
||||
return (
|
||||
<Input
|
||||
title="Title"
|
||||
placeholder="Eg. Deposit Invoice"
|
||||
onChange={(e) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
title: e.target.value,
|
||||
}));
|
||||
}}
|
||||
className="text-xl"
|
||||
defaultValue={form.title}
|
||||
required
|
||||
showLabel
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import AdminHero from "@/src/components/general/admin-hero";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<AdminHero
|
||||
title="Create Invoice"
|
||||
description={`Generate a branded ${SiteData["SiteName"]} invoice priced in Naira`}
|
||||
buttons={
|
||||
<>
|
||||
<Button
|
||||
title="All Invoices"
|
||||
href="/admin/invoices"
|
||||
variant="outlined"
|
||||
>
|
||||
All Invoices
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import InvoiceForm from "../(partials)/invoice-form";
|
||||
|
||||
export default function InvoiceFormSection() {
|
||||
return (
|
||||
<Section>
|
||||
<InvoiceForm />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -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/invoices",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import Hero from "./(sections)/hero";
|
||||
import InvoiceFormSection from "./(sections)/invoice-form-section";
|
||||
|
||||
export default function CreateInvoicePage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<InvoiceFormSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
||||
const existing_invoice = (
|
||||
params.serverRes?.props as PagePropsType | undefined
|
||||
)?.invoice;
|
||||
|
||||
return {
|
||||
title: `${
|
||||
existing_invoice?.id ? "Edit" : "Create"
|
||||
} Invoice | ${SiteData["SiteName"]}`,
|
||||
description: "Invoice generator",
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import Hero from "./(sections)/hero";
|
||||
import InvoicesListSection from "./(sections)/invoices-list-section";
|
||||
|
||||
export default function InvoicesPage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<InvoicesListSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
||||
return {
|
||||
title: `Invoices | ${SiteData["SiteName"]}`,
|
||||
description: "Invoice generator",
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { BUN_SQLITE_BSQLITE_LETTERS } from "@/db/types/db";
|
||||
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export default function useLettersListInit(_props?: Props) {
|
||||
const {
|
||||
res: letters,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
query,
|
||||
setQuery,
|
||||
limit,
|
||||
setLimit,
|
||||
ready,
|
||||
setReady,
|
||||
} = useAdminCrudGet<BUN_SQLITE_BSQLITE_LETTERS>({
|
||||
table: "letters",
|
||||
});
|
||||
|
||||
return {
|
||||
letters,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
query,
|
||||
setQuery,
|
||||
limit,
|
||||
setLimit,
|
||||
ready,
|
||||
setReady,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { BUN_SQLITE_BSQLITE_LETTERS } from "@/db/types/db";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Tag from "@moduletrace/twui/elements/Tag";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
||||
import { downloadGeneratedPdf } from "@/src/functions/frontend/document-actions";
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
letter: BUN_SQLITE_BSQLITE_LETTERS;
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export default function DownloadLetterModal({
|
||||
letter,
|
||||
open,
|
||||
setOpen,
|
||||
}: Props) {
|
||||
const { loading, setLoading, status, setStatus } = useStatus();
|
||||
|
||||
const [to_name, setToName] = useState(
|
||||
letter.to_name || "",
|
||||
);
|
||||
const [to_address, setToAddress] = useState(
|
||||
letter.to_address || "",
|
||||
);
|
||||
const [letter_date, setLetterDate] = useState(
|
||||
dayjs().format("YYYY-MM-DD"),
|
||||
);
|
||||
|
||||
const download_pdf = async () => {
|
||||
setStatus(undefined);
|
||||
setLoading(true);
|
||||
|
||||
const res = await downloadGeneratedPdf({
|
||||
url: "/api/admin/letters/generate-pdf",
|
||||
body: {
|
||||
letter_id: letter.id,
|
||||
to_name,
|
||||
to_address,
|
||||
letter_date: new Date(letter_date).getTime(),
|
||||
},
|
||||
fallback_filename: `${letter.title || "letter"}.pdf`,
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
setStatus({
|
||||
error: false,
|
||||
success: true,
|
||||
msg: "Letter downloaded",
|
||||
});
|
||||
} else {
|
||||
setStatus({
|
||||
error: true,
|
||||
msg: res.msg || "Couldn't download letter",
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
<Stack className="w-full gap-4">
|
||||
<h3>Download Letter PDF</h3>
|
||||
|
||||
<p className="text-sm opacity-60">
|
||||
{letter.title}
|
||||
</p>
|
||||
|
||||
{status?.error && <Tag color="error">{status.msg}</Tag>}
|
||||
{status?.success && <Tag color="success">{status.msg}</Tag>}
|
||||
|
||||
<Input
|
||||
title="Recipient Name"
|
||||
defaultValue={to_name}
|
||||
onChange={(e) => {
|
||||
setToName(e.target.value);
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
title="Recipient Address"
|
||||
defaultValue={to_address}
|
||||
onChange={(e) => {
|
||||
setToAddress(e.target.value);
|
||||
}}
|
||||
showLabel
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Input
|
||||
title="Letter Date"
|
||||
type="date"
|
||||
defaultValue={letter_date}
|
||||
onChange={(e) => {
|
||||
setLetterDate(e.target.value);
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Download PDF"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
download_pdf();
|
||||
}}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { BUN_SQLITE_BSQLITE_LETTERS } from "@/db/types/db";
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import { useContext, useState } from "react";
|
||||
import DownloadLetterModal from "./download-letter-modal";
|
||||
|
||||
type Props = {
|
||||
letter: BUN_SQLITE_BSQLITE_LETTERS;
|
||||
is_view_letter_page?: boolean;
|
||||
};
|
||||
|
||||
export default function LetterActions({ letter, is_view_letter_page }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const is_read_only = isReadOnlyUser({
|
||||
user_types: pageProps?.user_types,
|
||||
});
|
||||
const [download_open, setDownloadOpen] = useState(false);
|
||||
|
||||
const view_url = `/admin/letters/${letter.id}`;
|
||||
const edit_url = `/admin/letters/${letter.id}/edit`;
|
||||
const duplicate_url = `/admin/letters/create?from=${letter.id}`;
|
||||
const new_email_url = `/admin/emails/create?from_letter=${letter.id}`;
|
||||
|
||||
return (
|
||||
<Row className="w-full justify-end gap-2!">
|
||||
{is_view_letter_page ? null : (
|
||||
<Button
|
||||
title="View Letter"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
href={view_url}
|
||||
>
|
||||
<LucideIcon name="Eye" size={16} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
title="Download Letter PDF"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setDownloadOpen(true);
|
||||
}}
|
||||
>
|
||||
<LucideIcon name="Download" size={16} />
|
||||
</Button>
|
||||
|
||||
{is_read_only ? null : (
|
||||
<>
|
||||
<Button
|
||||
title="Create a New Email from this Letter"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
href={new_email_url}
|
||||
>
|
||||
<LucideIcon name="MailPlus" size={16} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
title="Create a New Letter from this Record"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
href={duplicate_url}
|
||||
>
|
||||
<LucideIcon name="Copy" size={16} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
title="Edit Letter"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
href={edit_url}
|
||||
>
|
||||
<LucideIcon name="Edit2" size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DownloadLetterModal
|
||||
letter={letter}
|
||||
open={download_open}
|
||||
setOpen={setDownloadOpen}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { BUN_SQLITE_BSQLITE_LETTERS } from "@/db/types/db";
|
||||
import Card from "@moduletrace/twui/elements/Card";
|
||||
import Tag from "@moduletrace/twui/elements/Tag";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import dayjs from "dayjs";
|
||||
import LetterActions from "./letter-actions";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
|
||||
type Props = {
|
||||
letter: BUN_SQLITE_BSQLITE_LETTERS;
|
||||
};
|
||||
|
||||
export default function LetterCard({ letter }: Props) {
|
||||
const date_created = dayjs(Number(letter.created_at));
|
||||
|
||||
const view_url = `/admin/letters/${letter.id}`;
|
||||
const edit_url = `/admin/letters/${letter.id}/edit`;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Link href={view_url} className="text-foreground-light! dark:text-foreground-dark!">
|
||||
<Row className="w-full justify-between items-start">
|
||||
<Stack className="gap-2">
|
||||
<H3>{letter.title}</H3>
|
||||
<Row className="gap-2!">
|
||||
<Tag color="gray">Letter</Tag>
|
||||
{letter.subject ? (
|
||||
<Tag color="gray">{letter.subject}</Tag>
|
||||
) : null}
|
||||
{letter.to_name ? (
|
||||
<Tag color="gray">{letter.to_name}</Tag>
|
||||
) : null}
|
||||
<Tag color="gray">{date_created.toString()}</Tag>
|
||||
</Row>
|
||||
</Stack>
|
||||
</Row>
|
||||
</Link>
|
||||
|
||||
<LetterActions letter={letter} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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 useLettersListInit from "../(hooks)/use-letters-list-init";
|
||||
import LetterCard from "./letter-card";
|
||||
|
||||
export default function LettersList() {
|
||||
const init = useLettersListInit({});
|
||||
const { letters } = init;
|
||||
|
||||
return (
|
||||
<Stack className="w-full items-stretch">
|
||||
{!letters ? (
|
||||
<Paper>
|
||||
<Center className="p-20">
|
||||
<Loading />
|
||||
</Center>
|
||||
</Paper>
|
||||
) : !letters[0] ? (
|
||||
<Paper>
|
||||
<Center className="p-20">
|
||||
<Span>No Letters Found!</Span>
|
||||
</Center>
|
||||
</Paper>
|
||||
) : (
|
||||
letters.map((letter, index) => {
|
||||
return <LetterCard letter={letter} key={index} />;
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user