Compare commits
2 Commits
feat/admin
...
feat/templ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b28ba968e | ||
|
|
d4f6fa7dc4 |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "Documenso",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"version": "latest",
|
||||
"enableNonRootDocker": "true",
|
||||
"moby": "true"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {}
|
||||
},
|
||||
"onCreateCommand": "./.devcontainer/on-create.sh",
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
54320,
|
||||
9000,
|
||||
2500,
|
||||
1100
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Start the database and mailserver
|
||||
docker compose -f ./docker/compose-without-app.yml up -d
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Copy the env file
|
||||
cp .env.example .env
|
||||
|
||||
# Source the env file, export the variables
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
# Run the migrations
|
||||
npm run -w @documenso/prisma prisma:migrate-dev
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
npm run dev
|
||||
@@ -12,7 +12,7 @@ tags:
|
||||
|
||||
Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version.
|
||||
|
||||
Last week, Lucas shared the reasoning on [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
|
||||
Last week, Lucas shared the reasoning how [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
|
||||
|
||||
Today, I'm pleased to share with you a preview of the next Documenso.
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const path = require('path');
|
||||
const { version } = require('./package.json');
|
||||
|
||||
const { parsed: env } = require('dotenv').config({
|
||||
path: path.join(__dirname, '../../.env.local'),
|
||||
@@ -19,10 +18,7 @@ const config = {
|
||||
'@documenso/ui',
|
||||
'@documenso/email',
|
||||
],
|
||||
env: {
|
||||
...env,
|
||||
APP_VERSION: version,
|
||||
},
|
||||
env,
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
|
||||
import { AdminNav } from './nav';
|
||||
|
||||
export type AdminSectionLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<div className="grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
<AdminNav className="col-span-12 md:col-span-3 md:flex" />
|
||||
|
||||
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { BarChart3, User2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type AdminNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-x-2.5 gap-y-2 md:flex-col', className)} {...props}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/stats') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/stats">
|
||||
<BarChart3 className="mr-2 h-5 w-5" />
|
||||
Stats
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/users') && 'bg-secondary',
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
<User2 className="mr-2 h-5 w-5" />
|
||||
Users (Coming Soon)
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Admin() {
|
||||
redirect('/admin/stats');
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import {
|
||||
File,
|
||||
FileCheck,
|
||||
FileClock,
|
||||
FileEdit,
|
||||
Mail,
|
||||
MailOpen,
|
||||
PenTool,
|
||||
User as UserIcon,
|
||||
UserPlus2,
|
||||
UserSquare2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||
import {
|
||||
getUsersCount,
|
||||
getUsersWithSubscriptionsCount,
|
||||
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
|
||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||
|
||||
export default async function AdminStatsPage() {
|
||||
const [usersCount, usersWithSubscriptionsCount, docStats, recipientStats] = await Promise.all([
|
||||
getUsersCount(),
|
||||
getUsersWithSubscriptionsCount(),
|
||||
getDocumentStats(),
|
||||
getRecipientsStats(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">Instance Stats</h2>
|
||||
|
||||
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<CardMetric icon={UserIcon} title="Total Users" value={usersCount} />
|
||||
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
||||
<CardMetric
|
||||
icon={UserPlus2}
|
||||
title="Active Subscriptions"
|
||||
value={usersWithSubscriptionsCount}
|
||||
/>
|
||||
<CardMetric icon={UserPlus2} title="App Version" value={`v${process.env.APP_VERSION}`} />
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">Document metrics</h3>
|
||||
|
||||
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
|
||||
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
||||
<CardMetric icon={FileEdit} title="Drafted Documents" value={docStats.DRAFT} />
|
||||
<CardMetric icon={FileClock} title="Pending Documents" value={docStats.PENDING} />
|
||||
<CardMetric icon={FileCheck} title="Completed Documents" value={docStats.COMPLETED} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
|
||||
|
||||
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
|
||||
<CardMetric
|
||||
icon={UserSquare2}
|
||||
title="Total Recipients"
|
||||
value={recipientStats.TOTAL_RECIPIENTS}
|
||||
/>
|
||||
<CardMetric icon={Mail} title="Documents Received" value={recipientStats.SENT} />
|
||||
<CardMetric icon={MailOpen} title="Documents Viewed" value={recipientStats.OPENED} />
|
||||
<CardMetric icon={PenTool} title="Signatures Collected" value={recipientStats.SIGNED} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal file
124
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Clock, File, FileCheck } from 'lucide-react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { UploadDocument } from './upload-document';
|
||||
|
||||
const CARD_DATA = [
|
||||
{
|
||||
icon: FileCheck,
|
||||
title: 'Completed',
|
||||
status: InternalDocumentStatus.COMPLETED,
|
||||
},
|
||||
{
|
||||
icon: File,
|
||||
title: 'Drafts',
|
||||
status: InternalDocumentStatus.DRAFT,
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Pending',
|
||||
status: InternalDocumentStatus.PENDING,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
const [stats, results] = await Promise.all([
|
||||
getStats({
|
||||
user,
|
||||
}),
|
||||
findDocuments({
|
||||
userId: user.id,
|
||||
perPage: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="text-4xl font-semibold">Dashboard</h1>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{CARD_DATA.map((card) => (
|
||||
<Link key={card.status} href={`/documents?status=${card.status}`}>
|
||||
<CardMetric icon={card.icon} title={card.title} value={stats[card.status]} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
<UploadDocument />
|
||||
|
||||
<h2 className="mt-8 text-2xl font-semibold">Recent Documents</h2>
|
||||
|
||||
<div className="border-border mt-8 overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Reciepient</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{results.data.map((document) => {
|
||||
return (
|
||||
<TableRow key={document.id}>
|
||||
<TableCell className="font-medium">{document.id}</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/documents/${document.id}`}
|
||||
className="focus-visible:ring-ring ring-offset-background rounded-md font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
{document.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<StackAvatarsWithTooltip recipients={document.Recipient} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<DocumentStatus status={document.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<LocaleDate date={document.created} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{results.data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export const EditDocumentForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/documents');
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
|
||||
export type DataTableTitleProps = {
|
||||
row: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
};
|
||||
|
||||
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||
|
||||
const isOwner = row.User.id === session.user.id;
|
||||
const isRecipient = !!recipient;
|
||||
|
||||
return match({
|
||||
isOwner,
|
||||
isRecipient,
|
||||
})
|
||||
.with({ isOwner: true }, () => (
|
||||
<Link
|
||||
href={`/documents/${row.id}`}
|
||||
title={row.title}
|
||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||
>
|
||||
{row.title}
|
||||
</Link>
|
||||
))
|
||||
.with({ isRecipient: true }, () => (
|
||||
<Link
|
||||
href={`/sign/${recipient?.token}`}
|
||||
title={row.title}
|
||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||
>
|
||||
{row.title}
|
||||
</Link>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
|
||||
{row.title}
|
||||
</span>
|
||||
));
|
||||
};
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
@@ -17,7 +18,6 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { DataTableActionButton } from './data-table-action-button';
|
||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||
import { DataTableTitle } from './data-table-title';
|
||||
|
||||
export type DocumentsDataTableProps = {
|
||||
results: FindResultSet<
|
||||
@@ -29,7 +29,6 @@ export type DocumentsDataTableProps = {
|
||||
};
|
||||
|
||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
@@ -43,22 +42,25 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'Created',
|
||||
accessorKey: 'created',
|
||||
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Title',
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/documents/${row.original.id}`}
|
||||
title={row.original.title}
|
||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||
>
|
||||
{row.original.title}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Recipient',
|
||||
@@ -72,6 +74,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
||||
},
|
||||
{
|
||||
header: 'Created',
|
||||
accessorKey: 'created',
|
||||
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => (
|
||||
@@ -88,7 +95,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
{(table) => <DataTablePagination table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
|
||||
@@ -11,8 +11,8 @@ import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-
|
||||
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { UploadDocument } from '../dashboard/upload-document';
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
import { UploadDocument } from './upload-document';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: {
|
||||
@@ -81,7 +81,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats[value], 99)}
|
||||
{stats[value] > 99 && '+'}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
166
apps/web/src/app/(dashboard)/template/page.tsx
Normal file
166
apps/web/src/app/(dashboard)/template/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { AddTemplateFormPartial } from '@documenso/ui/primitives/document-flow/add-template-details';
|
||||
import { TAddTemplateSchema } from '@documenso/ui/primitives/document-flow/add-template-details.types';
|
||||
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-template-fields';
|
||||
import { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-template-fields.types';
|
||||
import {
|
||||
DocumentFlowFormContainer,
|
||||
DocumentFlowFormContainerHeader,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { toast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type TemplatePageFlowStep = 'details' | 'fields';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function TemplatePage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [uploadedFile, setUploadedFile] = useState<{ name: string; file: string } | null>();
|
||||
const [step, setStep] = useState<TemplatePageFlowStep>('details');
|
||||
const [fields, setFields] = useState<Field[]>([]);
|
||||
|
||||
const documentFlow: Record<TemplatePageFlowStep, DocumentFlowStep> = {
|
||||
details: {
|
||||
title: 'Add Details',
|
||||
description: 'Add the name and description of your template.',
|
||||
stepIndex: 1,
|
||||
onSubmit: () => onAddTemplateDetailsFormSubmit,
|
||||
},
|
||||
fields: {
|
||||
title: 'Add Fields',
|
||||
description: 'Add all relevant fields for each recipient.',
|
||||
stepIndex: 2,
|
||||
onBackStep: () => setStep('details'),
|
||||
onSubmit: () => onAddTemplateFieldsFormSubmit,
|
||||
},
|
||||
};
|
||||
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
const onAddTemplateDetailsFormSubmit = (data: TAddTemplateSchema) => {
|
||||
if (!uploadedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
|
||||
const templateDetails = {
|
||||
document: uploadedFile,
|
||||
...data,
|
||||
};
|
||||
|
||||
console.log(templateDetails);
|
||||
|
||||
setStep('fields');
|
||||
};
|
||||
|
||||
const onAddTemplateFieldsFormSubmit = (data: TAddTemplateFieldsFormSchema) => {
|
||||
if (!uploadedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(data);
|
||||
|
||||
console.log('Submit fields in document flow');
|
||||
};
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const base64String = Buffer.from(arrayBuffer).toString('base64');
|
||||
|
||||
setUploadedFile({
|
||||
name: file.name,
|
||||
file: `data:application/pdf;base64,${base64String}`,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const placeholderRecipient: Recipient[] = [
|
||||
{
|
||||
id: -1,
|
||||
documentId: -1,
|
||||
email: '',
|
||||
name: '',
|
||||
token: '',
|
||||
expired: null,
|
||||
signedAt: null,
|
||||
readStatus: 'OPENED',
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="mt-4 max-w-xs truncate text-2xl font-semibold md:text-3xl" title="Templates">
|
||||
Templates
|
||||
</h1>
|
||||
|
||||
<div className="mt-12 grid w-full grid-cols-12 gap-8">
|
||||
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||
{uploadedFile ? (
|
||||
<Card gradient>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer document={uploadedFile.file} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={currentDocumentFlow.title}
|
||||
description={currentDocumentFlow.description}
|
||||
/>
|
||||
|
||||
{step === 'details' && (
|
||||
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
||||
<AddTemplateFormPartial
|
||||
documentFlow={documentFlow.details}
|
||||
fields={fields}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddTemplateDetailsFormSubmit}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{step === 'fields' && (
|
||||
<AddTemplateFieldsFormPartial
|
||||
documentFlow={documentFlow.fields}
|
||||
recipients={placeholderRecipient}
|
||||
fields={fields}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddTemplateFieldsFormSubmit}
|
||||
/>
|
||||
)}
|
||||
</DocumentFlowFormContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
|
||||
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
@@ -47,8 +45,6 @@ export const metadata = {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const flags = await getServerComponentAllFlags();
|
||||
|
||||
const locale = getLocale();
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
@@ -67,18 +63,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
</Suspense>
|
||||
|
||||
<body>
|
||||
<LocaleProvider locale={locale}>
|
||||
<FeatureFlagProvider initialFlags={flags}>
|
||||
<PlausibleProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
<TrpcProvider>{children}</TrpcProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</PlausibleProvider>
|
||||
<Toaster />
|
||||
</FeatureFlagProvider>
|
||||
</LocaleProvider>
|
||||
<FeatureFlagProvider initialFlags={flags}>
|
||||
<PlausibleProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
<TrpcProvider>{children}</TrpcProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</PlausibleProvider>
|
||||
<Toaster />
|
||||
</FeatureFlagProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ export type StackAvatarProps = {
|
||||
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
|
||||
};
|
||||
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => {
|
||||
let classes = '';
|
||||
let zIndexClass = '';
|
||||
const firstClass = first ? '' : '-ml-3';
|
||||
@@ -48,7 +48,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
|
||||
${firstClass}
|
||||
dark:border-border h-10 w-10 border-2 border-solid border-white`}
|
||||
>
|
||||
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
|
||||
<AvatarFallback className={classes}>{fallbackText ?? 'UK'}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { initials } from '@documenso/lib/client-only/recipient-initials';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -56,7 +56,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
fallbackText={initials(recipient.name)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
</div>
|
||||
@@ -73,7 +73,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
fallbackText={initials(recipient.name)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
fallbackText={initials(recipient.name)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
</div>
|
||||
@@ -107,7 +107,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
fallbackText={initials(recipient.name)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { initials } from '@documenso/lib/client-only/recipient-initials';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
@@ -26,7 +26,7 @@ export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
|
||||
first={first}
|
||||
zIndex={String(zIndex - index * 10)}
|
||||
type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)}
|
||||
fallbackText={lastItemText ? lastItemText : recipientAbbreviation(recipient)}
|
||||
fallbackText={lastItemText ? lastItemText : initials(recipient.name)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,13 +11,10 @@ import {
|
||||
Monitor,
|
||||
Moon,
|
||||
Sun,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -38,21 +35,24 @@ export type ProfileDropdownProps = {
|
||||
|
||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
|
||||
const avatarFallback = user.name
|
||||
? recipientInitials(user.name)
|
||||
: user.email.slice(0, 1).toUpperCase();
|
||||
const initials =
|
||||
user.name
|
||||
?.split(' ')
|
||||
.map((name: string) => name.slice(0, 1).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('') ?? 'UK';
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -60,19 +60,6 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||
|
||||
{isUserAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<UserCog className="mr-2 h-4 w-4" />
|
||||
Admin
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/profile" className="cursor-pointer">
|
||||
<LucideUser className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -18,10 +18,10 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
|
||||
)}
|
||||
>
|
||||
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
|
||||
<div className="flex items-center">
|
||||
{Icon && <Icon className="text-muted-foreground mr-2 h-4 w-4" />}
|
||||
<div className="flex items-start">
|
||||
{Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />}
|
||||
|
||||
<h3 className="text-primary-forground flex items-end text-sm font-medium">{title}</h3>
|
||||
<h3 className="flex items-end text-sm font-medium text-slate-500">{title}</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">
|
||||
|
||||
@@ -2,31 +2,16 @@
|
||||
|
||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import { DateTime, DateTimeFormatOptions } from 'luxon';
|
||||
|
||||
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
||||
|
||||
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
date: string | number | Date;
|
||||
format?: DateTimeFormatOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the date based on the user locale.
|
||||
*
|
||||
* Will use the estimated locale from the user headers on SSR, then will use
|
||||
* the client browser locale once mounted.
|
||||
*/
|
||||
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
|
||||
const { locale } = useLocale();
|
||||
|
||||
const [localeDate, setLocaleDate] = useState(() =>
|
||||
DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format),
|
||||
);
|
||||
export const LocaleDate = ({ className, date, ...props }: LocaleDateProps) => {
|
||||
const [localeDate, setLocaleDate] = useState(() => new Date(date).toISOString());
|
||||
|
||||
useEffect(() => {
|
||||
setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
|
||||
}, [date, format]);
|
||||
setLocaleDate(new Date(date).toLocaleString());
|
||||
}, [date]);
|
||||
|
||||
return (
|
||||
<span className={className} {...props}>
|
||||
|
||||
@@ -18,15 +18,13 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
const ErrorMessages = {
|
||||
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
||||
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
||||
[ErrorCode.USER_MISSING_PASSWORD]:
|
||||
'This account appears to be using a social login method, please sign in using that method',
|
||||
};
|
||||
|
||||
const LOGIN_REDIRECT_PATH = '/documents';
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
password: z.string().min(6).max(72),
|
||||
@@ -39,9 +37,8 @@ export type SignInFormProps = {
|
||||
};
|
||||
|
||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { toast } = useToast();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -64,7 +61,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
timeout = setTimeout(() => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred',
|
||||
description: ErrorMessages[errorCode] ?? 'An unknown error occurred',
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
@@ -81,10 +78,12 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||
callbackUrl: '/documents',
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
// throw new Error('Not implemented');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
@@ -96,7 +95,8 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
|
||||
const onSignInWithGoogleClick = async () => {
|
||||
try {
|
||||
await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH });
|
||||
await signIn('google', { callbackUrl: '/dashboard' });
|
||||
// throw new Error('Not implemented');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
|
||||
export type AddSignersActionInput = TAddSignersFormSchema & {
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const addSigners = async ({ documentId, signers }: AddSignersActionInput) => {
|
||||
'use server';
|
||||
|
||||
const { id: userId } = await getRequiredServerComponentSession();
|
||||
|
||||
await setRecipientsForDocument({
|
||||
userId,
|
||||
documentId,
|
||||
recipients: signers.map((signer) => ({
|
||||
id: signer.nativeId,
|
||||
email: signer.email,
|
||||
name: signer.name,
|
||||
})),
|
||||
});
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
|
||||
"dev": "turbo run dev --filter=@documenso/{web,marketing}",
|
||||
"start": "cd apps && cd web && next start",
|
||||
"lint": "turbo run lint",
|
||||
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type LocaleContextValue = {
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
export const useLocale = () => {
|
||||
const context = useContext(LocaleContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useLocale must be used within a LocaleProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export function LocaleProvider({
|
||||
children,
|
||||
locale,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
locale: string;
|
||||
}) {
|
||||
return (
|
||||
<LocaleContext.Provider
|
||||
value={{
|
||||
locale: locale,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
6
packages/lib/client-only/recipient-initials.ts
Normal file
6
packages/lib/client-only/recipient-initials.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const initials = (text: string) =>
|
||||
text
|
||||
?.split(' ')
|
||||
.map((name: string) => name.slice(0, 1).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('') ?? 'UK';
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Role, User } from '@documenso/prisma/client';
|
||||
|
||||
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
|
||||
|
||||
export { isAdmin };
|
||||
@@ -1,26 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
export const getDocumentStats = async () => {
|
||||
const counts = await prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
|
||||
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
};
|
||||
|
||||
counts.forEach((stat) => {
|
||||
stats[stat.status] = stat._count._all;
|
||||
|
||||
stats.ALL += stat._count._all;
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export const getRecipientsStats = async () => {
|
||||
const results = await prisma.recipient.groupBy({
|
||||
by: ['readStatus', 'signingStatus', 'sendStatus'],
|
||||
_count: true,
|
||||
});
|
||||
|
||||
const stats = {
|
||||
TOTAL_RECIPIENTS: 0,
|
||||
[ReadStatus.OPENED]: 0,
|
||||
[ReadStatus.NOT_OPENED]: 0,
|
||||
[SigningStatus.SIGNED]: 0,
|
||||
[SigningStatus.NOT_SIGNED]: 0,
|
||||
[SendStatus.SENT]: 0,
|
||||
[SendStatus.NOT_SENT]: 0,
|
||||
};
|
||||
|
||||
results.forEach((result) => {
|
||||
const { readStatus, signingStatus, sendStatus, _count } = result;
|
||||
stats[readStatus] += _count;
|
||||
stats[signingStatus] += _count;
|
||||
stats[sendStatus] += _count;
|
||||
stats.TOTAL_RECIPIENTS += _count;
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
export const getUsersCount = async () => {
|
||||
return await prisma.user.count();
|
||||
};
|
||||
|
||||
export const getUsersWithSubscriptionsCount = async () => {
|
||||
return await prisma.user.count({
|
||||
where: {
|
||||
Subscription: {
|
||||
some: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
38
packages/lib/server-only/template/create-template.ts
Normal file
38
packages/lib/server-only/template/create-template.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
'use server';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentContent } from '@documenso/prisma/client';
|
||||
|
||||
type CreateTemplateInput = {
|
||||
name: string;
|
||||
description: string;
|
||||
document: DocumentContent;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const createTemplate = async ({
|
||||
name,
|
||||
description,
|
||||
document,
|
||||
userId,
|
||||
}: CreateTemplateInput) => {
|
||||
const createTemplateDocument = await prisma.documentContent.create({
|
||||
data: {
|
||||
content: document.content,
|
||||
name: document.name,
|
||||
},
|
||||
});
|
||||
|
||||
const createdTemplate = await prisma.template.create({
|
||||
data: {
|
||||
name,
|
||||
slug: nanoid(10),
|
||||
description,
|
||||
ownerId: userId,
|
||||
documentId: createTemplateDocument.id,
|
||||
},
|
||||
});
|
||||
return createdTemplate;
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
|
||||
export const recipientInitials = (text: string) =>
|
||||
text
|
||||
.split(' ')
|
||||
.map((name: string) => name.slice(0, 1).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('');
|
||||
|
||||
export const recipientAbbreviation = (recipient: Recipient) => {
|
||||
return recipientInitials(recipient.name) || recipient.email.slice(0, 1).toUpperCase();
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "roles" "Role"[] DEFAULT ARRAY['USER']::"Role"[];
|
||||
@@ -0,0 +1,34 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TemplateType" AS ENUM ('PRIVATE', 'PUBLIC');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Template" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"kind" "TemplateType" NOT NULL,
|
||||
"ownerId" INTEGER NOT NULL,
|
||||
"documentId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DocumentContent" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "DocumentContent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Template_slug_key" ON "Template"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Template_ownerId_idx" ON "Template"("ownerId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Template" ADD CONSTRAINT "Template_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Template" ADD CONSTRAINT "Template_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "DocumentContent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `DocumentContent` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `Template` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_documentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_ownerId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "DocumentContent";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Template";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "TemplateType";
|
||||
@@ -0,0 +1,34 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TemplateType" AS ENUM ('PRIVATE', 'PUBLIC');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Template" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"kind" "TemplateType" NOT NULL,
|
||||
"ownerId" INTEGER NOT NULL,
|
||||
"documentId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DocumentContent" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "DocumentContent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Template_slug_key" ON "Template"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Template_ownerId_idx" ON "Template"("ownerId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Template" ADD CONSTRAINT "Template_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Template" ADD CONSTRAINT "Template_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "DocumentContent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" ALTER COLUMN "kind" SET DEFAULT 'PRIVATE';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" ADD COLUMN "description" TEXT;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `name` to the `DocumentContent` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentContent" ADD COLUMN "name" TEXT NOT NULL;
|
||||
@@ -13,11 +13,6 @@ enum IdentityProvider {
|
||||
GOOGLE
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
@@ -26,12 +21,12 @@ model User {
|
||||
password String?
|
||||
source String?
|
||||
signature String?
|
||||
roles Role[] @default([USER])
|
||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
Document Document[]
|
||||
Subscription Subscription[]
|
||||
Template Template[]
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
@@ -172,3 +167,29 @@ model Signature {
|
||||
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict)
|
||||
}
|
||||
|
||||
enum TemplateType {
|
||||
PRIVATE
|
||||
PUBLIC
|
||||
}
|
||||
|
||||
model Template {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
slug String @unique
|
||||
description String?
|
||||
kind TemplateType @default(PRIVATE)
|
||||
ownerId Int
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
documentId Int
|
||||
document DocumentContent @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
model DocumentContent {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
content String
|
||||
templates Template[]
|
||||
}
|
||||
|
||||
@@ -1,46 +1,19 @@
|
||||
import { Table } from '@tanstack/react-table';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Button } from './button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
|
||||
/**
|
||||
* The type of information to show on the left hand side of the pagination.
|
||||
*
|
||||
* Defaults to 'VisibleCount'.
|
||||
*/
|
||||
additionalInformation?: 'SelectedCount' | 'VisibleCount' | 'None';
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
additionalInformation = 'VisibleCount',
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-4 px-2">
|
||||
<div className="text-muted-foreground flex-1 text-sm">
|
||||
{match(additionalInformation)
|
||||
.with('SelectedCount', () => (
|
||||
<span>
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{' '}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</span>
|
||||
))
|
||||
.with('VisibleCount', () => {
|
||||
const visibleRows = table.getFilteredRowModel().rows.length;
|
||||
|
||||
return (
|
||||
<span>
|
||||
Showing {visibleRows} result{visibleRows > 1 && 's'}.
|
||||
</span>
|
||||
);
|
||||
})
|
||||
.with('None', () => null)
|
||||
.exhaustive()}
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{' '}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
|
||||
@@ -102,7 +102,6 @@ export const AddFieldsFormPartial = ({
|
||||
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
|
||||
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
|
||||
|
||||
@@ -315,7 +314,7 @@ export const AddFieldsFormPartial = ({
|
||||
))}
|
||||
|
||||
{!hideRecipients && (
|
||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -325,7 +324,7 @@ export const AddFieldsFormPartial = ({
|
||||
>
|
||||
{selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedSigner?.name} ({selectedSigner?.email})
|
||||
{selectedSigner?.email} ({selectedSigner?.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -349,10 +348,7 @@ export const AddFieldsFormPartial = ({
|
||||
className={cn({
|
||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||
})}
|
||||
onSelect={() => {
|
||||
setSelectedSigner(recipient);
|
||||
setShowRecipientsSelector(false);
|
||||
}}
|
||||
onSelect={() => setSelectedSigner(recipient)}
|
||||
>
|
||||
{recipient.sendStatus !== SendStatus.SENT ? (
|
||||
<Check
|
||||
|
||||
103
packages/ui/primitives/document-flow/add-template-details.tsx
Normal file
103
packages/ui/primitives/document-flow/add-template-details.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Field } from '@documenso/prisma/client';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
|
||||
import { TAddTemplateSchema, ZAddTemplateSchema } from './add-template-details.types';
|
||||
import {
|
||||
DocumentFlowFormContainerActions,
|
||||
DocumentFlowFormContainerContent,
|
||||
DocumentFlowFormContainerFooter,
|
||||
DocumentFlowFormContainerStep,
|
||||
} from './document-flow-root';
|
||||
import { DocumentFlowStep } from './types';
|
||||
|
||||
export type AddTemplateFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
fields: Field[];
|
||||
numberOfSteps: number;
|
||||
onSubmit: (_data: TAddTemplateSchema) => void;
|
||||
};
|
||||
|
||||
export const AddTemplateFormPartial = ({
|
||||
documentFlow,
|
||||
numberOfSteps,
|
||||
fields: _fields,
|
||||
onSubmit,
|
||||
}: AddTemplateFormProps) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TAddTemplateSchema>({
|
||||
resolver: zodResolver(ZAddTemplateSchema),
|
||||
defaultValues: {
|
||||
template: {
|
||||
name: '',
|
||||
description: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = handleSubmit(onSubmit);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerContent>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
|
||||
<Input
|
||||
id="name"
|
||||
className="bg-background mt-2"
|
||||
disabled={isSubmitting}
|
||||
{...register('template.name')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-2" error={errors.template?.name} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">
|
||||
Description <span className="text-muted-foreground">(Optional)</span>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="description"
|
||||
className="bg-background mt-2"
|
||||
disabled={isSubmitting}
|
||||
{...register('template.description')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-2" error={errors.template?.description} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerStep
|
||||
title={documentFlow.title}
|
||||
step={documentFlow.stepIndex}
|
||||
maxStep={numberOfSteps}
|
||||
/>
|
||||
|
||||
<DocumentFlowFormContainerActions
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
onGoBackClick={documentFlow.onBackStep}
|
||||
onGoNextClick={() => void onFormSubmit()}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZAddTemplateSchema = z.object({
|
||||
template: z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TAddTemplateSchema = z.infer<typeof ZAddTemplateSchema>;
|
||||
418
packages/ui/primitives/document-flow/add-template-fields.tsx
Normal file
418
packages/ui/primitives/document-flow/add-template-fields.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Caveat } from 'next/font/google';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
|
||||
import {
|
||||
DocumentFlowFormContainerActions,
|
||||
DocumentFlowFormContainerContent,
|
||||
DocumentFlowFormContainerFooter,
|
||||
DocumentFlowFormContainerStep,
|
||||
} from './document-flow-root';
|
||||
import { FieldItem } from './field-item';
|
||||
import { DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
|
||||
|
||||
const fontCaveat = Caveat({
|
||||
weight: ['500'],
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-caveat',
|
||||
});
|
||||
|
||||
const DEFAULT_HEIGHT_PERCENT = 5;
|
||||
const DEFAULT_WIDTH_PERCENT = 15;
|
||||
|
||||
const MIN_HEIGHT_PX = 60;
|
||||
const MIN_WIDTH_PX = 200;
|
||||
|
||||
export type AddTemplateFieldsFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
numberOfSteps: number;
|
||||
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
|
||||
};
|
||||
|
||||
export const AddTemplateFieldsFormPartial = ({
|
||||
documentFlow,
|
||||
recipients,
|
||||
fields,
|
||||
numberOfSteps,
|
||||
onSubmit,
|
||||
}: AddTemplateFieldsFormProps) => {
|
||||
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<TAddTemplateFieldsFormSchema>({
|
||||
defaultValues: {
|
||||
fields: fields.map((field) => ({
|
||||
nativeId: field.id,
|
||||
formId: `${field.id}-${field.documentId}`,
|
||||
pageNumber: field.page,
|
||||
type: field.type,
|
||||
pageX: Number(field.positionX),
|
||||
pageY: Number(field.positionY),
|
||||
pageWidth: Number(field.width),
|
||||
pageHeight: Number(field.height),
|
||||
signerEmail:
|
||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = handleSubmit(onSubmit);
|
||||
|
||||
const {
|
||||
append,
|
||||
remove,
|
||||
update,
|
||||
fields: localFields,
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: 'fields',
|
||||
});
|
||||
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
||||
|
||||
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
|
||||
|
||||
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const fieldBounds = useRef({
|
||||
height: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
setIsFieldWithinBounds(
|
||||
isWithinPageBounds(
|
||||
event,
|
||||
PDF_VIEWER_PAGE_SELECTOR,
|
||||
fieldBounds.current.width,
|
||||
fieldBounds.current.height,
|
||||
),
|
||||
);
|
||||
|
||||
setCoords({
|
||||
x: event.clientX - fieldBounds.current.width / 2,
|
||||
y: event.clientY - fieldBounds.current.height / 2,
|
||||
});
|
||||
},
|
||||
[isWithinPageBounds],
|
||||
);
|
||||
|
||||
const onMouseClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!selectedField || !selectedSigner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (
|
||||
!$page ||
|
||||
!isWithinPageBounds(
|
||||
event,
|
||||
PDF_VIEWER_PAGE_SELECTOR,
|
||||
fieldBounds.current.width,
|
||||
fieldBounds.current.height,
|
||||
)
|
||||
) {
|
||||
setSelectedField(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||
|
||||
// Calculate x and y as a percentage of the page width and height
|
||||
let pageX = ((event.pageX - left) / width) * 100;
|
||||
let pageY = ((event.pageY - top) / height) * 100;
|
||||
|
||||
// Get the bounds as a percentage of the page width and height
|
||||
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
|
||||
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
|
||||
|
||||
// And center it based on the bounds
|
||||
pageX -= fieldPageWidth / 2;
|
||||
pageY -= fieldPageHeight / 2;
|
||||
|
||||
append({
|
||||
formId: nanoid(12),
|
||||
type: selectedField,
|
||||
pageNumber,
|
||||
pageX,
|
||||
pageY,
|
||||
pageWidth: fieldPageWidth,
|
||||
pageHeight: fieldPageHeight,
|
||||
signerEmail: selectedSigner.email,
|
||||
});
|
||||
|
||||
setIsFieldWithinBounds(false);
|
||||
setSelectedField(null);
|
||||
},
|
||||
[append, isWithinPageBounds, selectedField, selectedSigner, getPage],
|
||||
);
|
||||
|
||||
const onFieldResize = useCallback(
|
||||
(node: HTMLElement, index: number) => {
|
||||
const field = localFields[index];
|
||||
|
||||
const $page = window.document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
x: pageX,
|
||||
y: pageY,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
} = getFieldPosition($page, node);
|
||||
|
||||
update(index, {
|
||||
...field,
|
||||
pageX,
|
||||
pageY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
},
|
||||
[getFieldPosition, localFields, update],
|
||||
);
|
||||
|
||||
const onFieldMove = useCallback(
|
||||
(node: HTMLElement, index: number) => {
|
||||
const field = localFields[index];
|
||||
|
||||
const $page = window.document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x: pageX, y: pageY } = getFieldPosition($page, node);
|
||||
|
||||
update(index, {
|
||||
...field,
|
||||
pageX,
|
||||
pageY,
|
||||
});
|
||||
},
|
||||
[getFieldPosition, localFields, update],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedField) {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('click', onMouseClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('click', onMouseClick);
|
||||
};
|
||||
}, [onMouseClick, onMouseMove, selectedField]);
|
||||
|
||||
useEffect(() => {
|
||||
const $page = window.document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, width } = $page.getBoundingClientRect();
|
||||
|
||||
fieldBounds.current = {
|
||||
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
|
||||
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
|
||||
}, [recipients]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerContent>
|
||||
<div className="flex flex-col">
|
||||
{selectedField && (
|
||||
<Card
|
||||
className={cn(
|
||||
'pointer-events-none fixed z-50 cursor-pointer bg-white transition-opacity',
|
||||
{
|
||||
'border-primary': isFieldWithinBounds,
|
||||
'opacity-50': !isFieldWithinBounds,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
top: coords.y,
|
||||
left: coords.x,
|
||||
height: fieldBounds.current.height,
|
||||
width: fieldBounds.current.width,
|
||||
}}
|
||||
>
|
||||
<CardContent className="text-foreground flex h-full w-full items-center justify-center p-2">
|
||||
{FRIENDLY_FIELD_TYPE[selectedField]}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{localFields.map((field, index) => (
|
||||
<FieldItem
|
||||
key={index}
|
||||
field={field}
|
||||
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
|
||||
minHeight={fieldBounds.current.height}
|
||||
minWidth={fieldBounds.current.width}
|
||||
passive={isFieldWithinBounds && !!selectedField}
|
||||
onResize={(options) => onFieldResize(options, index)}
|
||||
onMove={(options) => onFieldMove(options, index)}
|
||||
onRemove={() => remove(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="-mx-2 flex-1 overflow-y-scroll px-2">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-3xl font-medium',
|
||||
fontCaveat.className,
|
||||
)}
|
||||
>
|
||||
{selectedSigner?.name || 'Signature'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Email'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Email</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Name'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Name</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Date'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Date</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerStep
|
||||
title={documentFlow.title}
|
||||
step={documentFlow.stepIndex}
|
||||
maxStep={numberOfSteps}
|
||||
/>
|
||||
|
||||
<DocumentFlowFormContainerActions
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
onGoBackClick={documentFlow.onBackStep}
|
||||
onGoNextClick={() => void onFormSubmit()}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export const ZAddTemplateFieldsFormSchema = z.object({
|
||||
fields: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TAddTemplateFieldsFormSchema = z.infer<typeof ZAddTemplateFieldsFormSchema>;
|
||||
10
templates
Normal file
10
templates
Normal file
@@ -0,0 +1,10 @@
|
||||
Plan
|
||||
|
||||
Take the document
|
||||
Take the details of the template
|
||||
Take the fields
|
||||
---??? Store it in the database with a templates schema
|
||||
|
||||
Issues?
|
||||
|
||||
Persist template details information.
|
||||
14
turbo.json
14
turbo.json
@@ -2,8 +2,13 @@
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"pipeline": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "!.next/cache/**"]
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"!.next/cache/**"
|
||||
]
|
||||
},
|
||||
"lint": {},
|
||||
"dev": {
|
||||
@@ -11,9 +16,10 @@
|
||||
"persistent": true
|
||||
}
|
||||
},
|
||||
"globalDependencies": ["**/.env.*local"],
|
||||
"globalDependencies": [
|
||||
"**/.env.*local"
|
||||
],
|
||||
"globalEnv": [
|
||||
"APP_VERSION",
|
||||
"NEXTAUTH_URL",
|
||||
"NEXTAUTH_SECRET",
|
||||
"NEXT_PUBLIC_APP_URL",
|
||||
|
||||
Reference in New Issue
Block a user