feat: add template page (#1395)
Add a template page view to allow users to see more details about a template at a glance.
This commit is contained in:
14
apps/web/src/app/(dashboard)/templates/[id]/edit/page.tsx
Normal file
14
apps/web/src/app/(dashboard)/templates/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
|
import type { TemplateEditPageViewProps } from './template-edit-page-view';
|
||||||
|
import { TemplateEditPageView } from './template-edit-page-view';
|
||||||
|
|
||||||
|
type TemplateEditPageProps = Pick<TemplateEditPageViewProps, 'params'>;
|
||||||
|
|
||||||
|
export default async function TemplateEditPage({ params }: TemplateEditPageProps) {
|
||||||
|
await setupI18nSSR();
|
||||||
|
|
||||||
|
return <TemplateEditPageView params={params} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||||
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
|
import { TemplateDirectLinkBadge } from '../../template-direct-link-badge';
|
||||||
|
import { TemplateDirectLinkDialogWrapper } from '../template-direct-link-dialog-wrapper';
|
||||||
|
import { EditTemplateForm } from './edit-template';
|
||||||
|
|
||||||
|
export type TemplateEditPageViewProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
team?: Team;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplateEditPageView = async ({ params, team }: TemplateEditPageViewProps) => {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const templateId = Number(id);
|
||||||
|
const templateRootPath = formatTemplatesPath(team?.url);
|
||||||
|
|
||||||
|
if (!templateId || Number.isNaN(templateId)) {
|
||||||
|
redirect(templateRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const template = await getTemplateWithDetailsById({
|
||||||
|
id: templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!template || !template.templateDocumentData) {
|
||||||
|
redirect(templateRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTemplateEnterprise = await isUserEnterprise({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
|
||||||
|
<div className="flex flex-col justify-between sm:flex-row">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={`${templateRootPath}/${templateId}`}
|
||||||
|
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
<Trans>Template</Trans>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||||
|
{template.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center">
|
||||||
|
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
|
||||||
|
|
||||||
|
{template.directLink?.token && (
|
||||||
|
<TemplateDirectLinkBadge
|
||||||
|
className="ml-4"
|
||||||
|
token={template.directLink.token}
|
||||||
|
enabled={template.directLink.enabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 sm:mt-0 sm:self-end">
|
||||||
|
<TemplateDirectLinkDialogWrapper template={template} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditTemplateForm
|
||||||
|
className="mt-6"
|
||||||
|
initialTemplate={template}
|
||||||
|
templateRootPath={templateRootPath}
|
||||||
|
isEnterprise={isTemplateEnterprise}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import type { TemplatePageViewProps } from './template-page-view';
|
|
||||||
import { TemplatePageView } from './template-page-view';
|
import { TemplatePageView } from './template-page-view';
|
||||||
|
|
||||||
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
|
export type TemplatePageProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||||
await setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
|
|
||||||
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
|
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
|
||||||
|
|
||||||
export type TemplatePageViewProps = {
|
export type TemplateDirectLinkDialogWrapperProps = {
|
||||||
template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] };
|
template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplateDirectLinkDialogWrapper = ({ template }: TemplatePageViewProps) => {
|
export const TemplateDirectLinkDialogWrapper = ({
|
||||||
|
template,
|
||||||
|
}: TemplateDirectLinkDialogWrapperProps) => {
|
||||||
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
|
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { InfoIcon } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
|
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import { SelectItem } from '@documenso/ui/primitives/select';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
|
import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search';
|
||||||
|
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||||
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
import { SearchParamSelector } from '~/components/forms/search-param-selector';
|
||||||
|
|
||||||
|
import { DataTableActionButton } from '../../documents/data-table-action-button';
|
||||||
|
import { DataTableActionDropdown } from '../../documents/data-table-action-dropdown';
|
||||||
|
import { DataTableTitle } from '../../documents/data-table-title';
|
||||||
|
|
||||||
|
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
|
||||||
|
DOCUMENT: msg`Document`,
|
||||||
|
TEMPLATE: msg`Template`,
|
||||||
|
TEMPLATE_DIRECT_LINK: msg`Direct link`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZTemplateSearchParamsSchema = ZBaseTableSearchParamsSchema.extend({
|
||||||
|
source: z
|
||||||
|
.nativeEnum(DocumentSource)
|
||||||
|
.optional()
|
||||||
|
.catch(() => undefined),
|
||||||
|
status: z
|
||||||
|
.nativeEnum(DocumentStatusEnum)
|
||||||
|
.optional()
|
||||||
|
.catch(() => undefined),
|
||||||
|
search: z.coerce
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.catch(() => undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TemplatePageViewDocumentsTableProps = {
|
||||||
|
templateId: number;
|
||||||
|
team?: Team;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplatePageViewDocumentsTable = ({
|
||||||
|
templateId,
|
||||||
|
team,
|
||||||
|
}: TemplatePageViewDocumentsTableProps) => {
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZTemplateSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||||
|
trpc.document.findDocuments.useQuery(
|
||||||
|
{
|
||||||
|
templateId,
|
||||||
|
teamId: team?.id,
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
search: parsedSearchParams.search,
|
||||||
|
source: parsedSearchParams.source,
|
||||||
|
status: parsedSearchParams.status,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: _(msg`Created`),
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Title`),
|
||||||
|
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
header: _(msg`Recipient`),
|
||||||
|
accessorKey: 'recipient',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={row.original.Recipient}
|
||||||
|
documentStatus={row.original.status}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Status`),
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
||||||
|
size: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => (
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Trans>Source</Trans>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
|
||||||
|
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
|
||||||
|
<li>
|
||||||
|
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||||
|
<Trans>Template</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<Trans>
|
||||||
|
This document was created by you or a team member using the template above.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||||
|
<Trans>Direct Link</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<Trans>This document was created using a direct link.</Trans>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
accessorKey: 'type',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
{_(DOCUMENT_SOURCE_LABELS[row.original.source])}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: _(msg`Actions`),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<DataTableActionButton team={team} row={row.original} />
|
||||||
|
|
||||||
|
<DataTableActionDropdown team={team} row={row.original} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex flex-row space-x-4">
|
||||||
|
<DocumentSearch />
|
||||||
|
|
||||||
|
<SearchParamSelector
|
||||||
|
paramKey="status"
|
||||||
|
isValueValid={(value) =>
|
||||||
|
[...DocumentStatusEnum.COMPLETED].includes(value as unknown as string)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectItem value="all">
|
||||||
|
<Trans>Any Status</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={DocumentStatusEnum.COMPLETED}>
|
||||||
|
<Trans>Completed</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={DocumentStatusEnum.PENDING}>
|
||||||
|
<Trans>Pending</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={DocumentStatusEnum.DRAFT}>
|
||||||
|
<Trans>Draft</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SearchParamSelector>
|
||||||
|
|
||||||
|
<SearchParamSelector
|
||||||
|
paramKey="source"
|
||||||
|
isValueValid={(value) =>
|
||||||
|
[...DocumentSource.TEMPLATE].includes(value as unknown as string)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectItem value="all">
|
||||||
|
<Trans>Any Source</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={DocumentSource.TEMPLATE}>
|
||||||
|
<Trans>Template</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={DocumentSource.TEMPLATE_DIRECT_LINK}>
|
||||||
|
<Trans>Direct Link</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SearchParamSelector>
|
||||||
|
|
||||||
|
<PeriodSelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-24 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-4 pr-4">
|
||||||
|
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row justify-end space-x-2">
|
||||||
|
<Skeleton className="h-10 w-20 rounded" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import type { Template, User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type TemplatePageViewInformationProps = {
|
||||||
|
userId: number;
|
||||||
|
template: Template & {
|
||||||
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplatePageViewInformation = ({
|
||||||
|
template,
|
||||||
|
userId,
|
||||||
|
}: TemplatePageViewInformationProps) => {
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
const templateInformation = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
description: msg`Uploaded by`,
|
||||||
|
value: userId === template.userId ? _(msg`You`) : template.User.name ?? template.User.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Created`,
|
||||||
|
value: i18n.date(template.createdAt, { dateStyle: 'medium' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Last modified`,
|
||||||
|
value: DateTime.fromJSDate(template.updatedAt)
|
||||||
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
|
.toRelative(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isMounted, template, userId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
||||||
|
<h1 className="px-4 py-3 font-medium">
|
||||||
|
<Trans>Information</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<ul className="divide-y border-t">
|
||||||
|
{templateInformation.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{_(item.description)}</span>
|
||||||
|
<span>{item.value}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { DocumentSource } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type TemplatePageViewRecentActivityProps = {
|
||||||
|
templateId: number;
|
||||||
|
teamId?: number;
|
||||||
|
documentRootPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplatePageViewRecentActivity = ({
|
||||||
|
templateId,
|
||||||
|
teamId,
|
||||||
|
documentRootPath,
|
||||||
|
}: TemplatePageViewRecentActivityProps) => {
|
||||||
|
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
|
||||||
|
templateId,
|
||||||
|
teamId,
|
||||||
|
orderBy: {
|
||||||
|
column: 'createdAt',
|
||||||
|
direction: 'asc',
|
||||||
|
},
|
||||||
|
perPage: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||||
|
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||||||
|
<h1 className="text-foreground font-medium">
|
||||||
|
<Trans>Recent documents</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Can add dropdown menu here for additional options. */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex h-full items-center justify-center py-16">
|
||||||
|
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoadingError && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center py-16">
|
||||||
|
<p className="text-foreground/80 text-sm">
|
||||||
|
<Trans>Unable to load documents</Trans>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={async () => refetch()}
|
||||||
|
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
||||||
|
>
|
||||||
|
<Trans>Click here to retry</Trans>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<ul role="list" className="space-y-6 p-4">
|
||||||
|
{data.data.length > 0 && results.totalPages > 1 && (
|
||||||
|
<li className="relative flex gap-x-4">
|
||||||
|
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
|
||||||
|
<div className="bg-border w-px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
|
||||||
|
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: document.getElementById('documents')?.offsetTop,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-foreground/70 hover:text-muted-foreground flex items-center text-xs"
|
||||||
|
>
|
||||||
|
<Trans>View more</Trans>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.data.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<p className="text-muted-foreground/70 text-sm">
|
||||||
|
<Trans>No recent documents</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.data.map((document, documentIndex) => (
|
||||||
|
<li key={document.id} className="relative flex gap-x-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
documentIndex === results.data.length - 1 ? 'h-6' : '-bottom-6',
|
||||||
|
'absolute left-0 top-0 flex w-6 justify-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-border w-px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
|
||||||
|
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`${documentRootPath}/${document.id}`}
|
||||||
|
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
||||||
|
>
|
||||||
|
{match(document.source)
|
||||||
|
.with(DocumentSource.DOCUMENT, DocumentSource.TEMPLATE, () => (
|
||||||
|
<Trans>
|
||||||
|
Document created by <span className="font-bold">{document.User.name}</span>
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with(DocumentSource.TEMPLATE_DIRECT_LINK, () => (
|
||||||
|
<Trans>
|
||||||
|
Document created using a <span className="font-bold">direct link</span>
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
|
||||||
|
{DateTime.fromJSDate(document.createdAt).toRelative({ style: 'short' })}
|
||||||
|
</time>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mx-4 mb-4"
|
||||||
|
onClick={() => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: document.getElementById('documents')?.offsetTop,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>View all related documents</Trans>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { PenIcon, PlusIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import type { Recipient, Template } from '@documenso/prisma/client';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
|
||||||
|
export type TemplatePageViewRecipientsProps = {
|
||||||
|
template: Template & {
|
||||||
|
Recipient: Recipient[];
|
||||||
|
};
|
||||||
|
templateRootPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplatePageViewRecipients = ({
|
||||||
|
template,
|
||||||
|
templateRootPath,
|
||||||
|
}: TemplatePageViewRecipientsProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const recipients = template.Recipient;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||||
|
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||||
|
<h1 className="text-foreground font-medium">
|
||||||
|
<Trans>Recipients</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`${templateRootPath}/${template.id}/edit?step=signers`}
|
||||||
|
title={_(msg`Modify recipients`)}
|
||||||
|
className="flex flex-row items-center justify-between"
|
||||||
|
>
|
||||||
|
{recipients.length === 0 ? (
|
||||||
|
<PlusIcon className="ml-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<PenIcon className="ml-2 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="text-muted-foreground divide-y border-t">
|
||||||
|
{recipients.length === 0 && (
|
||||||
|
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||||
|
<Trans>No recipients</Trans>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipients.map((recipient) => (
|
||||||
|
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||||
|
<AvatarWithText
|
||||||
|
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||||
|
secondaryText={
|
||||||
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
|
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,22 +1,28 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft, LucideEdit } from 'lucide-react';
|
||||||
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import type { Team } from '@documenso/prisma/client';
|
import { DocumentSigningOrder, SigningStatus, type Team } from '@documenso/prisma/client';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
|
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||||
import { TemplateType } from '~/components/formatter/template-type';
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
|
import { DataTableActionDropdown } from '../data-table-action-dropdown';
|
||||||
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
|
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
|
||||||
import { EditTemplateForm } from './edit-template';
|
import { UseTemplateDialog } from '../use-template-dialog';
|
||||||
import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper';
|
import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper';
|
||||||
|
import { TemplatePageViewDocumentsTable } from './template-page-view-documents-table';
|
||||||
|
import { TemplatePageViewInformation } from './template-page-view-information';
|
||||||
|
import { TemplatePageViewRecentActivity } from './template-page-view-recent-activity';
|
||||||
|
import { TemplatePageViewRecipients } from './template-page-view-recipients';
|
||||||
|
|
||||||
export type TemplatePageViewProps = {
|
export type TemplatePageViewProps = {
|
||||||
params: {
|
params: {
|
||||||
@@ -30,6 +36,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
|
|
||||||
const templateId = Number(id);
|
const templateId = Number(id);
|
||||||
const templateRootPath = formatTemplatesPath(team?.url);
|
const templateRootPath = formatTemplatesPath(team?.url);
|
||||||
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
if (!templateId || Number.isNaN(templateId)) {
|
if (!templateId || Number.isNaN(templateId)) {
|
||||||
redirect(templateRootPath);
|
redirect(templateRootPath);
|
||||||
@@ -37,29 +44,51 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const template = await getTemplateWithDetailsById({
|
const template = await getTemplateById({
|
||||||
id: templateId,
|
id: templateId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!template || !template.templateDocumentData) {
|
if (!template || !template.templateDocumentData || (template?.teamId && !team?.url)) {
|
||||||
redirect(templateRootPath);
|
redirect(templateRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTemplateEnterprise = await isUserEnterprise({
|
const { templateDocumentData, Field, Recipient: recipients, templateMeta } = template;
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
// Remap to fit the DocumentReadOnlyFields component.
|
||||||
|
const readOnlyFields = Field.map((field) => {
|
||||||
|
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
Recipient: recipient,
|
||||||
|
Signature: null,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const mockedDocumentMeta = templateMeta
|
||||||
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
|
? {
|
||||||
<div className="flex flex-col justify-between sm:flex-row">
|
typedSignatureEnabled: false,
|
||||||
<div>
|
...templateMeta,
|
||||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
documentId: 0,
|
||||||
<Trans>Templates</Trans>
|
}
|
||||||
</Link>
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link href={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
<Trans>Templates</Trans>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-between truncate">
|
||||||
|
<div>
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||||
{template.title}
|
{template.title}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -77,17 +106,97 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 sm:mt-0 sm:self-end">
|
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
|
||||||
<TemplateDirectLinkDialogWrapper template={template} />
|
<TemplateDirectLinkDialogWrapper template={template} />
|
||||||
|
|
||||||
|
<Button className="w-full" asChild>
|
||||||
|
<Link href={`${templateRootPath}/${template.id}/edit`}>
|
||||||
|
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
<Trans>Edit Template</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditTemplateForm
|
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||||
className="mt-6"
|
<Card
|
||||||
initialTemplate={template}
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
templateRootPath={templateRootPath}
|
gradient
|
||||||
isEnterprise={isTemplateEnterprise}
|
>
|
||||||
/>
|
<CardContent className="p-2">
|
||||||
|
<LazyPDFViewer
|
||||||
|
document={template}
|
||||||
|
key={template.id}
|
||||||
|
documentData={templateDocumentData}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DocumentReadOnlyFields
|
||||||
|
fields={readOnlyFields}
|
||||||
|
showFieldStatus={false}
|
||||||
|
documentMeta={mockedDocumentMeta}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
|
<Trans>Template</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<DataTableActionDropdown
|
||||||
|
row={template}
|
||||||
|
teamId={team?.id}
|
||||||
|
templateRootPath={templateRootPath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 px-4 text-sm ">
|
||||||
|
<Trans>Manage and view template</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t px-4 pt-4">
|
||||||
|
<UseTemplateDialog
|
||||||
|
templateId={template.id}
|
||||||
|
templateSigningOrder={template.templateMeta?.signingOrder}
|
||||||
|
recipients={template.Recipient}
|
||||||
|
documentRootPath={documentRootPath}
|
||||||
|
trigger={
|
||||||
|
<Button className="w-full">
|
||||||
|
<Trans>Use</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Template information section. */}
|
||||||
|
<TemplatePageViewInformation template={template} userId={user.id} />
|
||||||
|
|
||||||
|
{/* Recipients section. */}
|
||||||
|
<TemplatePageViewRecipients template={template} templateRootPath={templateRootPath} />
|
||||||
|
|
||||||
|
{/* Recent activity section. */}
|
||||||
|
<TemplatePageViewRecentActivity
|
||||||
|
documentRootPath={documentRootPath}
|
||||||
|
templateId={template.id}
|
||||||
|
teamId={team?.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16" id="documents">
|
||||||
|
<h1 className="mb-4 text-2xl font-bold">
|
||||||
|
<Trans>Documents created from template</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<TemplatePageViewDocumentsTable team={team} templateId={template.id} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Trans } from '@lingui/macro';
|
|||||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
|
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -23,7 +23,10 @@ import { MoveTemplateDialog } from './move-template-dialog';
|
|||||||
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
||||||
|
|
||||||
export type DataTableActionDropdownProps = {
|
export type DataTableActionDropdownProps = {
|
||||||
row: FindTemplateRow;
|
row: Template & {
|
||||||
|
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||||
|
Recipient: Recipient[];
|
||||||
|
};
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
};
|
};
|
||||||
@@ -57,7 +60,7 @@ export const DataTableActionDropdown = ({
|
|||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||||
<Link href={`${templateRootPath}/${row.id}`}>
|
<Link href={`${templateRootPath}/${row.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<Trans>Edit</Trans>
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export const TemplatesDataTable = ({
|
|||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<TemplateType type="PRIVATE" />
|
<TemplateType type={row.original.type} />
|
||||||
|
|
||||||
{row.original.directLink?.token && (
|
{row.original.directLink?.token && (
|
||||||
<TemplateDirectLinkBadge
|
<TemplateDirectLinkBadge
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
|
|
||||||
setShowNewTemplateDialog(false);
|
setShowNewTemplateDialog(false);
|
||||||
|
|
||||||
router.push(`${templateRootPath}/${id}`);
|
router.push(`${templateRootPath}/${id}/edit`);
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@@ -92,6 +94,7 @@ export type UseTemplateDialogProps = {
|
|||||||
templateSigningOrder?: DocumentSigningOrder | null;
|
templateSigningOrder?: DocumentSigningOrder | null;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
documentRootPath: string;
|
documentRootPath: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UseTemplateDialog({
|
export function UseTemplateDialog({
|
||||||
@@ -99,6 +102,7 @@ export function UseTemplateDialog({
|
|||||||
documentRootPath,
|
documentRootPath,
|
||||||
templateId,
|
templateId,
|
||||||
templateSigningOrder,
|
templateSigningOrder,
|
||||||
|
trigger,
|
||||||
}: UseTemplateDialogProps) {
|
}: UseTemplateDialogProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -186,10 +190,12 @@ export function UseTemplateDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="bg-background">
|
{trigger || (
|
||||||
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
<Button variant="outline" className="bg-background">
|
||||||
<Trans>Use Template</Trans>
|
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
||||||
</Button>
|
<Trans>Use Template</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
|
import type { TemplateEditPageViewProps } from '~/app/(dashboard)/templates/[id]/edit/template-edit-page-view';
|
||||||
|
import { TemplateEditPageView } from '~/app/(dashboard)/templates/[id]/edit/template-edit-page-view';
|
||||||
|
|
||||||
|
export type TeamsTemplateEditPageProps = {
|
||||||
|
params: TemplateEditPageViewProps['params'] & {
|
||||||
|
teamUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TeamsTemplateEditPage({ params }: TeamsTemplateEditPageProps) {
|
||||||
|
await setupI18nSSR();
|
||||||
|
|
||||||
|
const { teamUrl } = params;
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
|
return <TemplateEditPageView params={params} team={team} />;
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { EyeOffIcon } from 'lucide-react';
|
import { Clock, EyeOffIcon } from 'lucide-react';
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,8 +19,10 @@ import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
|||||||
import type { DocumentMeta } from '@documenso/prisma/client';
|
import type { DocumentMeta } from '@documenso/prisma/client';
|
||||||
import { FieldType, SigningStatus } from '@documenso/prisma/client';
|
import { FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||||
|
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||||
@@ -27,9 +30,14 @@ import { PopoverHover } from '@documenso/ui/primitives/popover';
|
|||||||
export type DocumentReadOnlyFieldsProps = {
|
export type DocumentReadOnlyFieldsProps = {
|
||||||
fields: DocumentField[];
|
fields: DocumentField[];
|
||||||
documentMeta?: DocumentMeta;
|
documentMeta?: DocumentMeta;
|
||||||
|
showFieldStatus?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => {
|
export const DocumentReadOnlyFields = ({
|
||||||
|
documentMeta,
|
||||||
|
fields,
|
||||||
|
showFieldStatus = true,
|
||||||
|
}: DocumentReadOnlyFieldsProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
|
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
|
||||||
@@ -58,15 +66,37 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
}
|
}
|
||||||
contentProps={{
|
contentProps={{
|
||||||
className: 'relative flex w-fit flex-col p-2.5 text-sm',
|
className: 'relative flex w-fit flex-col p-4 text-sm',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="font-semibold">
|
{showFieldStatus && (
|
||||||
{field.Recipient.signingStatus === SigningStatus.SIGNED ? 'Signed' : 'Pending'}{' '}
|
<Badge
|
||||||
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type]).toLowerCase()} field
|
className="mx-auto mb-1 py-0.5"
|
||||||
|
variant={
|
||||||
|
field.Recipient.signingStatus === SigningStatus.SIGNED
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{field.Recipient.signingStatus === SigningStatus.SIGNED ? (
|
||||||
|
<>
|
||||||
|
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||||
|
<Trans>Signed</Trans>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
|
<Trans>Pending</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-center font-semibold">
|
||||||
|
<span>{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground mt-1 text-center text-xs">
|
||||||
{field.Recipient.name
|
{field.Recipient.name
|
||||||
? `${field.Recipient.name} (${field.Recipient.email})`
|
? `${field.Recipient.name} (${field.Recipient.email})`
|
||||||
: field.Recipient.email}{' '}
|
: field.Recipient.email}{' '}
|
||||||
|
|||||||
50
apps/web/src/components/forms/search-param-selector.tsx
Normal file
50
apps/web/src/components/forms/search-param-selector.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Select, SelectContent, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||||
|
|
||||||
|
export type SearchParamSelector = {
|
||||||
|
paramKey: string;
|
||||||
|
isValueValid: (value: unknown) => boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SearchParamSelector = ({ children, paramKey, isValueValid }: SearchParamSelector) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const value = useMemo(() => {
|
||||||
|
const p = searchParams?.get(paramKey) ?? 'all';
|
||||||
|
|
||||||
|
return isValueValid(p) ? p : 'all';
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const onValueChange = (newValue: string) => {
|
||||||
|
if (!pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
|
|
||||||
|
params.set(paramKey, newValue);
|
||||||
|
|
||||||
|
if (newValue === '' || newValue === 'all') {
|
||||||
|
params.delete(paramKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select defaultValue={value} onValueChange={onValueChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">{children}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -32,7 +32,7 @@ test.describe('[EE_ONLY]', () => {
|
|||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
redirectPath: `/templates/${template.id}`,
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set EE action auth.
|
// Set EE action auth.
|
||||||
@@ -74,7 +74,7 @@ test.describe('[EE_ONLY]', () => {
|
|||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: teamMemberUser.email,
|
email: teamMemberUser.email,
|
||||||
redirectPath: `/t/${team.url}/templates/${template.id}`,
|
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set EE action auth.
|
// Set EE action auth.
|
||||||
@@ -110,7 +110,7 @@ test.describe('[EE_ONLY]', () => {
|
|||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: teamMemberUser.email,
|
email: teamMemberUser.email,
|
||||||
redirectPath: `/templates/${template.id}`,
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global action auth should not be visible.
|
// Global action auth should not be visible.
|
||||||
@@ -132,7 +132,7 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
|
|||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
redirectPath: `/templates/${template.id}`,
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set title.
|
// Set title.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ test.describe('[EE_ONLY]', () => {
|
|||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
redirectPath: `/templates/${template.id}`,
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save the settings by going to the next step.
|
// Save the settings by going to the next step.
|
||||||
@@ -81,7 +81,7 @@ test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
|
|||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
redirectPath: `/templates/${template.id}`,
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save the settings by going to the next step.
|
// Save the settings by going to the next step.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
|
|||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
redirectPath: `/templates/${template.id}`,
|
redirectPath: `/templates/${template.id}/edit`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set template title.
|
// Set template title.
|
||||||
@@ -172,7 +172,7 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
|||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: owner.email,
|
email: owner.email,
|
||||||
redirectPath: `/t/${team.url}/templates/${template.id}`,
|
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set template title.
|
// Set template title.
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import { P, match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
|
import { RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
|
||||||
import type { Document, Prisma, Team, TeamEmail, User } from '@documenso/prisma/client';
|
import type {
|
||||||
|
Document,
|
||||||
|
DocumentSource,
|
||||||
|
Prisma,
|
||||||
|
Team,
|
||||||
|
TeamEmail,
|
||||||
|
User,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
|
||||||
import { DocumentVisibility } from '../../types/document-visibility';
|
import { DocumentVisibility } from '../../types/document-visibility';
|
||||||
@@ -16,6 +23,8 @@ export type FindDocumentsOptions = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
term?: string;
|
term?: string;
|
||||||
|
templateId?: number;
|
||||||
|
source?: DocumentSource;
|
||||||
status?: ExtendedDocumentStatus;
|
status?: ExtendedDocumentStatus;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
@@ -32,6 +41,8 @@ export const findDocuments = async ({
|
|||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
term,
|
term,
|
||||||
|
templateId,
|
||||||
|
source,
|
||||||
status = ExtendedDocumentStatus.ALL,
|
status = ExtendedDocumentStatus.ALL,
|
||||||
page = 1,
|
page = 1,
|
||||||
perPage = 10,
|
perPage = 10,
|
||||||
@@ -40,44 +51,37 @@ export const findDocuments = async ({
|
|||||||
senderIds,
|
senderIds,
|
||||||
search,
|
search,
|
||||||
}: FindDocumentsOptions) => {
|
}: FindDocumentsOptions) => {
|
||||||
const { user, team } = await prisma.$transaction(async (tx) => {
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
const user = await tx.user.findFirstOrThrow({
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let team = null;
|
||||||
|
|
||||||
|
if (teamId !== undefined) {
|
||||||
|
team = await prisma.team.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: teamId,
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
teamEmail: true,
|
||||||
|
members: {
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
let team = null;
|
|
||||||
|
|
||||||
if (teamId !== undefined) {
|
|
||||||
team = await tx.team.findFirstOrThrow({
|
|
||||||
where: {
|
|
||||||
id: teamId,
|
|
||||||
members: {
|
|
||||||
some: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
teamEmail: true,
|
|
||||||
members: {
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
role: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
team,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||||
@@ -197,8 +201,27 @@ export const findDocuments = async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const whereAndClause: Prisma.DocumentWhereInput['AND'] = [
|
||||||
|
{ ...termFilters },
|
||||||
|
{ ...filters },
|
||||||
|
{ ...deletedFilter },
|
||||||
|
{ ...searchFilter },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (templateId) {
|
||||||
|
whereAndClause.push({
|
||||||
|
templateId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source) {
|
||||||
|
whereAndClause.push({
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const whereClause: Prisma.DocumentWhereInput = {
|
const whereClause: Prisma.DocumentWhereInput = {
|
||||||
AND: [{ ...termFilters }, { ...filters }, { ...deletedFilter }, { ...searchFilter }],
|
AND: whereAndClause,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (period) {
|
if (period) {
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
|
|||||||
templateMeta: true,
|
templateMeta: true,
|
||||||
Recipient: true,
|
Recipient: true,
|
||||||
Field: true,
|
Field: true,
|
||||||
|
User: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createDocument } from '@documenso/lib/server-only/document/create-docum
|
|||||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||||
import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id';
|
import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id';
|
||||||
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
|
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
|
||||||
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
ZDownloadAuditLogsMutationSchema,
|
ZDownloadAuditLogsMutationSchema,
|
||||||
ZDownloadCertificateMutationSchema,
|
ZDownloadCertificateMutationSchema,
|
||||||
ZFindDocumentAuditLogsQuerySchema,
|
ZFindDocumentAuditLogsQuerySchema,
|
||||||
|
ZFindDocumentsQuerySchema,
|
||||||
ZGetDocumentByIdQuerySchema,
|
ZGetDocumentByIdQuerySchema,
|
||||||
ZGetDocumentByTokenQuerySchema,
|
ZGetDocumentByTokenQuerySchema,
|
||||||
ZGetDocumentWithDetailsByIdQuerySchema,
|
ZGetDocumentWithDetailsByIdQuerySchema,
|
||||||
@@ -190,6 +192,37 @@ export const documentRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
findDocuments: authenticatedProcedure
|
||||||
|
.input(ZFindDocumentsQuerySchema)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { user } = ctx;
|
||||||
|
|
||||||
|
const { search, teamId, templateId, page, perPage, orderBy, source, status } = input;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const documents = await findDocuments({
|
||||||
|
userId: user.id,
|
||||||
|
teamId,
|
||||||
|
templateId,
|
||||||
|
search,
|
||||||
|
source,
|
||||||
|
status,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
orderBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
return documents;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We are unable to search for documents. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
findDocumentAuditLogs: authenticatedProcedure
|
findDocumentAuditLogs: authenticatedProcedure
|
||||||
.input(ZFindDocumentAuditLogsQuerySchema)
|
.input(ZFindDocumentAuditLogsQuerySchema)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -7,7 +7,30 @@ import {
|
|||||||
} from '@documenso/lib/types/document-auth';
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
|
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
|
||||||
import { DocumentSigningOrder, FieldType, RecipientRole } from '@documenso/prisma/client';
|
import {
|
||||||
|
DocumentSigningOrder,
|
||||||
|
DocumentSource,
|
||||||
|
DocumentStatus,
|
||||||
|
FieldType,
|
||||||
|
RecipientRole,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const ZFindDocumentsQuerySchema = ZBaseTableSearchParamsSchema.extend({
|
||||||
|
teamId: z.number().min(1).optional(),
|
||||||
|
templateId: z.number().min(1).optional(),
|
||||||
|
search: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.catch(() => undefined),
|
||||||
|
source: z.nativeEnum(DocumentSource).optional(),
|
||||||
|
status: z.nativeEnum(DocumentStatus).optional(),
|
||||||
|
orderBy: z
|
||||||
|
.object({
|
||||||
|
column: z.enum(['createdAt']),
|
||||||
|
direction: z.enum(['asc', 'desc']),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}).omit({ query: true });
|
||||||
|
|
||||||
export const ZFindDocumentAuditLogsQuerySchema = ZBaseTableSearchParamsSchema.extend({
|
export const ZFindDocumentAuditLogsQuerySchema = ZBaseTableSearchParamsSchema.extend({
|
||||||
documentId: z.number().min(1),
|
documentId: z.number().min(1),
|
||||||
|
|||||||
Reference in New Issue
Block a user