mirror of
https://github.com/documenso/documenso.git
synced 2026-07-02 17:20:44 +10:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1464ac2d3 | |||
| e7e2aa9bd8 |
@@ -308,7 +308,7 @@ The Web UI can be found at http://localhost:9000, while the SMTP port will be on
|
||||
|
||||
### Support IPv6
|
||||
|
||||
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Remix start command
|
||||
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Next.js start command
|
||||
|
||||
For local docker run
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -48,7 +48,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
documentData: DocumentData;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
metadata?: DocumentMeta | null;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhiteLabelling?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -33,7 +33,7 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/
|
||||
export type EmbedDocumentFieldsProps = {
|
||||
fields: Field[];
|
||||
metadata?: Pick<
|
||||
DocumentMeta | TemplateMeta,
|
||||
DocumentMeta,
|
||||
| 'timezone'
|
||||
| 'dateFormat'
|
||||
| 'typedSignatureEnabled'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
import {
|
||||
type DocumentData,
|
||||
type Field,
|
||||
@@ -50,7 +50,7 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
completedFields: DocumentField[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
metadata?: DocumentMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhitelabelling?: boolean;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return ['', '7d', '14d', '30d'].includes(value as string);
|
||||
};
|
||||
|
||||
export const PeriodSelector = () => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const period = useMemo(() => {
|
||||
const p = searchParams?.get('period') ?? 'all';
|
||||
|
||||
return isPeriodSelectorValue(p) ? p : 'all';
|
||||
}, [searchParams]);
|
||||
|
||||
const onPeriodChange = (newPeriod: string) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('period', newPeriod);
|
||||
|
||||
if (newPeriod === '' || newPeriod === 'all') {
|
||||
params.delete('period');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select defaultValue={period} onValueChange={onPeriodChange}>
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="all">
|
||||
<Trans>All Time</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="7d">
|
||||
<Trans>Last 7 days</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="14d">
|
||||
<Trans>Last 14 days</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="30d">
|
||||
<Trans>Last 30 days</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
+91
-153
@@ -1,136 +1,80 @@
|
||||
import { useMemo, useTransition } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentSource } from '@prisma/client';
|
||||
import { InfoIcon, Loader } from 'lucide-react';
|
||||
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@prisma/client';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/schema';
|
||||
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 { DataTable } from '@documenso/ui/primitives/data-table/data-table';
|
||||
import {
|
||||
type TimePeriod,
|
||||
isDateInPeriod,
|
||||
timePeriods,
|
||||
} from '@documenso/ui/primitives/data-table/utils/time-filters';
|
||||
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 { DocumentStatus as DocumentStatusComponent } from '~/components/general/document/document-status';
|
||||
import { SearchParamSelector } from '~/components/forms/search-param-selector';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||
import { DocumentsTableActionButton } from '~/components/tables/documents-table-action-button';
|
||||
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
|
||||
import { DataTableTitle } from '~/components/tables/documents-table-title';
|
||||
import { TemplateDocumentsTableEmptyState } from '~/components/tables/template-documents-table-empty-state';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { PeriodSelector } from '../period-selector';
|
||||
|
||||
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
|
||||
DOCUMENT: msg`Document`,
|
||||
TEMPLATE: msg`Template`,
|
||||
TEMPLATE_DIRECT_LINK: msg`Direct link`,
|
||||
};
|
||||
|
||||
const ZDocumentSearchParamsSchema = ZUrlSearchParamsSchema.extend({
|
||||
source: z
|
||||
.nativeEnum(DocumentSource)
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
status: z
|
||||
.nativeEnum(DocumentStatusEnum)
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
});
|
||||
|
||||
type TemplatePageViewDocumentsTableProps = {
|
||||
templateId: number;
|
||||
};
|
||||
|
||||
type DocumentsTableRow = TFindDocumentsInternalResponse['data'][number];
|
||||
|
||||
export const TemplatePageViewDocumentsTable = ({
|
||||
templateId,
|
||||
}: TemplatePageViewDocumentsTableProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const createFilterHandler = (paramName: string, isSingleValue = false) => {
|
||||
return (values: string[]) => {
|
||||
startTransition(() => {
|
||||
if (values.length === 0) {
|
||||
updateSearchParams({ [paramName]: undefined, page: undefined });
|
||||
} else {
|
||||
const value = isSingleValue ? values[0] : values.join(',');
|
||||
updateSearchParams({ [paramName]: value, page: undefined });
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(
|
||||
Object.fromEntries(searchParams ?? []),
|
||||
);
|
||||
|
||||
const getFilterValues = (paramName: string, isSingleValue = false): string[] => {
|
||||
const value = searchParams.get(paramName);
|
||||
if (!value) return [];
|
||||
return isSingleValue ? [value] : value.split(',').filter(Boolean);
|
||||
};
|
||||
|
||||
const handleStatusFilterChange = createFilterHandler('status');
|
||||
const handleTimePeriodFilterChange = createFilterHandler('period', true);
|
||||
const handleSourceFilterChange = createFilterHandler('source');
|
||||
|
||||
const selectedStatusValues = getFilterValues('status');
|
||||
const selectedTimePeriodValues = getFilterValues('period', true);
|
||||
const selectedSourceValues = getFilterValues('source');
|
||||
|
||||
const isStatusFiltered = selectedStatusValues.length > 0;
|
||||
const isTimePeriodFiltered = selectedTimePeriodValues.length > 0;
|
||||
const isSourceFiltered = selectedSourceValues.length > 0;
|
||||
|
||||
const handleResetFilters = () => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
status: undefined,
|
||||
source: undefined,
|
||||
period: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const sourceParam = searchParams.get('source');
|
||||
const statusParam = searchParams.get('status');
|
||||
const periodParam = searchParams.get('period');
|
||||
|
||||
// Parse status parameter to handle multiple values
|
||||
const parsedStatus = statusParam
|
||||
? statusParam
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.filter((status) =>
|
||||
Object.values(ExtendedDocumentStatus).includes(status as ExtendedDocumentStatus),
|
||||
)
|
||||
.map((status) => status as ExtendedDocumentStatus)
|
||||
: undefined;
|
||||
|
||||
const parsedPeriod =
|
||||
periodParam && timePeriods.includes(periodParam as TimePeriod)
|
||||
? (periodParam as TimePeriod)
|
||||
: undefined;
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery(
|
||||
{
|
||||
templateId,
|
||||
page: Number(searchParams.get('page')) || 1,
|
||||
perPage: Number(searchParams.get('perPage')) || 10,
|
||||
query: searchParams.get('query') || undefined,
|
||||
source:
|
||||
sourceParam && Object.values(DocumentSource).includes(sourceParam as DocumentSource)
|
||||
? (sourceParam as DocumentSource)
|
||||
: undefined,
|
||||
status: parsedStatus,
|
||||
period: parsedPeriod,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
query: parsedSearchParams.query,
|
||||
source: parsedSearchParams.source,
|
||||
status: parsedSearchParams.status,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
@@ -138,11 +82,9 @@ export const TemplatePageViewDocumentsTable = ({
|
||||
);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -153,13 +95,6 @@ export const TemplatePageViewDocumentsTable = ({
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const getEmptyStateStatus = (): ExtendedDocumentStatus => {
|
||||
if (selectedStatusValues.length > 0) {
|
||||
return selectedStatusValues[0] as ExtendedDocumentStatus;
|
||||
}
|
||||
return ExtendedDocumentStatus.ALL;
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -167,21 +102,12 @@ export const TemplatePageViewDocumentsTable = ({
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) =>
|
||||
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
|
||||
filterFn: (row, id, value) => {
|
||||
const createdAt = row.getValue(id) as Date;
|
||||
if (!value || !Array.isArray(value) || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const period = value[0] as TimePeriod;
|
||||
return isDateInPeriod(createdAt, period);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`Title`),
|
||||
accessorKey: 'title',
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
||||
},
|
||||
|
||||
{
|
||||
header: _(msg`Recipient`),
|
||||
accessorKey: 'recipient',
|
||||
@@ -195,14 +121,8 @@ export const TemplatePageViewDocumentsTable = ({
|
||||
{
|
||||
header: _(msg`Status`),
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatusComponent status={row.original.status} />,
|
||||
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
||||
size: 140,
|
||||
filterFn: (row, id, value) => {
|
||||
if (!value || !Array.isArray(value) || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return value.includes(row.getValue(id));
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
@@ -241,51 +161,79 @@ export const TemplatePageViewDocumentsTable = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'source',
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-row items-center">
|
||||
{_(DOCUMENT_SOURCE_LABELS[row.original.source as DocumentSource])}
|
||||
{_(DOCUMENT_SOURCE_LABELS[row.original.source])}
|
||||
</div>
|
||||
),
|
||||
filterFn: (row, id, value) => {
|
||||
if (!value || !Array.isArray(value) || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return value.includes(row.getValue(id));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: _(msg`Actions`),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<DocumentsTableActionButton row={row.original} />
|
||||
|
||||
<DocumentsTableActionDropdown row={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<DocumentsTableRow>[];
|
||||
}, [_, team?.url]);
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<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
|
||||
data={results.data}
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
stats={data?.stats}
|
||||
onStatusFilterChange={handleStatusFilterChange}
|
||||
selectedStatusValues={selectedStatusValues}
|
||||
onTimePeriodFilterChange={handleTimePeriodFilterChange}
|
||||
selectedTimePeriodValues={selectedTimePeriodValues}
|
||||
onSourceFilterChange={handleSourceFilterChange}
|
||||
selectedSourceValues={selectedSourceValues}
|
||||
onResetFilters={handleResetFilters}
|
||||
isStatusFiltered={isStatusFiltered}
|
||||
isTimePeriodFiltered={isTimePeriodFiltered}
|
||||
isSourceFiltered={isSourceFiltered}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
@@ -317,19 +265,9 @@ export const TemplatePageViewDocumentsTable = ({
|
||||
</>
|
||||
),
|
||||
}}
|
||||
emptyState={{
|
||||
enable: !isLoading && !isLoadingError,
|
||||
component: <TemplateDocumentsTableEmptyState status={getEmptyStateStatus()} />,
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Document, Role, Subscription } from '@prisma/client';
|
||||
import type { Role, Subscription } from '@prisma/client';
|
||||
import { Edit, Loader } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
@@ -20,7 +20,7 @@ type UserData = {
|
||||
email: string;
|
||||
roles: Role[];
|
||||
subscriptions?: SubscriptionLite[] | null;
|
||||
documents: DocumentLite[];
|
||||
documentCount: number;
|
||||
};
|
||||
|
||||
type SubscriptionLite = Pick<
|
||||
@@ -28,8 +28,6 @@ type SubscriptionLite = Pick<
|
||||
'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd'
|
||||
>;
|
||||
|
||||
type DocumentLite = Pick<Document, 'id'>;
|
||||
|
||||
type AdminDashboardUsersTableProps = {
|
||||
users: UserData[];
|
||||
totalPages: number;
|
||||
@@ -74,10 +72,7 @@ export const AdminDashboardUsersTable = ({
|
||||
},
|
||||
{
|
||||
header: _(msg`Documents`),
|
||||
accessorKey: 'documents',
|
||||
cell: ({ row }) => {
|
||||
return <div>{row.original.documents?.length}</div>;
|
||||
},
|
||||
accessorKey: 'documentCount',
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Bird, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
@@ -25,21 +25,6 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
|
||||
message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`,
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.PENDING, () => ({
|
||||
title: msg`No pending documents`,
|
||||
message: msg`There are no pending documents at the moment. Documents awaiting signatures will appear here.`,
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.REJECTED, () => ({
|
||||
title: msg`No rejected documents`,
|
||||
message: msg`There are no rejected documents. Documents that have been declined will appear here.`,
|
||||
icon: XCircle,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.INBOX, () => ({
|
||||
title: msg`Your inbox is empty`,
|
||||
message: msg`There are no documents waiting for your action. Documents requiring your signature will appear here.`,
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
title: msg`We're all empty`,
|
||||
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
|
||||
@@ -53,7 +38,7 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-muted-foreground/60 mt-12 flex h-60 flex-col items-center justify-center gap-y-4"
|
||||
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
|
||||
data-testid="empty-document-state"
|
||||
>
|
||||
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
@@ -1,100 +1,51 @@
|
||||
import { useMemo, useTransition } from 'react';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/schema';
|
||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
||||
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 { DataTable } from '@documenso/ui/primitives/data-table/data-table';
|
||||
import {
|
||||
type TimePeriod,
|
||||
isDateInPeriod,
|
||||
} from '@documenso/ui/primitives/data-table/utils/time-filters';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
||||
import { DocumentsTableActionButton } from './documents-table-action-button';
|
||||
import { DocumentsTableActionDropdown } from './documents-table-action-dropdown';
|
||||
import { DocumentsTableEmptyState } from './documents-table-empty-state';
|
||||
|
||||
export type DataTableProps = {
|
||||
data?: TFindDocumentsInternalResponse;
|
||||
export type DocumentsTableProps = {
|
||||
data?: TFindDocumentsResponse;
|
||||
isLoading?: boolean;
|
||||
isLoadingError?: boolean;
|
||||
onMoveDocument?: (documentId: number) => void;
|
||||
};
|
||||
|
||||
type DocumentsTableRow = TFindDocumentsInternalResponse['data'][number];
|
||||
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
||||
|
||||
export function DocumentsDataTable({
|
||||
export const DocumentsTable = ({
|
||||
data,
|
||||
isLoading,
|
||||
isLoadingError,
|
||||
onMoveDocument,
|
||||
}: DataTableProps) {
|
||||
const { _ } = useLingui();
|
||||
}: DocumentsTableProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const handleStatusFilterChange = (values: string[]) => {
|
||||
startTransition(() => {
|
||||
if (values.length === 0) {
|
||||
updateSearchParams({ status: undefined, page: undefined });
|
||||
} else {
|
||||
updateSearchParams({ status: values.join(','), page: undefined });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const currentStatus = searchParams.get('status');
|
||||
const selectedStatusValues = currentStatus ? currentStatus.split(',').filter(Boolean) : [];
|
||||
|
||||
const handleResetFilters = () => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({ status: undefined, period: undefined, page: undefined });
|
||||
});
|
||||
};
|
||||
|
||||
const isStatusFiltered = selectedStatusValues.length > 0;
|
||||
|
||||
const handleTimePeriodFilterChange = (values: string[]) => {
|
||||
startTransition(() => {
|
||||
if (values.length === 0) {
|
||||
updateSearchParams({ period: undefined, page: undefined });
|
||||
} else {
|
||||
updateSearchParams({ period: values[0], page: undefined });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const currentPeriod = searchParams.get('period');
|
||||
const selectedTimePeriodValues = currentPeriod ? [currentPeriod] : [];
|
||||
const isTimePeriodFiltered = selectedTimePeriodValues.length > 0;
|
||||
|
||||
const handleSourceFilterChange = (values: string[]) => {
|
||||
// Documents table doesn't have source filtering
|
||||
};
|
||||
|
||||
const selectedSourceValues: string[] = [];
|
||||
const isSourceFiltered = false;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
@@ -103,19 +54,9 @@ export function DocumentsDataTable({
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) =>
|
||||
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
|
||||
filterFn: (row, id, value) => {
|
||||
const createdAt = row.getValue(id) as Date;
|
||||
if (!value || !Array.isArray(value) || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const period = value[0] as TimePeriod;
|
||||
return isDateInPeriod(createdAt, period);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`Title`),
|
||||
accessorKey: 'title',
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
||||
},
|
||||
{
|
||||
@@ -138,12 +79,6 @@ export function DocumentsDataTable({
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||
size: 140,
|
||||
filterFn: (row, id, value) => {
|
||||
if (!value || !Array.isArray(value) || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return value.includes(row.getValue(id));
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`Actions`),
|
||||
@@ -177,34 +112,18 @@ export function DocumentsDataTable({
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const getEmptyStateStatus = (): ExtendedDocumentStatus => {
|
||||
if (selectedStatusValues.length > 0) {
|
||||
return selectedStatusValues[0] as ExtendedDocumentStatus;
|
||||
}
|
||||
return ExtendedDocumentStatus.ALL;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<DataTable
|
||||
data={results.data}
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
stats={data?.stats}
|
||||
onStatusFilterChange={handleStatusFilterChange}
|
||||
selectedStatusValues={selectedStatusValues}
|
||||
onTimePeriodFilterChange={handleTimePeriodFilterChange}
|
||||
selectedTimePeriodValues={selectedTimePeriodValues}
|
||||
onSourceFilterChange={handleSourceFilterChange}
|
||||
selectedSourceValues={selectedSourceValues}
|
||||
onResetFilters={handleResetFilters}
|
||||
isStatusFiltered={isStatusFiltered}
|
||||
isTimePeriodFiltered={isTimePeriodFiltered}
|
||||
isSourceFiltered={isSourceFiltered}
|
||||
showSourceFilter={false}
|
||||
columnVisibility={{
|
||||
sender: team !== undefined,
|
||||
}}
|
||||
error={{
|
||||
enable: isLoadingError || false,
|
||||
}}
|
||||
@@ -233,10 +152,6 @@ export function DocumentsDataTable({
|
||||
</>
|
||||
),
|
||||
}}
|
||||
emptyState={{
|
||||
enable: !isLoading && !isLoadingError,
|
||||
component: <DocumentsTableEmptyState status={getEmptyStateStatus()} />,
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
@@ -248,14 +163,14 @@ export function DocumentsDataTable({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type DataTableTitleProps = {
|
||||
row: DocumentsTableRow;
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||
const { user } = useSession();
|
||||
|
||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
export type TemplateDocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus };
|
||||
|
||||
export const TemplateDocumentsTableEmptyState = ({
|
||||
status,
|
||||
}: TemplateDocumentsTableEmptyStateProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const {
|
||||
title,
|
||||
message,
|
||||
icon: Icon,
|
||||
} = match(status)
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||
title: msg`No completed documents`,
|
||||
message: msg`No documents created from this template have been completed yet. Completed documents will appear here once all recipients have signed.`,
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||
title: msg`No draft documents`,
|
||||
message: msg`There are no draft documents created from this template. Use this template to create a new document.`,
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.PENDING, () => ({
|
||||
title: msg`No pending documents`,
|
||||
message: msg`There are no pending documents created from this template. Documents awaiting signatures will appear here.`,
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.REJECTED, () => ({
|
||||
title: msg`No rejected documents`,
|
||||
message: msg`No documents created from this template have been rejected. Documents that have been declined will appear here.`,
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.INBOX, () => ({
|
||||
title: msg`No documents in inbox`,
|
||||
message: msg`There are no documents from this template waiting for your action. Documents requiring your signature will appear here.`,
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
title: msg`No documents yet`,
|
||||
message: msg`No documents have been created from this template yet. Use this template to create your first document.`,
|
||||
icon: Bird,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
title: msg`No documents found`,
|
||||
message: msg`No documents created from this template match the current filters. Try adjusting your search criteria.`,
|
||||
icon: CheckCircle2,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-muted-foreground/60 mt-12 flex h-60 flex-col items-center justify-center gap-y-4"
|
||||
data-testid="empty-template-document-state"
|
||||
>
|
||||
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">{_(title)}</h3>
|
||||
|
||||
<p className="mt-2 max-w-[60ch]">{_(message)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,33 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FolderType } from '@prisma/client';
|
||||
import { FolderType, OrganisationType } from '@prisma/client';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/schema';
|
||||
import {
|
||||
type TFindDocumentsInternalResponse,
|
||||
ZFindDocumentsInternalRequestSchema,
|
||||
} from '@documenso/trpc/server/document-router/schema';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { DocumentsDataTable } from '~/components/tables/documents-table';
|
||||
import { PeriodSelector } from '~/components/general/period-selector';
|
||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -24,26 +36,17 @@ export function meta() {
|
||||
}
|
||||
|
||||
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
status: true,
|
||||
period: true,
|
||||
page: true,
|
||||
perPage: true,
|
||||
query: true,
|
||||
}).extend({
|
||||
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
||||
status: z
|
||||
.string()
|
||||
.transform(
|
||||
(val) =>
|
||||
val
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean) as ExtendedDocumentStatus[],
|
||||
)
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
});
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { folderId } = useParams();
|
||||
@@ -52,6 +55,15 @@ export default function DocumentsPage() {
|
||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
|
||||
|
||||
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
});
|
||||
|
||||
const findDocumentSearchParams = useMemo(
|
||||
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||
[searchParams],
|
||||
@@ -62,6 +74,42 @@ export default function DocumentsPage() {
|
||||
folderId,
|
||||
});
|
||||
|
||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
params.set('status', value);
|
||||
|
||||
if (value === ExtendedDocumentStatus.ALL) {
|
||||
params.delete('status');
|
||||
}
|
||||
|
||||
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
|
||||
params.delete('status');
|
||||
}
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
let path = formatDocumentsPath(team.url);
|
||||
|
||||
if (folderId) {
|
||||
path += `/f/${folderId}`;
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
path += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.stats) {
|
||||
setStats(data.stats);
|
||||
}
|
||||
}, [data?.stats]);
|
||||
|
||||
return (
|
||||
<DocumentDropZoneWrapper>
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
@@ -80,18 +128,72 @@ export default function DocumentsPage() {
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
]
|
||||
.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((value) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
className="hover:text-foreground min-w-[60px]"
|
||||
value={value}
|
||||
asChild
|
||||
>
|
||||
<Link to={getTabHref(value)} preventScrollReset>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<DocumentsDataTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoadingError={isLoadingError}
|
||||
onMoveDocument={(documentId) => {
|
||||
setDocumentToMove(documentId);
|
||||
setIsMovingDocument(true);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
{data && data.count === 0 ? (
|
||||
<DocumentsTableEmptyState
|
||||
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
||||
/>
|
||||
) : (
|
||||
<DocumentsTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoadingError={isLoadingError}
|
||||
onMoveDocument={(documentId) => {
|
||||
setDocumentToMove(documentId);
|
||||
setIsMovingDocument(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{documentToMove && (
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FolderType } from '@prisma/client';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Templates');
|
||||
}
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DateTime } from 'luxon';
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
@@ -91,25 +90,19 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
|
||||
<Card>
|
||||
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
<Trans>Document ID</Trans>
|
||||
</span>
|
||||
<span className="font-medium">{_(msg`Document ID`)}</span>
|
||||
|
||||
<span className="mt-1 block break-words">{document.id}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
<Trans>Enclosed Document</Trans>
|
||||
</span>
|
||||
<span className="font-medium">{_(msg`Enclosed Document`)}</span>
|
||||
|
||||
<span className="mt-1 block break-words">{document.title}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
<Trans>Status</Trans>
|
||||
</span>
|
||||
<span className="font-medium">{_(msg`Status`)}</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
{_(
|
||||
@@ -119,9 +112,7 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
<Trans>Owner</Trans>
|
||||
</span>
|
||||
<span className="font-medium">{_(msg`Owner`)}</span>
|
||||
|
||||
<span className="mt-1 block break-words">
|
||||
{document.user.name} ({document.user.email})
|
||||
@@ -129,9 +120,7 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
<Trans>Created At</Trans>
|
||||
</span>
|
||||
<span className="font-medium">{_(msg`Created At`)}</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
{DateTime.fromJSDate(document.createdAt)
|
||||
@@ -141,9 +130,7 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
<Trans>Last Updated</Trans>
|
||||
</span>
|
||||
<span className="font-medium">{_(msg`Last Updated`)}</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
{DateTime.fromJSDate(document.updatedAt)
|
||||
@@ -153,9 +140,7 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
<Trans>Time Zone</Trans>
|
||||
</span>
|
||||
<span className="font-medium">{_(msg`Time Zone`)}</span>
|
||||
|
||||
<span className="mt-1 block break-words">
|
||||
{document.documentMeta?.timezone ?? 'N/A'}
|
||||
@@ -163,9 +148,7 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
<Trans>Recipients</Trans>
|
||||
</p>
|
||||
<p className="font-medium">{_(msg`Recipients`)}</p>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{document.recipients.map((recipient) => (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FieldType, SigningStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { redirect } from 'react-router';
|
||||
@@ -200,9 +199,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
return (
|
||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||
<div className="flex items-center">
|
||||
<h1 className="my-8 text-2xl font-bold">
|
||||
<Trans>Signing Certificate</Trans>
|
||||
</h1>
|
||||
<h1 className="my-8 text-2xl font-bold">{_(msg`Signing Certificate`)}</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@@ -210,15 +207,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Trans>Signer Events</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Signature</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Details</Trans>
|
||||
</TableHead>
|
||||
<TableHead>{_(msg`Signer Events`)}</TableHead>
|
||||
<TableHead>{_(msg`Signature`)}</TableHead>
|
||||
<TableHead>{_(msg`Details`)}</TableHead>
|
||||
{/* <TableHead>Security</TableHead> */}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -238,9 +229,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">
|
||||
<Trans>Authentication Level</Trans>:
|
||||
</span>{' '}
|
||||
<span className="font-medium">{_(msg`Authentication Level`)}:</span>{' '}
|
||||
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
|
||||
</p>
|
||||
</TableCell>
|
||||
@@ -270,9 +259,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">
|
||||
<Trans>Signature ID</Trans>:
|
||||
</span>{' '}
|
||||
<span className="font-medium">{_(msg`Signature ID`)}:</span>{' '}
|
||||
<span className="block font-mono uppercase">
|
||||
{signature.secondaryId}
|
||||
</span>
|
||||
@@ -283,18 +270,14 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">
|
||||
<Trans>IP Address</Trans>:
|
||||
</span>{' '}
|
||||
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
|
||||
<span className="font-medium">
|
||||
<Trans>Device</Trans>:
|
||||
</span>{' '}
|
||||
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
|
||||
</span>
|
||||
@@ -304,9 +287,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">
|
||||
<Trans>Sent</Trans>:
|
||||
</span>{' '}
|
||||
<span className="font-medium">{_(msg`Sent`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.EMAIL_SENT[0]
|
||||
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
|
||||
@@ -317,9 +298,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">
|
||||
<Trans>Viewed</Trans>:
|
||||
</span>{' '}
|
||||
<span className="font-medium">{_(msg`Viewed`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_OPENED[0]
|
||||
? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt)
|
||||
@@ -331,9 +310,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
|
||||
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">
|
||||
<Trans>Rejected</Trans>:
|
||||
</span>{' '}
|
||||
<span className="font-medium">{_(msg`Rejected`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_REJECTED[0]
|
||||
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt)
|
||||
@@ -344,9 +321,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">
|
||||
<Trans>Signed</Trans>:
|
||||
</span>{' '}
|
||||
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
|
||||
? DateTime.fromJSDate(
|
||||
@@ -360,9 +335,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">
|
||||
<Trans>Reason</Trans>:
|
||||
</span>{' '}
|
||||
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{recipient.signingStatus === SigningStatus.REJECTED
|
||||
? recipient.rejectionReason
|
||||
@@ -398,7 +371,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||
<Trans>Signing certificate provided by</Trans>:
|
||||
{_(msg`Signing certificate provided by`)}:
|
||||
</p>
|
||||
<BrandingLogo className="max-h-6 print:max-h-4" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
@@ -52,8 +51,8 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
|
||||
if (!configuration || !configuration.documentData) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Please configure the document first`),
|
||||
title: _('Error'),
|
||||
description: _('Please configure the document first'),
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -104,8 +103,8 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Document created successfully`),
|
||||
title: _('Success'),
|
||||
description: _('Document created successfully'),
|
||||
});
|
||||
|
||||
// Send a message to the parent window with the document details
|
||||
@@ -131,8 +130,8 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to create document`),
|
||||
title: _('Error'),
|
||||
description: _('Failed to create document'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
@@ -50,8 +49,8 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
|
||||
if (!configuration || !configuration.documentData) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Please configure the template first`),
|
||||
title: _('Error'),
|
||||
description: _('Please configure the template first'),
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -94,8 +93,8 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Template created successfully`),
|
||||
title: _('Success'),
|
||||
description: _('Template created successfully'),
|
||||
});
|
||||
|
||||
// Send a message to the parent window with the template details
|
||||
@@ -121,8 +120,8 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to create template`),
|
||||
title: _('Error'),
|
||||
description: _('Failed to create template'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -330,7 +330,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
formValues: body.formValues,
|
||||
folderId: body.folderId,
|
||||
documentDataId: documentData.id,
|
||||
requestMetadata: metadata,
|
||||
});
|
||||
@@ -555,6 +554,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
status: 200,
|
||||
body: {
|
||||
...template,
|
||||
templateMeta: template.templateMeta
|
||||
? {
|
||||
...template.templateMeta,
|
||||
templateId: template.id,
|
||||
}
|
||||
: null,
|
||||
Field: template.fields.map((field) => ({
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null,
|
||||
@@ -737,7 +742,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
teamId: team?.id,
|
||||
recipients: body.recipients,
|
||||
prefillFields: body.prefillFields,
|
||||
folderId: body.folderId,
|
||||
override: {
|
||||
title: body.title,
|
||||
...body.meta,
|
||||
|
||||
@@ -136,12 +136,6 @@ export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSucc
|
||||
export const ZCreateDocumentMutationSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
externalId: z.string().nullish(),
|
||||
folderId: z
|
||||
.string()
|
||||
.describe(
|
||||
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
|
||||
)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -293,12 +287,6 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
|
||||
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
externalId: z.string().optional(),
|
||||
folderId: z
|
||||
.string()
|
||||
.describe(
|
||||
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
|
||||
)
|
||||
.optional(),
|
||||
recipients: z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta, DocumentVisibility } from '@prisma/client';
|
||||
import {
|
||||
DocumentSource,
|
||||
FolderType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
@@ -46,9 +45,8 @@ export type CreateDocumentOptions = {
|
||||
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||
formValues?: TDocumentFormValues;
|
||||
recipients: TCreateDocumentV2Request['recipients'];
|
||||
folderId?: string;
|
||||
};
|
||||
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
@@ -61,7 +59,7 @@ export const createDocumentV2 = async ({
|
||||
meta,
|
||||
requestMetadata,
|
||||
}: CreateDocumentOptions) => {
|
||||
const { title, formValues, folderId } = data;
|
||||
const { title, formValues } = data;
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({ teamId, userId }),
|
||||
@@ -80,22 +78,6 @@ export const createDocumentV2 = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id: folderId,
|
||||
type: FolderType.DOCUMENT,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId,
|
||||
teamId,
|
||||
@@ -182,7 +164,6 @@ export const createDocumentV2 = async ({
|
||||
teamId,
|
||||
authOptions,
|
||||
visibility,
|
||||
folderId,
|
||||
formValues,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
documentMeta: {
|
||||
|
||||
@@ -5,26 +5,27 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import type { TimePeriod } from '@documenso/ui/primitives/data-table/utils/time-filters';
|
||||
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import { type FindResultResponse } from '../../types/search-params';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||
|
||||
export type FindDocumentsOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
templateId?: number;
|
||||
source?: DocumentSource;
|
||||
status?: ExtendedDocumentStatus[];
|
||||
status?: ExtendedDocumentStatus;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof Omit<Document, 'document'>;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
period?: TimePeriod;
|
||||
period?: PeriodSelectorValue;
|
||||
senderIds?: number[];
|
||||
query?: string;
|
||||
folderId?: string;
|
||||
@@ -35,7 +36,7 @@ export const findDocuments = async ({
|
||||
teamId,
|
||||
templateId,
|
||||
source,
|
||||
status = [ExtendedDocumentStatus.ALL],
|
||||
status = ExtendedDocumentStatus.ALL,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
@@ -105,30 +106,10 @@ export const findDocuments = async ({
|
||||
},
|
||||
];
|
||||
|
||||
let filters: Prisma.DocumentWhereInput | null = null;
|
||||
|
||||
if (status.length === 1) {
|
||||
filters = findDocumentsFilter(status[0], user, folderId);
|
||||
} else if (status.length > 1) {
|
||||
const statusFilters = status
|
||||
.map((s) => findDocumentsFilter(s, user, folderId))
|
||||
.filter((filter): filter is Prisma.DocumentWhereInput => filter !== null);
|
||||
if (statusFilters.length > 0) {
|
||||
filters = { OR: statusFilters };
|
||||
}
|
||||
}
|
||||
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user, folderId);
|
||||
|
||||
if (team) {
|
||||
if (status.length === 1) {
|
||||
filters = findTeamDocumentsFilter(status[0], team, visibilityFilters, folderId);
|
||||
} else if (status.length > 1) {
|
||||
const statusFilters = status
|
||||
.map((s) => findTeamDocumentsFilter(s, team, visibilityFilters, folderId))
|
||||
.filter((filter): filter is Prisma.DocumentWhereInput => filter !== null);
|
||||
if (statusFilters.length > 0) {
|
||||
filters = { OR: statusFilters };
|
||||
}
|
||||
}
|
||||
filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId);
|
||||
}
|
||||
|
||||
if (filters === null) {
|
||||
@@ -216,73 +197,13 @@ export const findDocuments = async ({
|
||||
AND: whereAndClause,
|
||||
};
|
||||
|
||||
if (period && period !== 'all-time') {
|
||||
const now = DateTime.now();
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
|
||||
const { startDate, endDate } = match(period)
|
||||
.with('today', () => ({
|
||||
startDate: now.startOf('day'),
|
||||
endDate: now.startOf('day').plus({ days: 1 }),
|
||||
}))
|
||||
.with('yesterday', () => {
|
||||
const yesterday = now.minus({ days: 1 });
|
||||
return {
|
||||
startDate: yesterday.startOf('day'),
|
||||
endDate: yesterday.startOf('day').plus({ days: 1 }),
|
||||
};
|
||||
})
|
||||
.with('this-week', () => ({
|
||||
startDate: now.startOf('week'),
|
||||
endDate: now.startOf('week').plus({ weeks: 1 }),
|
||||
}))
|
||||
.with('last-week', () => {
|
||||
const lastWeek = now.minus({ weeks: 1 });
|
||||
return {
|
||||
startDate: lastWeek.startOf('week'),
|
||||
endDate: lastWeek.startOf('week').plus({ weeks: 1 }),
|
||||
};
|
||||
})
|
||||
.with('this-month', () => ({
|
||||
startDate: now.startOf('month'),
|
||||
endDate: now.startOf('month').plus({ months: 1 }),
|
||||
}))
|
||||
.with('last-month', () => {
|
||||
const lastMonth = now.minus({ months: 1 });
|
||||
return {
|
||||
startDate: lastMonth.startOf('month'),
|
||||
endDate: lastMonth.startOf('month').plus({ months: 1 }),
|
||||
};
|
||||
})
|
||||
.with('this-quarter', () => ({
|
||||
startDate: now.startOf('quarter'),
|
||||
endDate: now.startOf('quarter').plus({ quarters: 1 }),
|
||||
}))
|
||||
.with('last-quarter', () => {
|
||||
const lastQuarter = now.minus({ quarters: 1 });
|
||||
return {
|
||||
startDate: lastQuarter.startOf('quarter'),
|
||||
endDate: lastQuarter.startOf('quarter').plus({ quarters: 1 }),
|
||||
};
|
||||
})
|
||||
.with('this-year', () => ({
|
||||
startDate: now.startOf('year'),
|
||||
endDate: now.startOf('year').plus({ years: 1 }),
|
||||
}))
|
||||
.with('last-year', () => {
|
||||
const lastYear = now.minus({ years: 1 });
|
||||
return {
|
||||
startDate: lastYear.startOf('year'),
|
||||
endDate: lastYear.startOf('year').plus({ years: 1 }),
|
||||
};
|
||||
})
|
||||
.otherwise(() => ({
|
||||
startDate: now.startOf('day'),
|
||||
endDate: now.startOf('day').plus({ days: 1 }),
|
||||
}));
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
whereClause.createdAt = {
|
||||
gte: startDate.toJSDate(),
|
||||
lt: endDate.toJSDate(),
|
||||
gte: startOfPeriod.toJSDate(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,32 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type GetDocumentByIdOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const getDocumentById = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
}: GetDocumentByIdOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumentByIdOptions) => {
|
||||
const { documentWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
validatedUserId: userId,
|
||||
unvalidatedTeamId: teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
...documentWhereInput,
|
||||
folderId,
|
||||
},
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
documentData: true,
|
||||
documents: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
user: {
|
||||
select: {
|
||||
@@ -56,7 +49,7 @@ export const getDocumentById = async ({
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document could not be found',
|
||||
});
|
||||
@@ -64,93 +57,3 @@ export const getDocumentById = async ({
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
export type GetDocumentWhereInputOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the where input for a given Prisma document query.
|
||||
*
|
||||
* This will return a query that allows a user to get a document if they have valid access to it.
|
||||
*/
|
||||
export const getDocumentWhereInput = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetDocumentWhereInputOptions) => {
|
||||
const team = await getTeamById({ teamId, userId });
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const teamVisibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
])
|
||||
.with(TeamMemberRole.MANAGER, () => [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
])
|
||||
.otherwise(() => [DocumentVisibility.EVERYONE]);
|
||||
|
||||
const documentOrInput: Prisma.DocumentWhereInput[] = [
|
||||
// Allow access if they own the document.
|
||||
{
|
||||
userId,
|
||||
},
|
||||
// Or, if they belong to the team that the document is associated with.
|
||||
{
|
||||
visibility: {
|
||||
in: teamVisibilityFilters,
|
||||
},
|
||||
teamId: team.id,
|
||||
},
|
||||
// Or, if they are a recipient of the document.
|
||||
{
|
||||
status: {
|
||||
not: DocumentStatus.DRAFT,
|
||||
},
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Allow access to documents sent to or from the team email.
|
||||
if (team.teamEmail) {
|
||||
documentOrInput.push(
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const documentWhereInput: Prisma.DocumentWhereUniqueInput = {
|
||||
id: documentId,
|
||||
OR: documentOrInput,
|
||||
};
|
||||
|
||||
return {
|
||||
documentWhereInput,
|
||||
team,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface GetDocumentMetaByDocumentIdOptions {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export const getDocumentMetaByDocumentId = async ({ id }: GetDocumentMetaByDocumentIdOptions) => {
|
||||
return await prisma.documentMeta.findFirstOrThrow({
|
||||
where: {
|
||||
documentId: id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,17 +1,19 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import type { Prisma, User } from '@prisma/client';
|
||||
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import type { TimePeriod } from '@documenso/ui/primitives/data-table/utils/time-filters';
|
||||
import { getDateRangeForPeriod } from '@documenso/ui/primitives/data-table/utils/time-filters';
|
||||
|
||||
export type GetStatsInput = {
|
||||
user: Pick<User, 'id' | 'email'>;
|
||||
team?: Omit<GetTeamCountsOption, 'createdAt'>;
|
||||
period?: TimePeriod;
|
||||
period?: PeriodSelectorValue;
|
||||
search?: string;
|
||||
folderId?: string;
|
||||
};
|
||||
@@ -25,15 +27,14 @@ export const getStats = async ({
|
||||
}: GetStatsInput) => {
|
||||
let createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
|
||||
if (period && period !== 'all-time') {
|
||||
const dateRange = getDateRangeForPeriod(period);
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
|
||||
if (dateRange) {
|
||||
createdAt = {
|
||||
gte: dateRange.start.toJSDate(),
|
||||
lte: dateRange.end.toJSDate(),
|
||||
};
|
||||
}
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
createdAt = {
|
||||
gte: startOfPeriod.toJSDate(),
|
||||
};
|
||||
}
|
||||
|
||||
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { buildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type GetEnvelopeByIdOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const getEnvelopeById = async ({ id, userId, teamId }: GetEnvelopeByIdOptions) => {
|
||||
const { documentWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
validatedUserId: userId,
|
||||
unvalidatedTeamId: teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.envelope.findFirst({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
documents: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
recipients: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document could not be found',
|
||||
});
|
||||
}
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
export type GetEnvelopeWhereInputOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
|
||||
/**
|
||||
* The user ID who has been authenticated.
|
||||
*/
|
||||
validatedUserId: number;
|
||||
|
||||
/**
|
||||
* The unknown teamId from the request.
|
||||
*/
|
||||
unvalidatedTeamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the where input for a given Prisma envelope query.
|
||||
*
|
||||
* This will return a query that allows a user to get a document if they have valid access to it.
|
||||
*/
|
||||
export const getEnvelopeWhereInput = async ({
|
||||
id,
|
||||
validatedUserId,
|
||||
unvalidatedTeamId,
|
||||
}: GetEnvelopeWhereInputOptions) => {
|
||||
const team = await getTeamById({ teamId: unvalidatedTeamId, userId: validatedUserId });
|
||||
|
||||
const teamVisibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
])
|
||||
.with(TeamMemberRole.MANAGER, () => [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
])
|
||||
.otherwise(() => [DocumentVisibility.EVERYONE]);
|
||||
|
||||
const documentOrInput: Prisma.EnvelopeWhereInput[] = [
|
||||
// Allow access if they own the document.
|
||||
{
|
||||
userId: validatedUserId,
|
||||
},
|
||||
// Or, if they belong to the team that the document is associated with.
|
||||
{
|
||||
visibility: {
|
||||
in: teamVisibilityFilters,
|
||||
},
|
||||
teamId: team.id,
|
||||
},
|
||||
// Or, if they are a recipient of the document.
|
||||
// ????????????? should recipients be able to do X?
|
||||
// {
|
||||
// status: {
|
||||
// not: DocumentStatus.DRAFT,
|
||||
// },
|
||||
// recipients: {
|
||||
// some: {
|
||||
// email: user.email,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
];
|
||||
|
||||
// Allow access to documents sent to or from the team email.
|
||||
if (team.teamEmail) {
|
||||
documentOrInput.push(
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const documentWhereInput: Prisma.EnvelopeWhereUniqueInput = {
|
||||
...buildEnvelopeIdQuery(id),
|
||||
OR: documentOrInput,
|
||||
};
|
||||
|
||||
return {
|
||||
documentWhereInput,
|
||||
team,
|
||||
};
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import type { PDFDocument } from 'pdf-lib';
|
||||
import { TextAlignment, rgb, setFontAndSize } from 'pdf-lib';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { getPageSize } from './get-page-size';
|
||||
|
||||
/**
|
||||
* Adds a rejection stamp to each page of a PDF document.
|
||||
@@ -28,7 +27,7 @@ export async function addRejectionStampToPdf(
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
const { width, height } = getPageSize(page);
|
||||
const { width, height } = page.getSize();
|
||||
|
||||
// Draw the "REJECTED" text
|
||||
const rejectedTitleText = 'DOCUMENT REJECTED';
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { PDFPage } from 'pdf-lib';
|
||||
|
||||
/**
|
||||
* Gets the effective page size for PDF operations.
|
||||
*
|
||||
* Uses CropBox by default to handle rare cases where MediaBox is larger than CropBox.
|
||||
* Falls back to MediaBox when it's smaller than CropBox, following typical PDF reader behavior.
|
||||
*/
|
||||
export const getPageSize = (page: PDFPage) => {
|
||||
const cropBox = page.getCropBox();
|
||||
const mediaBox = page.getMediaBox();
|
||||
|
||||
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
|
||||
return mediaBox;
|
||||
}
|
||||
|
||||
return cropBox;
|
||||
};
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
import { getPageSize } from './get-page-size';
|
||||
|
||||
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
const [fontCaveat, fontNoto] = await Promise.all([
|
||||
@@ -78,7 +77,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
|
||||
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
|
||||
|
||||
let { width: pageWidth, height: pageHeight } = getPageSize(page);
|
||||
let { width: pageWidth, height: pageHeight } = page.getSize();
|
||||
|
||||
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
|
||||
// However when we load the PDF in the backend, the rotation is applied.
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
import { getPageSize } from './get-page-size';
|
||||
|
||||
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
const [fontCaveat, fontNoto] = await Promise.all([
|
||||
@@ -64,7 +63,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
|
||||
|
||||
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
|
||||
|
||||
let { width: pageWidth, height: pageHeight } = getPageSize(page);
|
||||
let { width: pageWidth, height: pageHeight } = page.getSize();
|
||||
|
||||
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
|
||||
// However when we load the PDF in the backend, the rotation is applied.
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/c
|
||||
import {
|
||||
DocumentSource,
|
||||
type Field,
|
||||
FolderType,
|
||||
type Recipient,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
@@ -70,7 +69,6 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
email: string;
|
||||
signingOrder?: number | null;
|
||||
}[];
|
||||
folderId?: string;
|
||||
prefillFields?: TFieldMetaPrefillFieldsSchema[];
|
||||
customDocumentDataId?: string;
|
||||
|
||||
@@ -276,7 +274,6 @@ export const createDocumentFromTemplate = async ({
|
||||
customDocumentDataId,
|
||||
override,
|
||||
requestMetadata,
|
||||
folderId,
|
||||
prefillFields,
|
||||
}: CreateDocumentFromTemplateOptions) => {
|
||||
const template = await prisma.template.findUnique({
|
||||
@@ -301,22 +298,6 @@ export const createDocumentFromTemplate = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id: folderId,
|
||||
type: FolderType.DOCUMENT,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId,
|
||||
teamId,
|
||||
@@ -387,7 +368,6 @@ export const createDocumentFromTemplate = async ({
|
||||
externalId: externalId || template.externalId,
|
||||
templateId: template.id,
|
||||
userId,
|
||||
folderId,
|
||||
teamId: template.teamId,
|
||||
title: override?.title || template.title,
|
||||
documentDataId: documentData.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta, DocumentVisibility, Template } from '@prisma/client';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -26,7 +26,7 @@ export type CreateTemplateOptions = {
|
||||
publicDescription?: string;
|
||||
type?: Template['type'];
|
||||
};
|
||||
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
|
||||
};
|
||||
|
||||
export const ZCreateTemplateResponseSchema = TemplateSchema;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta, DocumentVisibility, Template } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@@ -22,7 +22,7 @@ export type UpdateTemplateOptions = {
|
||||
type?: Template['type'];
|
||||
useLegacyFieldInsertion?: boolean;
|
||||
};
|
||||
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
|
||||
};
|
||||
|
||||
export const updateTemplate = async ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@@ -25,9 +25,10 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
||||
const serviceAccount = await deletedAccountServiceAccount();
|
||||
|
||||
// TODO: Send out cancellations for all pending docs
|
||||
await prisma.document.updateMany({
|
||||
await prisma.envelope.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: {
|
||||
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { EnvelopeType, Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@@ -34,12 +34,20 @@ export const findUsers = async ({
|
||||
|
||||
const [users, count] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
include: {
|
||||
documents: {
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
id: true,
|
||||
envelopes: {
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
roles: true,
|
||||
},
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
@@ -51,7 +59,10 @@ export const findUsers = async ({
|
||||
]);
|
||||
|
||||
return {
|
||||
users,
|
||||
users: users.map((user) => ({
|
||||
...user,
|
||||
documentCount: user._count.envelopes,
|
||||
})),
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { z } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
import { DocumentSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentSchema';
|
||||
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
|
||||
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
import { LegacyDocumentSchema } from '@documenso/prisma/types/document-legacy-schema';
|
||||
|
||||
import { ZFieldSchema } from './field';
|
||||
import { ZRecipientLiteSchema } from './recipient';
|
||||
@@ -15,7 +15,7 @@ import { ZRecipientLiteSchema } from './recipient';
|
||||
*
|
||||
* Mainly used for returning a single document from the API.
|
||||
*/
|
||||
export const ZDocumentSchema = DocumentSchema.pick({
|
||||
export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
visibility: true,
|
||||
status: true,
|
||||
source: true,
|
||||
@@ -31,9 +31,12 @@ export const ZDocumentSchema = DocumentSchema.pick({
|
||||
completedAt: true,
|
||||
deletedAt: true,
|
||||
teamId: true,
|
||||
templateId: true,
|
||||
folderId: true,
|
||||
}).extend({
|
||||
// Which "Template" the document was created from. Legacy field for backwards compatibility.
|
||||
// The actual field is now called `createdFromDocumentId`.
|
||||
templateId: z.number().optional(),
|
||||
|
||||
// Todo: Maybe we want to alter this a bit since this returns a lot of data.
|
||||
documentData: DocumentDataSchema.pick({
|
||||
type: true,
|
||||
@@ -82,7 +85,7 @@ export type TDocument = z.infer<typeof ZDocumentSchema>;
|
||||
/**
|
||||
* A lite version of the document response schema without relations.
|
||||
*/
|
||||
export const ZDocumentLiteSchema = DocumentSchema.pick({
|
||||
export const ZDocumentLiteSchema = LegacyDocumentSchema.pick({
|
||||
visibility: true,
|
||||
status: true,
|
||||
source: true,
|
||||
@@ -98,9 +101,12 @@ export const ZDocumentLiteSchema = DocumentSchema.pick({
|
||||
completedAt: true,
|
||||
deletedAt: true,
|
||||
teamId: true,
|
||||
templateId: true,
|
||||
folderId: true,
|
||||
useLegacyFieldInsertion: true,
|
||||
}).extend({
|
||||
// Which "Template" the document was created from. Legacy field for backwards compatibility.
|
||||
// The actual field is now called `createdFromDocumentId`.
|
||||
templateId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TDocumentLite = z.infer<typeof ZDocumentLiteSchema>;
|
||||
@@ -108,7 +114,7 @@ export type TDocumentLite = z.infer<typeof ZDocumentLiteSchema>;
|
||||
/**
|
||||
* A version of the document response schema when returning multiple documents at once from a single API endpoint.
|
||||
*/
|
||||
export const ZDocumentManySchema = DocumentSchema.pick({
|
||||
export const ZDocumentManySchema = LegacyDocumentSchema.pick({
|
||||
visibility: true,
|
||||
status: true,
|
||||
source: true,
|
||||
@@ -124,10 +130,13 @@ export const ZDocumentManySchema = DocumentSchema.pick({
|
||||
completedAt: true,
|
||||
deletedAt: true,
|
||||
teamId: true,
|
||||
templateId: true,
|
||||
folderId: true,
|
||||
useLegacyFieldInsertion: true,
|
||||
}).extend({
|
||||
// Which "Template" the document was created from. Legacy field for backwards compatibility.
|
||||
// The actual field is now called `createdFromDocumentId`.
|
||||
templateId: z.number().optional(),
|
||||
|
||||
user: UserSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
|
||||
@@ -18,8 +18,6 @@ export const ZFieldSchema = FieldSchema.pick({
|
||||
type: true,
|
||||
id: true,
|
||||
secondaryId: true,
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
recipientId: true,
|
||||
page: true,
|
||||
positionX: true,
|
||||
@@ -29,6 +27,10 @@ export const ZFieldSchema = FieldSchema.pick({
|
||||
customText: true,
|
||||
inserted: true,
|
||||
fieldMeta: true,
|
||||
}).extend({
|
||||
// Todo: Decide whether to make these two IDs backwards compatible.
|
||||
documentId: z.number().optional(),
|
||||
templateId: z.number().optional(),
|
||||
});
|
||||
|
||||
export const ZFieldPageNumberSchema = z
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
|
||||
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
@@ -15,8 +17,6 @@ export const ZRecipientSchema = RecipientSchema.pick({
|
||||
signingStatus: true,
|
||||
sendStatus: true,
|
||||
id: true,
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
token: true,
|
||||
@@ -28,6 +28,10 @@ export const ZRecipientSchema = RecipientSchema.pick({
|
||||
rejectionReason: true,
|
||||
}).extend({
|
||||
fields: ZFieldSchema.array(),
|
||||
|
||||
// Todo: Decide whether to make these two IDs backwards compatible.
|
||||
documentId: z.number().optional(),
|
||||
templateId: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -39,8 +43,6 @@ export const ZRecipientLiteSchema = RecipientSchema.pick({
|
||||
signingStatus: true,
|
||||
sendStatus: true,
|
||||
id: true,
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
token: true,
|
||||
@@ -50,6 +52,10 @@ export const ZRecipientLiteSchema = RecipientSchema.pick({
|
||||
authOptions: true,
|
||||
signingOrder: true,
|
||||
rejectionReason: true,
|
||||
}).extend({
|
||||
// Todo: Decide whether to make these two IDs backwards compatible.
|
||||
documentId: z.number().optional(),
|
||||
templateId: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -61,8 +67,6 @@ export const ZRecipientManySchema = RecipientSchema.pick({
|
||||
signingStatus: true,
|
||||
sendStatus: true,
|
||||
id: true,
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
token: true,
|
||||
@@ -83,4 +87,8 @@ export const ZRecipientManySchema = RecipientSchema.pick({
|
||||
id: true,
|
||||
url: true,
|
||||
}).nullable(),
|
||||
|
||||
// Todo: Decide whether to make these two IDs backwards compatible.
|
||||
documentId: z.number().optional(),
|
||||
templateId: z.number().optional(),
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { z } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
|
||||
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
|
||||
import { TemplateMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateMetaSchema';
|
||||
import { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateSchema';
|
||||
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
import { TemplateSchema } from '@documenso/prisma/types/template-legacy-schema';
|
||||
|
||||
import { ZFieldSchema } from './field';
|
||||
import { ZRecipientLiteSchema } from './recipient';
|
||||
@@ -39,7 +39,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
||||
data: true,
|
||||
initialData: true,
|
||||
}),
|
||||
templateMeta: TemplateMetaSchema.pick({
|
||||
templateMeta: DocumentMetaSchema.pick({
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
@@ -51,13 +51,17 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
distributionMethod: true,
|
||||
templateId: true,
|
||||
redirectUrl: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
emailId: true,
|
||||
emailReplyTo: true,
|
||||
}).nullable(),
|
||||
})
|
||||
.extend({
|
||||
// Legacy field for backwards compatibility. Needs to refer to the Envelope `secondaryTemplateId`.
|
||||
templateId: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
directLink: TemplateDirectLinkSchema.nullable(),
|
||||
user: UserSchema.pick({
|
||||
id: true,
|
||||
@@ -129,7 +133,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
|
||||
}).nullable(),
|
||||
fields: ZFieldSchema.array(),
|
||||
recipients: ZRecipientLiteSchema.array(),
|
||||
templateMeta: TemplateMetaSchema.pick({
|
||||
templateMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
}).nullable(),
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import type {
|
||||
Document,
|
||||
DocumentMeta,
|
||||
OrganisationGlobalSettings,
|
||||
TemplateMeta,
|
||||
} from '@prisma/client';
|
||||
import type { DocumentMeta, Envelope, OrganisationGlobalSettings } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
|
||||
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
|
||||
|
||||
export const isDocumentCompleted = (document: Pick<Document, 'status'> | DocumentStatus) => {
|
||||
export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | DocumentStatus) => {
|
||||
const status = typeof document === 'string' ? document : document.status;
|
||||
|
||||
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
|
||||
@@ -29,7 +24,7 @@ export const isDocumentCompleted = (document: Pick<Document, 'status'> | Documen
|
||||
*/
|
||||
export const extractDerivedDocumentMeta = (
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>,
|
||||
overrideMeta: Partial<DocumentMeta | TemplateMeta> | undefined | null,
|
||||
overrideMeta: Partial<DocumentMeta> | undefined | null,
|
||||
) => {
|
||||
const meta = overrideMeta ?? {};
|
||||
|
||||
@@ -58,5 +53,5 @@ export const extractDerivedDocumentMeta = (
|
||||
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
|
||||
emailSettings:
|
||||
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||
} satisfies Omit<DocumentMeta, 'id' | 'documentId'>;
|
||||
} satisfies Omit<DocumentMeta, 'id' | 'envelopeId'>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
|
||||
const envelopeDocumentPrefixId = 'document';
|
||||
const envelopeTemplatePrefixId = 'template';
|
||||
const envelopePrefixId = 'envelope';
|
||||
|
||||
const ZDocumentIdSchema = z.string().regex(/^document_\d+$/);
|
||||
const ZTemplateIdSchema = z.string().regex(/^template_\d+$/);
|
||||
const ZEnvelopeIdSchema = z.string().regex(/^envelope_\d+$/);
|
||||
|
||||
export type EnvelopeIdOptions =
|
||||
| {
|
||||
type: 'envelopeId';
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'documentId';
|
||||
id: string | number;
|
||||
}
|
||||
| {
|
||||
type: 'templateId';
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses an unknown document or template ID.
|
||||
*
|
||||
* @param id
|
||||
* @param type
|
||||
* @returns
|
||||
*/
|
||||
export const buildEnvelopeIdQuery = (options: EnvelopeIdOptions) => {
|
||||
return match(options)
|
||||
.with({ type: 'envelopeId' }, (value) => {
|
||||
const parsed = ZEnvelopeIdSchema.safeParse(value.id);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid envelope ID',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
};
|
||||
})
|
||||
.with({ type: 'documentId' }, (value) => ({
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
secondaryId: parseDocumentIdToEnvelopeSecondaryId(value.id),
|
||||
}))
|
||||
.with({ type: 'templateId' }, (value) => ({
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
secondaryId: parseTemplateIdToEnvelopeSecondaryId(value.id),
|
||||
}))
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
export const parseDocumentIdToEnvelopeSecondaryId = (documentId: string | number) => {
|
||||
if (typeof documentId === 'number') {
|
||||
return `${envelopeDocumentPrefixId}_${documentId}`;
|
||||
}
|
||||
|
||||
const parsed = ZDocumentIdSchema.safeParse(documentId);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid document ID',
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
export const parseTemplateIdToEnvelopeSecondaryId = (templateId: string | number) => {
|
||||
if (typeof templateId === 'number') {
|
||||
return `${envelopeTemplatePrefixId}_${templateId}`;
|
||||
}
|
||||
|
||||
const parsed = ZTemplateIdSchema.safeParse(templateId);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid template ID',
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { type Recipient } from '@prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TemplateMeta" DROP CONSTRAINT "TemplateMeta_templateId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentMeta" ADD COLUMN "templateId" INTEGER,
|
||||
ALTER COLUMN "documentId" DROP NOT NULL;
|
||||
|
||||
-- [CUSTOM_CHANGE] Migrate existing TemplateMeta to DocumentMeta
|
||||
INSERT INTO "DocumentMeta" (
|
||||
"id",
|
||||
"subject",
|
||||
"message",
|
||||
"timezone",
|
||||
"password",
|
||||
"dateFormat",
|
||||
"redirectUrl",
|
||||
"signingOrder",
|
||||
"allowDictateNextSigner",
|
||||
"typedSignatureEnabled",
|
||||
"uploadSignatureEnabled",
|
||||
"drawSignatureEnabled",
|
||||
"language",
|
||||
"distributionMethod",
|
||||
"emailSettings",
|
||||
"emailReplyTo",
|
||||
"emailId",
|
||||
"templateId"
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid()::text, -- Generate new CUID-like IDs to avoid collisions
|
||||
"subject",
|
||||
"message",
|
||||
"timezone",
|
||||
"password",
|
||||
"dateFormat",
|
||||
"redirectUrl",
|
||||
"signingOrder",
|
||||
"allowDictateNextSigner",
|
||||
"typedSignatureEnabled",
|
||||
"uploadSignatureEnabled",
|
||||
"drawSignatureEnabled",
|
||||
"language",
|
||||
"distributionMethod",
|
||||
"emailSettings",
|
||||
"emailReplyTo",
|
||||
"emailId",
|
||||
"templateId"
|
||||
FROM "TemplateMeta";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "TemplateMeta";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DocumentMeta_templateId_key" ON "DocumentMeta"("templateId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentMeta" ADD CONSTRAINT "DocumentMeta_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `authOptions` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `completedAt` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `createdAt` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `deletedAt` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `externalId` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `folderId` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `formValues` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `source` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `status` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `teamId` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `templateId` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `updatedAt` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `useLegacyFieldInsertion` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `userId` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `visibility` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `documentId` on the `DocumentAuditLog` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `documentId` on the `DocumentMeta` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `templateId` on the `DocumentMeta` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `documentId` on the `DocumentShareLink` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `documentId` on the `Field` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `templateId` on the `Field` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `documentId` on the `Recipient` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `templateId` on the `Recipient` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `templateId` on the `TemplateDirectLink` table. All the data in the column 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.
|
||||
- A unique constraint covering the columns `[envelopeId,email]` on the table `DocumentShareLink` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[envelopeId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[envelopeId]` on the table `TemplateDirectLink` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `envelopeId` to the `DocumentAuditLog` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `envelopeId` to the `DocumentShareLink` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `envelopeId` to the `Field` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `envelopeId` to the `Recipient` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `envelopeId` to the `TemplateDirectLink` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `type` to the `TemplateDirectLink` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EnvelopeType" AS ENUM ('DOCUMENT', 'TEMPLATE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TemplateDirectLinkType" AS ENUM ('PUBLIC', 'PRIVATE');
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Document" DROP CONSTRAINT "Document_folderId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Document" DROP CONSTRAINT "Document_teamId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Document" DROP CONSTRAINT "Document_templateId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Document" DROP CONSTRAINT "Document_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "DocumentAuditLog" DROP CONSTRAINT "DocumentAuditLog_documentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "DocumentMeta" DROP CONSTRAINT "DocumentMeta_documentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "DocumentMeta" DROP CONSTRAINT "DocumentMeta_templateId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "DocumentShareLink" DROP CONSTRAINT "DocumentShareLink_documentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Field" DROP CONSTRAINT "Field_documentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Field" DROP CONSTRAINT "Field_templateId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Recipient" DROP CONSTRAINT "Recipient_documentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Recipient" DROP CONSTRAINT "Recipient_templateId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_folderId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_teamId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDocumentDataId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TemplateDirectLink" DROP CONSTRAINT "TemplateDirectLink_templateId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Document_folderId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Document_status_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Document_userId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "DocumentMeta_documentId_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "DocumentMeta_templateId_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "DocumentShareLink_documentId_email_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Field_documentId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Field_templateId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_documentId_email_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_documentId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_templateId_email_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_templateId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "TemplateDirectLink_templateId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" DROP COLUMN "authOptions",
|
||||
DROP COLUMN "completedAt",
|
||||
DROP COLUMN "createdAt",
|
||||
DROP COLUMN "deletedAt",
|
||||
DROP COLUMN "externalId",
|
||||
DROP COLUMN "folderId",
|
||||
DROP COLUMN "formValues",
|
||||
DROP COLUMN "source",
|
||||
DROP COLUMN "status",
|
||||
DROP COLUMN "teamId",
|
||||
DROP COLUMN "templateId",
|
||||
DROP COLUMN "updatedAt",
|
||||
DROP COLUMN "useLegacyFieldInsertion",
|
||||
DROP COLUMN "userId",
|
||||
DROP COLUMN "visibility";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentAuditLog" DROP COLUMN "documentId",
|
||||
ADD COLUMN "envelopeId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentMeta" DROP COLUMN "documentId",
|
||||
DROP COLUMN "templateId",
|
||||
ADD COLUMN "envelopeId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentShareLink" DROP COLUMN "documentId",
|
||||
ADD COLUMN "envelopeId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Field" DROP COLUMN "documentId",
|
||||
DROP COLUMN "templateId",
|
||||
ADD COLUMN "envelopeId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Recipient" DROP COLUMN "documentId",
|
||||
DROP COLUMN "templateId",
|
||||
ADD COLUMN "envelopeId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TemplateDirectLink" DROP COLUMN "templateId",
|
||||
ADD COLUMN "envelopeId" TEXT NOT NULL,
|
||||
ADD COLUMN "type" "TemplateDirectLinkType" NOT NULL;
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Template";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "TemplateType";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Envelope" (
|
||||
"id" TEXT NOT NULL,
|
||||
"secondaryId" TEXT NOT NULL,
|
||||
"externalId" TEXT,
|
||||
"type" "EnvelopeType" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
"title" TEXT NOT NULL,
|
||||
"status" "DocumentStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"source" "DocumentSource" NOT NULL,
|
||||
"useLegacyFieldInsertion" BOOLEAN NOT NULL DEFAULT false,
|
||||
"authOptions" JSONB,
|
||||
"formValues" JSONB,
|
||||
"visibility" "DocumentVisibility" NOT NULL DEFAULT 'EVERYONE',
|
||||
"publicTitle" TEXT NOT NULL DEFAULT '',
|
||||
"publicDescription" TEXT NOT NULL DEFAULT '',
|
||||
"templateId" INTEGER,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"teamId" INTEGER NOT NULL,
|
||||
"folderId" TEXT,
|
||||
"documentMetaId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Envelope_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Envelope_secondaryId_key" ON "Envelope"("secondaryId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Envelope_documentMetaId_key" ON "Envelope"("documentMetaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DocumentShareLink_envelopeId_email_key" ON "DocumentShareLink"("envelopeId", "email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Field_envelopeId_idx" ON "Field"("envelopeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_envelopeId_idx" ON "Recipient"("envelopeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Recipient_envelopeId_email_key" ON "Recipient"("envelopeId", "email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TemplateDirectLink_envelopeId_key" ON "TemplateDirectLink"("envelopeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Envelope" ADD CONSTRAINT "Envelope_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Envelope" ADD CONSTRAINT "Envelope_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Envelope" ADD CONSTRAINT "Envelope_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Envelope" ADD CONSTRAINT "Envelope_documentMetaId_fkey" FOREIGN KEY ("documentMetaId") REFERENCES "DocumentMeta"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentAuditLog" ADD CONSTRAINT "DocumentAuditLog_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Field" ADD CONSTRAINT "Field_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentShareLink" ADD CONSTRAINT "DocumentShareLink_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TemplateDirectLink" ADD CONSTRAINT "TemplateDirectLink_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `envelopeId` to the `Document` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" ADD COLUMN "envelopeId" TEXT NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+86
-128
@@ -64,8 +64,7 @@ model User {
|
||||
twoFactorBackupCodes String?
|
||||
|
||||
folders Folder[]
|
||||
documents Document[]
|
||||
templates Template[]
|
||||
envelopes Envelope[]
|
||||
|
||||
verificationTokens VerificationToken[]
|
||||
apiTokens ApiToken[]
|
||||
@@ -348,8 +347,7 @@ model Folder {
|
||||
pinned Boolean @default(false)
|
||||
parentId String?
|
||||
parent Folder? @relation("FolderToFolder", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
documents Document[]
|
||||
templates Template[]
|
||||
envelopes Envelope[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
subfolders Folder[] @relation("FolderToFolder")
|
||||
@@ -362,53 +360,79 @@ model Folder {
|
||||
@@index([type])
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';"])
|
||||
model Document {
|
||||
id Int @id @default(autoincrement())
|
||||
qrToken String? /// @zod.string.describe("The token for viewing the document using the QR code on the certificate.")
|
||||
externalId String? /// @zod.string.describe("A custom external ID you can use to identify the document.")
|
||||
enum EnvelopeType {
|
||||
DOCUMENT
|
||||
TEMPLATE
|
||||
}
|
||||
|
||||
model Envelope {
|
||||
id String @id @default(cuid())
|
||||
secondaryId String @unique
|
||||
externalId String? /// @zod.string.describe("A custom external ID you can use to identify the document.")
|
||||
|
||||
type EnvelopeType
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
completedAt DateTime?
|
||||
deletedAt DateTime?
|
||||
|
||||
title String
|
||||
status DocumentStatus @default(DRAFT)
|
||||
source DocumentSource
|
||||
|
||||
useLegacyFieldInsertion Boolean @default(false)
|
||||
|
||||
documents Document[]
|
||||
recipients Recipient[]
|
||||
fields Field[]
|
||||
shareLinks DocumentShareLink[]
|
||||
auditLogs DocumentAuditLog[]
|
||||
|
||||
// Envelope settings
|
||||
authOptions Json? /// [DocumentAuthOptions] @zod.custom.use(ZDocumentAuthOptionsSchema)
|
||||
formValues Json? /// [DocumentFormValues] @zod.custom.use(ZDocumentFormValuesSchema)
|
||||
visibility DocumentVisibility @default(EVERYONE)
|
||||
|
||||
// Template specific fields.
|
||||
publicTitle String @default("")
|
||||
publicDescription String @default("")
|
||||
directLink TemplateDirectLink?
|
||||
templateId Int? // Todo: Migrate from templateId -> This @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
||||
|
||||
// Relations
|
||||
userId Int /// @zod.number.describe("The ID of the user that created this document.")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
teamId Int
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
authOptions Json? /// [DocumentAuthOptions] @zod.custom.use(ZDocumentAuthOptionsSchema)
|
||||
formValues Json? /// [DocumentFormValues] @zod.custom.use(ZDocumentFormValuesSchema)
|
||||
visibility DocumentVisibility @default(EVERYONE)
|
||||
title String
|
||||
status DocumentStatus @default(DRAFT)
|
||||
recipients Recipient[]
|
||||
fields Field[]
|
||||
shareLinks DocumentShareLink[]
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId String?
|
||||
|
||||
documentMetaId String @unique
|
||||
documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id])
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';"])
|
||||
model Document {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
qrToken String? /// @zod.string.describe("The token for viewing the document using the QR code on the certificate.")
|
||||
title String
|
||||
|
||||
documentDataId String
|
||||
documentMeta DocumentMeta?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
completedAt DateTime?
|
||||
deletedAt DateTime?
|
||||
templateId Int?
|
||||
source DocumentSource
|
||||
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
||||
|
||||
useLegacyFieldInsertion Boolean @default(false)
|
||||
|
||||
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
||||
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
|
||||
|
||||
auditLogs DocumentAuditLog[]
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId String?
|
||||
envelopeId String
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([documentDataId])
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([folderId])
|
||||
}
|
||||
|
||||
model DocumentAuditLog {
|
||||
id String @id @default(cuid())
|
||||
documentId Int
|
||||
envelopeId String
|
||||
createdAt DateTime @default(now())
|
||||
type String
|
||||
data Json
|
||||
@@ -420,7 +444,7 @@ model DocumentAuditLog {
|
||||
userAgent String?
|
||||
ipAddress String?
|
||||
|
||||
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum DocumentDataType {
|
||||
@@ -440,7 +464,6 @@ model DocumentData {
|
||||
data String
|
||||
initialData String
|
||||
document Document?
|
||||
template Template?
|
||||
}
|
||||
|
||||
enum DocumentDistributionMethod {
|
||||
@@ -456,8 +479,6 @@ model DocumentMeta {
|
||||
timezone String? @default("Etc/UTC") @db.Text
|
||||
password String?
|
||||
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||
documentId Int @unique
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
redirectUrl String?
|
||||
signingOrder DocumentSigningOrder @default(PARALLEL)
|
||||
allowDictateNextSigner Boolean @default(false)
|
||||
@@ -472,6 +493,9 @@ model DocumentMeta {
|
||||
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
|
||||
emailReplyTo String?
|
||||
emailId String?
|
||||
|
||||
envelopeId String?
|
||||
envelope Envelope?
|
||||
}
|
||||
|
||||
enum ReadStatus {
|
||||
@@ -501,8 +525,7 @@ enum RecipientRole {
|
||||
/// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
|
||||
model Recipient {
|
||||
id Int @id @default(autoincrement())
|
||||
documentId Int?
|
||||
templateId Int?
|
||||
envelopeId String
|
||||
email String @db.VarChar(255)
|
||||
name String @default("") @db.VarChar(255)
|
||||
token String
|
||||
@@ -516,15 +539,12 @@ model Recipient {
|
||||
readStatus ReadStatus @default(NOT_OPENED)
|
||||
signingStatus SigningStatus @default(NOT_SIGNED)
|
||||
sendStatus SendStatus @default(NOT_SENT)
|
||||
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
|
||||
@@unique([documentId, email])
|
||||
@@unique([templateId, email])
|
||||
@@index([documentId])
|
||||
@@index([templateId])
|
||||
@@unique([envelopeId, email])
|
||||
@@index([envelopeId])
|
||||
@@index([token])
|
||||
}
|
||||
|
||||
@@ -546,8 +566,7 @@ enum FieldType {
|
||||
model Field {
|
||||
id Int @id @default(autoincrement())
|
||||
secondaryId String @unique @default(cuid())
|
||||
documentId Int?
|
||||
templateId Int?
|
||||
envelopeId String
|
||||
recipientId Int
|
||||
type FieldType
|
||||
page Int /// @zod.number.describe("The page number of the field on the document. Starts from 1.")
|
||||
@@ -557,14 +576,12 @@ model Field {
|
||||
height Decimal @default(-1)
|
||||
customText String
|
||||
inserted Boolean
|
||||
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
signature Signature?
|
||||
fieldMeta Json? /// [FieldMeta] @zod.custom.use(ZFieldMetaNotOptionalSchema)
|
||||
|
||||
@@index([documentId])
|
||||
@@index([templateId])
|
||||
@@index([envelopeId])
|
||||
@@index([recipientId])
|
||||
}
|
||||
|
||||
@@ -586,13 +603,13 @@ model DocumentShareLink {
|
||||
id Int @id @default(autoincrement())
|
||||
email String
|
||||
slug String @unique
|
||||
documentId Int
|
||||
envelopeId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([documentId, email])
|
||||
@@unique([envelopeId, email])
|
||||
}
|
||||
|
||||
enum OrganisationType {
|
||||
@@ -803,8 +820,7 @@ model Team {
|
||||
|
||||
profile TeamProfile?
|
||||
|
||||
documents Document[]
|
||||
templates Template[]
|
||||
envelopes Envelope[]
|
||||
folders Folder[]
|
||||
apiTokens ApiToken[]
|
||||
webhooks Webhook[]
|
||||
@@ -837,84 +853,26 @@ model TeamEmailVerification {
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum TemplateType {
|
||||
// TODO: USE THIS
|
||||
// TODO: USE THIS
|
||||
// TODO: USE THIS
|
||||
// TODO: USE THIS
|
||||
enum TemplateDirectLinkType {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
||||
model TemplateMeta {
|
||||
id String @id @default(cuid())
|
||||
subject String?
|
||||
message String?
|
||||
timezone String? @default("Etc/UTC") @db.Text
|
||||
password String?
|
||||
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||
signingOrder DocumentSigningOrder? @default(PARALLEL)
|
||||
allowDictateNextSigner Boolean @default(false)
|
||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||
|
||||
typedSignatureEnabled Boolean @default(true)
|
||||
uploadSignatureEnabled Boolean @default(true)
|
||||
drawSignatureEnabled Boolean @default(true)
|
||||
|
||||
templateId Int @unique
|
||||
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
redirectUrl String?
|
||||
language String @default("en")
|
||||
|
||||
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
|
||||
emailReplyTo String?
|
||||
emailId String?
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
|
||||
model Template {
|
||||
id Int @id @default(autoincrement())
|
||||
externalId String?
|
||||
type TemplateType @default(PRIVATE)
|
||||
title String
|
||||
visibility DocumentVisibility @default(EVERYONE)
|
||||
authOptions Json? /// [DocumentAuthOptions] @zod.custom.use(ZDocumentAuthOptionsSchema)
|
||||
templateMeta TemplateMeta?
|
||||
templateDocumentDataId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
publicTitle String @default("")
|
||||
publicDescription String @default("")
|
||||
|
||||
useLegacyFieldInsertion Boolean @default(false)
|
||||
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
teamId Int
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
|
||||
|
||||
recipients Recipient[]
|
||||
fields Field[]
|
||||
directLink TemplateDirectLink?
|
||||
documents Document[]
|
||||
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId String?
|
||||
|
||||
@@unique([templateDocumentDataId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model TemplateDirectLink {
|
||||
id String @id @unique @default(cuid())
|
||||
templateId Int @unique
|
||||
token String @unique
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @unique @default(cuid())
|
||||
envelopeId String @unique
|
||||
token String @unique
|
||||
createdAt DateTime @default(now())
|
||||
enabled Boolean
|
||||
type TemplateDirectLinkType
|
||||
|
||||
directTemplateRecipientId Int
|
||||
|
||||
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model SiteSettings {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Legacy Document schema to confirm backwards API compatibility since
|
||||
* we migrated Documents to Envelopes.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
|
||||
|
||||
import DocumentStatusSchema from '../generated/zod/inputTypeSchemas/DocumentStatusSchema';
|
||||
import DocumentVisibilitySchema from '../generated/zod/inputTypeSchemas/DocumentVisibilitySchema';
|
||||
|
||||
const DocumentSourceSchema = z.enum(['DOCUMENT', 'TEMPLATE', 'TEMPLATE_DIRECT_LINK']);
|
||||
const DocumentTypeSchema = z.enum(['DOCUMENT', 'PUBLIC_TEMPLATE', 'PRIVATE_TEMPLATE']);
|
||||
|
||||
/////////////////////////////////////////
|
||||
// DOCUMENT SCHEMA
|
||||
/////////////////////////////////////////
|
||||
|
||||
export const LegacyDocumentSchema = z.object({
|
||||
type: DocumentTypeSchema,
|
||||
visibility: DocumentVisibilitySchema,
|
||||
status: DocumentStatusSchema,
|
||||
source: DocumentSourceSchema,
|
||||
id: z.number(),
|
||||
qrToken: z
|
||||
.string()
|
||||
.describe('The token for viewing the document using the QR code on the certificate.')
|
||||
.nullable(),
|
||||
externalId: z
|
||||
.string()
|
||||
.describe('A custom external ID you can use to identify the document.')
|
||||
.nullable(),
|
||||
secondaryDocumentId: z.number(),
|
||||
secondaryTemplateId: z.number(),
|
||||
publicTitle: z.string(),
|
||||
publicDescription: z.string(),
|
||||
createdFromDocumentId: z.number().nullable(),
|
||||
userId: z.number().describe('The ID of the user that created this document.'),
|
||||
teamId: z.number(),
|
||||
/**
|
||||
* [DocumentAuthOptions]
|
||||
*/
|
||||
authOptions: ZDocumentAuthOptionsSchema.nullable(),
|
||||
/**
|
||||
* [DocumentFormValues]
|
||||
*/
|
||||
formValues: ZDocumentFormValuesSchema.nullable(),
|
||||
title: z.string(),
|
||||
documentDataId: z.string(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
completedAt: z.coerce.date().nullable(),
|
||||
deletedAt: z.coerce.date().nullable(),
|
||||
useLegacyFieldInsertion: z.boolean(),
|
||||
folderId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type Document = z.infer<typeof LegacyDocumentSchema>;
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Legacy Template schema to confirm backwards API compatibility since
|
||||
* we removed the "Template" prisma schema model.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
|
||||
import { DocumentVisibilitySchema } from '../generated/zod/inputTypeSchemas/DocumentVisibilitySchema';
|
||||
|
||||
export const TemplateTypeSchema = z.enum(['PUBLIC', 'PRIVATE']);
|
||||
|
||||
export type TemplateTypeType = `${z.infer<typeof TemplateTypeSchema>}`;
|
||||
|
||||
export const TemplateSchema = z.object({
|
||||
type: TemplateTypeSchema,
|
||||
visibility: DocumentVisibilitySchema,
|
||||
id: z.number(),
|
||||
externalId: z.string().nullable(),
|
||||
title: z.string(),
|
||||
/**
|
||||
* [DocumentAuthOptions]
|
||||
*/
|
||||
authOptions: ZDocumentAuthOptionsSchema.nullable(),
|
||||
templateDocumentDataId: z.string(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
publicTitle: z.string(),
|
||||
publicDescription: z.string(),
|
||||
useLegacyFieldInsertion: z.boolean(),
|
||||
userId: z.number(),
|
||||
teamId: z.number(),
|
||||
folderId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type Template = z.infer<typeof TemplateSchema>;
|
||||
@@ -137,7 +137,7 @@ export const documentRouter = router({
|
||||
templateId,
|
||||
query,
|
||||
source,
|
||||
status: status ? [status] : undefined,
|
||||
status,
|
||||
page,
|
||||
perPage,
|
||||
folderId,
|
||||
@@ -284,7 +284,6 @@ export const documentRouter = router({
|
||||
globalActionAuth,
|
||||
recipients,
|
||||
meta,
|
||||
folderId,
|
||||
} = input;
|
||||
|
||||
const { remaining } = await getServerLimits({ userId: user.id, teamId });
|
||||
@@ -317,7 +316,6 @@ export const documentRouter = router({
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
recipients,
|
||||
folderId,
|
||||
},
|
||||
meta,
|
||||
requestMetadata: ctx.metadata,
|
||||
|
||||
@@ -142,23 +142,9 @@ export const ZFindDocumentsResponseSchema = ZFindResultResponse.extend({
|
||||
export type TFindDocumentsResponse = z.infer<typeof ZFindDocumentsResponseSchema>;
|
||||
|
||||
export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.extend({
|
||||
period: z
|
||||
.enum([
|
||||
'today',
|
||||
'yesterday',
|
||||
'this-week',
|
||||
'last-week',
|
||||
'this-month',
|
||||
'last-month',
|
||||
'this-quarter',
|
||||
'last-quarter',
|
||||
'this-year',
|
||||
'last-year',
|
||||
'all-time',
|
||||
])
|
||||
.optional(),
|
||||
period: z.enum(['7d', '14d', '30d']).optional(),
|
||||
senderIds: z.array(z.number()).optional(),
|
||||
status: z.array(z.nativeEnum(ExtendedDocumentStatus)).optional(),
|
||||
status: z.nativeEnum(ExtendedDocumentStatus).optional(),
|
||||
folderId: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -223,12 +209,6 @@ export const ZCreateDocumentV2RequestSchema = z.object({
|
||||
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
|
||||
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
|
||||
formValues: ZDocumentFormValuesSchema.optional(),
|
||||
folderId: z
|
||||
.string()
|
||||
.describe(
|
||||
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
|
||||
)
|
||||
.optional(),
|
||||
recipients: z
|
||||
.array(
|
||||
ZCreateRecipientSchema.extend({
|
||||
|
||||
@@ -94,7 +94,7 @@ export const createEmbeddingTemplateRoute = procedure
|
||||
emailSettings: meta.emailSettings,
|
||||
};
|
||||
|
||||
await prisma.templateMeta.upsert({
|
||||
await prisma.documentMeta.upsert({
|
||||
where: {
|
||||
templateId: template.id,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@@ -39,7 +38,7 @@ export const updateOrganisationRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const updatedOrganisation = await prisma.organisation.update({
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
@@ -48,12 +47,4 @@ export const updateOrganisationRoute = authenticatedProcedure
|
||||
url: data.url,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedOrganisation.customerId) {
|
||||
await stripe.customers.update(updatedOrganisation.customerId, {
|
||||
metadata: {
|
||||
organisationName: data.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -339,14 +339,8 @@ export const templateRouter = router({
|
||||
.output(ZCreateDocumentFromTemplateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { teamId } = ctx;
|
||||
const {
|
||||
templateId,
|
||||
recipients,
|
||||
distributeDocument,
|
||||
customDocumentDataId,
|
||||
prefillFields,
|
||||
folderId,
|
||||
} = input;
|
||||
const { templateId, recipients, distributeDocument, customDocumentDataId, prefillFields } =
|
||||
input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
@@ -367,7 +361,6 @@ export const templateRouter = router({
|
||||
recipients,
|
||||
customDocumentDataId,
|
||||
requestMetadata: ctx.metadata,
|
||||
folderId,
|
||||
prefillFields,
|
||||
});
|
||||
|
||||
|
||||
@@ -117,12 +117,6 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
||||
'The data ID of an alternative PDF to use when creating the document. If not provided, the PDF attached to the template will be used.',
|
||||
)
|
||||
.optional(),
|
||||
folderId: z
|
||||
.string()
|
||||
.describe(
|
||||
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
|
||||
)
|
||||
.optional(),
|
||||
prefillFields: z
|
||||
.array(ZFieldMetaPrefillFieldsSchema)
|
||||
.describe(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentData } from '@prisma/client';
|
||||
@@ -46,8 +45,8 @@ export const DocumentDownloadButton = ({
|
||||
setIsLoading(false);
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
title: _('Something went wrong'),
|
||||
description: _('An error occurred while downloading your document.'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, Field, Recipient, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta, Field, Recipient } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { Clock, EyeOffIcon } from 'lucide-react';
|
||||
|
||||
@@ -36,7 +36,7 @@ const getRecipientDisplayText = (recipient: { name: string; email: string }) =>
|
||||
|
||||
export type DocumentReadOnlyFieldsProps = {
|
||||
fields: DocumentField[];
|
||||
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
|
||||
documentMeta?: Pick<DocumentMeta, 'dateFormat'>;
|
||||
|
||||
showFieldStatus?: boolean;
|
||||
|
||||
|
||||
@@ -86,9 +86,7 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<Trans>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
</Trans>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -97,9 +95,7 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<Trans>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
</Trans>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -108,9 +104,7 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<Trans>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
</Trans>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -119,9 +113,7 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<Trans>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
</Trans>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../badge';
|
||||
import { Button } from '../button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '../command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
|
||||
import { Separator } from '../separator';
|
||||
|
||||
interface DataTableFacetedFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>;
|
||||
title?: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
stats?: Record<string, number>;
|
||||
onFilterChange?: (values: string[]) => void;
|
||||
selectedValues?: string[];
|
||||
options: {
|
||||
label: MessageDescriptor;
|
||||
value: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function DataTableFacetedFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
icon: Icon,
|
||||
stats,
|
||||
onFilterChange,
|
||||
selectedValues,
|
||||
options,
|
||||
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||
const { _ } = useLingui();
|
||||
const facets = column?.getFacetedUniqueValues();
|
||||
const selectedValuesSet = new Set(selectedValues || (column?.getFilterValue() as string[]));
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1.5 border-dashed px-2.5">
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{title}
|
||||
{selectedValuesSet.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1 h-8" />
|
||||
<Badge variant="secondary" className="rounded-sm px-2 py-0.5 font-normal lg:hidden">
|
||||
{selectedValuesSet.size}
|
||||
</Badge>
|
||||
<div className="hidden gap-1 lg:flex">
|
||||
{selectedValuesSet.size > 2 ? (
|
||||
<Badge variant="neutral" className="rounded-sm px-2 py-0.5 font-normal">
|
||||
{selectedValuesSet.size} {_(msg`selected`)}
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValuesSet.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="neutral"
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'rounded-sm border-none px-2 py-0.5 font-normal',
|
||||
option.bgColor ? option.bgColor : 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
{_(option.label)}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValuesSet.has(option.value);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
className="gap-x-2"
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
selectedValuesSet.delete(option.value);
|
||||
} else {
|
||||
selectedValuesSet.add(option.value);
|
||||
}
|
||||
const filterValues = Array.from(selectedValuesSet);
|
||||
|
||||
if (onFilterChange) {
|
||||
onFilterChange(filterValues);
|
||||
} else {
|
||||
column?.setFilterValue(filterValues.length ? filterValues : undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-4 items-center justify-center rounded-[4px] border',
|
||||
isSelected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'border-input [&_svg]:invisible',
|
||||
)}
|
||||
>
|
||||
<Check className="text-primary-foreground size-3.5" />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon
|
||||
className={cn(
|
||||
'size-4',
|
||||
option.color ? option.color : 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span>{_(option.label)}</span>
|
||||
{(stats?.[option.value] || facets?.get(option.value)) && (
|
||||
<span className="text-muted-foreground ml-auto flex size-4 items-center justify-center font-mono text-xs">
|
||||
{stats?.[option.value] || facets?.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '../button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="text-muted-foreground flex-1 text-sm">
|
||||
<Trans>
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{' '}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</Trans>
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium">
|
||||
<Trans>Rows per page</Trans>
|
||||
</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 25, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
<Trans>
|
||||
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</Trans>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<Trans>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
</Trans>
|
||||
<ChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<Trans>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
</Trans>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<Trans>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
</Trans>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<Trans>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
</Trans>
|
||||
<ChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
} from '../select';
|
||||
import { Separator } from '../separator';
|
||||
|
||||
interface DataTableSingleFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>;
|
||||
title?: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
onFilterChange?: (values: string[]) => void;
|
||||
selectedValues?: string[];
|
||||
options: {
|
||||
label: MessageDescriptor;
|
||||
value: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
}[];
|
||||
groups?: {
|
||||
label: MessageDescriptor;
|
||||
values: string[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export function DataTableSingleFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
groups,
|
||||
icon: Icon,
|
||||
onFilterChange,
|
||||
selectedValues,
|
||||
}: DataTableSingleFilterProps<TData, TValue>) {
|
||||
const { _ } = useLingui();
|
||||
const filterValue = column?.getFilterValue() as string[] | undefined;
|
||||
const selectedValue = selectedValues?.[0] || (filterValue?.[0] ?? undefined);
|
||||
const selectedOption = options.find((option) => option.value === selectedValue);
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
if (value === selectedValue) {
|
||||
if (onFilterChange) {
|
||||
onFilterChange([]);
|
||||
} else {
|
||||
column?.setFilterValue(undefined);
|
||||
}
|
||||
} else {
|
||||
if (onFilterChange) {
|
||||
onFilterChange([value]);
|
||||
} else {
|
||||
column?.setFilterValue([value]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderOptions = () => {
|
||||
if (groups) {
|
||||
return groups.map((group, groupIndex) => (
|
||||
<React.Fragment key={JSON.stringify(group.label)}>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{_(group.label)}</SelectLabel>
|
||||
{options
|
||||
.filter((option) => group.values.includes(option.value))
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{option.icon && (
|
||||
<option.icon
|
||||
className={cn(
|
||||
'size-4',
|
||||
option.color ? option.color : 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span>{_(option.label)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
{groupIndex < groups.length - 1 && <SelectSeparator />}
|
||||
</React.Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectGroup>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{option.icon && (
|
||||
<option.icon
|
||||
className={cn('size-4', option.color ? option.color : 'text-muted-foreground')}
|
||||
/>
|
||||
)}
|
||||
<span>{_(option.label)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={selectedValue || ''} onValueChange={handleValueChange}>
|
||||
<SelectTrigger className="border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 w-auto gap-1.5 border border-dashed px-2.5 focus:ring-0">
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{title}
|
||||
{selectedValue && selectedOption && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1 h-8" />
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className={cn(
|
||||
'rounded-sm border-none px-2 py-0.5 font-normal',
|
||||
selectedOption.bgColor ? selectedOption.bgColor : 'variant-secondary',
|
||||
)}
|
||||
>
|
||||
{_(selectedOption.label)}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>{renderOptions()}</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import { Calendar, CircleDashedIcon, Globe, ListFilter, X, XCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '../button';
|
||||
import { Input } from '../input';
|
||||
import { DataTableFacetedFilter } from './data-table-faceted-filter';
|
||||
import { DataTableSingleFilter } from './data-table-single-filter';
|
||||
import { sources, statuses, timePeriodGroups, timePeriods } from './data/data';
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
table: Table<TData>;
|
||||
stats?: Record<string, number>;
|
||||
onStatusFilterChange?: (values: string[]) => void;
|
||||
selectedStatusValues?: string[];
|
||||
onTimePeriodFilterChange?: (values: string[]) => void;
|
||||
selectedTimePeriodValues?: string[];
|
||||
onSourceFilterChange?: (values: string[]) => void;
|
||||
selectedSourceValues?: string[];
|
||||
onResetFilters?: () => void;
|
||||
isStatusFiltered?: boolean;
|
||||
isTimePeriodFiltered?: boolean;
|
||||
isSourceFiltered?: boolean;
|
||||
showSourceFilter?: boolean;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
stats,
|
||||
onStatusFilterChange,
|
||||
selectedStatusValues,
|
||||
onTimePeriodFilterChange,
|
||||
selectedTimePeriodValues,
|
||||
onSourceFilterChange,
|
||||
selectedSourceValues,
|
||||
onResetFilters,
|
||||
isStatusFiltered,
|
||||
isTimePeriodFiltered,
|
||||
isSourceFiltered,
|
||||
showSourceFilter = true,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const { _ } = useLingui();
|
||||
const isFiltered =
|
||||
table.getState().columnFilters.length > 0 ||
|
||||
isStatusFiltered ||
|
||||
isTimePeriodFiltered ||
|
||||
isSourceFiltered;
|
||||
const searchValue = (table.getColumn('title')?.getFilterValue() as string) ?? '';
|
||||
|
||||
const handleClearFilter = () => {
|
||||
table.getColumn('title')?.setFilterValue('');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
table.resetColumnFilters();
|
||||
onResetFilters?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="peer h-8 w-[150px] pe-9 ps-9 lg:w-[250px]"
|
||||
placeholder={_(msg`Search documents...`)}
|
||||
value={searchValue}
|
||||
onChange={(event) => table.getColumn('title')?.setFilterValue(event.target.value)}
|
||||
/>
|
||||
<div className="text-muted-foreground/80 pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 peer-disabled:opacity-50">
|
||||
<ListFilter size={16} aria-hidden="true" />
|
||||
</div>
|
||||
{searchValue && (
|
||||
<button
|
||||
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-md outline-none transition-[color,box-shadow] focus:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={_(msg`Clear filter`)}
|
||||
onClick={handleClearFilter}
|
||||
>
|
||||
<XCircle className="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{table.getColumn('status') && (
|
||||
<DataTableFacetedFilter
|
||||
column={table.getColumn('status')}
|
||||
title={_(msg`Status`)}
|
||||
options={statuses}
|
||||
icon={CircleDashedIcon}
|
||||
stats={stats}
|
||||
onFilterChange={onStatusFilterChange}
|
||||
selectedValues={selectedStatusValues}
|
||||
/>
|
||||
)}
|
||||
|
||||
{table.getColumn('createdAt') && (
|
||||
<DataTableSingleFilter
|
||||
column={table.getColumn('createdAt')}
|
||||
title={_(msg`Time Period`)}
|
||||
options={timePeriods}
|
||||
groups={timePeriodGroups}
|
||||
icon={Calendar}
|
||||
onFilterChange={onTimePeriodFilterChange}
|
||||
selectedValues={selectedTimePeriodValues}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSourceFilter && table.getColumn('source') && (
|
||||
<DataTableFacetedFilter
|
||||
column={table.getColumn('source')}
|
||||
title={_(msg`Source`)}
|
||||
options={sources}
|
||||
icon={Globe}
|
||||
onFilterChange={onSourceFilterChange}
|
||||
selectedValues={selectedSourceValues}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isFiltered && (
|
||||
<Button variant="ghost" className="h-8 gap-2" size="sm" onClick={handleReset}>
|
||||
{_(msg`Reset`)}
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
PaginationState,
|
||||
SortingState,
|
||||
Updater,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
import type { DataTableChildren } from '../data-table';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table';
|
||||
import { DataTableToolbar } from './data-table-toolbar';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
perPage?: number;
|
||||
currentPage?: number;
|
||||
totalPages?: number;
|
||||
onPaginationChange?: (_page: number, _perPage: number) => void;
|
||||
children?: DataTableChildren<TData>;
|
||||
stats?: Record<string, number>;
|
||||
onStatusFilterChange?: (values: string[]) => void;
|
||||
selectedStatusValues?: string[];
|
||||
onTimePeriodFilterChange?: (values: string[]) => void;
|
||||
selectedTimePeriodValues?: string[];
|
||||
onSourceFilterChange?: (values: string[]) => void;
|
||||
selectedSourceValues?: string[];
|
||||
onResetFilters?: () => void;
|
||||
isStatusFiltered?: boolean;
|
||||
isTimePeriodFiltered?: boolean;
|
||||
isSourceFiltered?: boolean;
|
||||
showSourceFilter?: boolean;
|
||||
skeleton?: {
|
||||
enable: boolean;
|
||||
rows: number;
|
||||
component?: React.ReactNode;
|
||||
};
|
||||
error?: {
|
||||
enable: boolean;
|
||||
component?: React.ReactNode;
|
||||
};
|
||||
emptyState?: {
|
||||
enable: boolean;
|
||||
component?: React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
error,
|
||||
perPage,
|
||||
currentPage,
|
||||
totalPages,
|
||||
skeleton,
|
||||
onPaginationChange,
|
||||
children,
|
||||
stats,
|
||||
onStatusFilterChange,
|
||||
selectedStatusValues,
|
||||
onTimePeriodFilterChange,
|
||||
selectedTimePeriodValues,
|
||||
onSourceFilterChange,
|
||||
selectedSourceValues,
|
||||
onResetFilters,
|
||||
isStatusFiltered,
|
||||
isTimePeriodFiltered,
|
||||
isSourceFiltered,
|
||||
showSourceFilter,
|
||||
emptyState,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const { _ } = useLingui();
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
|
||||
const pagination = useMemo<PaginationState>(() => {
|
||||
if (currentPage !== undefined && perPage !== undefined) {
|
||||
return {
|
||||
pageIndex: currentPage - 1,
|
||||
pageSize: perPage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pageIndex: 0,
|
||||
pageSize: 0,
|
||||
};
|
||||
}, [currentPage, perPage]);
|
||||
|
||||
const manualPagination = Boolean(currentPage !== undefined && totalPages !== undefined);
|
||||
|
||||
const onTablePaginationChange = (updater: Updater<PaginationState>) => {
|
||||
if (typeof updater === 'function') {
|
||||
const newState = updater(pagination);
|
||||
|
||||
onPaginationChange?.(newState.pageIndex + 1, newState.pageSize);
|
||||
} else {
|
||||
onPaginationChange?.(updater.pageIndex + 1, updater.pageSize);
|
||||
}
|
||||
};
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination: manualPagination ? pagination : undefined,
|
||||
},
|
||||
manualPagination,
|
||||
pageCount: manualPagination ? totalPages : undefined,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
onPaginationChange: onTablePaginationChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
stats={stats}
|
||||
onStatusFilterChange={onStatusFilterChange}
|
||||
selectedStatusValues={selectedStatusValues}
|
||||
onTimePeriodFilterChange={onTimePeriodFilterChange}
|
||||
selectedTimePeriodValues={selectedTimePeriodValues}
|
||||
onSourceFilterChange={onSourceFilterChange}
|
||||
selectedSourceValues={selectedSourceValues}
|
||||
onResetFilters={onResetFilters}
|
||||
isStatusFiltered={isStatusFiltered}
|
||||
isTimePeriodFiltered={isTimePeriodFiltered}
|
||||
isSourceFiltered={isSourceFiltered}
|
||||
showSourceFilter={showSourceFilter}
|
||||
/>
|
||||
{table.getRowModel().rows?.length || error?.enable || skeleton?.enable ? (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : error?.enable ? (
|
||||
<TableRow>
|
||||
{error.component ?? (
|
||||
<TableCell colSpan={columns.length} className="h-32 text-center">
|
||||
<Trans>Something went wrong.</Trans>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
) : skeleton?.enable ? (
|
||||
Array.from({ length: skeleton.rows }).map((_, i) => (
|
||||
<TableRow key={`skeleton-row-${i}`}>
|
||||
{skeleton.component ?? <Skeleton />}
|
||||
</TableRow>
|
||||
))
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : emptyState?.enable ? (
|
||||
(emptyState.component ?? (
|
||||
<div className="flex h-24 items-center justify-center text-center">
|
||||
{_(msg`No results.`)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-24 items-center justify-center text-center">
|
||||
{_(msg`No results.`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children && (table.getRowModel().rows?.length || error?.enable || skeleton?.enable) && (
|
||||
<div className="mt-8 w-full">{children(table)}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { CheckCircle2, Clock, File, FileText, Inbox, Link, XCircle } from 'lucide-react';
|
||||
|
||||
export const statuses = [
|
||||
{
|
||||
value: 'INBOX',
|
||||
label: msg`Inbox`,
|
||||
icon: Inbox,
|
||||
color: 'text-blue-700 dark:text-blue-300',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-100 text-blue-700 dark:text-blue-700',
|
||||
},
|
||||
{
|
||||
value: 'DRAFT',
|
||||
label: msg`Draft`,
|
||||
icon: File,
|
||||
color: 'text-yellow-500 dark:text-yellow-300',
|
||||
bgColor: 'bg-yellow-100 dark:bg-yellow-100 text-yellow-700 dark:text-yellow-700',
|
||||
},
|
||||
{
|
||||
value: 'PENDING',
|
||||
label: msg`Pending`,
|
||||
icon: Clock,
|
||||
color: 'text-blue-700 dark:text-blue-300',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-100 text-blue-700 dark:text-blue-700',
|
||||
},
|
||||
{
|
||||
value: 'COMPLETED',
|
||||
label: msg`Completed`,
|
||||
icon: CheckCircle2,
|
||||
color: 'text-documenso-700 dark:text-documenso-300',
|
||||
bgColor: 'bg-documenso-200 dark:bg-documenso-200 text-documenso-800 dark:text-documenso-800',
|
||||
},
|
||||
{
|
||||
value: 'REJECTED',
|
||||
label: msg`Rejected`,
|
||||
icon: XCircle,
|
||||
color: 'text-red-700 dark:text-red-300',
|
||||
bgColor: 'bg-red-100 dark:bg-red-100 text-red-500 dark:text-red-700',
|
||||
},
|
||||
];
|
||||
|
||||
export const sources = [
|
||||
{
|
||||
value: 'TEMPLATE',
|
||||
label: msg`Template`,
|
||||
icon: FileText,
|
||||
color: 'text-blue-700 dark:text-blue-300',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-100 text-blue-700 dark:text-blue-700',
|
||||
},
|
||||
{
|
||||
value: 'DIRECT_LINK',
|
||||
label: msg`Direct Link`,
|
||||
icon: Link,
|
||||
color: 'text-green-700 dark:text-green-300',
|
||||
bgColor: 'bg-green-100 dark:bg-green-100 text-green-700 dark:text-green-700',
|
||||
},
|
||||
];
|
||||
|
||||
export const timePeriods = [
|
||||
{
|
||||
value: 'today',
|
||||
label: msg`Today`,
|
||||
},
|
||||
{
|
||||
value: 'this-week',
|
||||
label: msg`This Week`,
|
||||
},
|
||||
{
|
||||
value: 'this-month',
|
||||
label: msg`This Month`,
|
||||
},
|
||||
{
|
||||
value: 'this-quarter',
|
||||
label: msg`This Quarter`,
|
||||
},
|
||||
{
|
||||
value: 'this-year',
|
||||
label: msg`This Year`,
|
||||
},
|
||||
{
|
||||
value: 'yesterday',
|
||||
label: msg`Yesterday`,
|
||||
},
|
||||
{
|
||||
value: 'last-week',
|
||||
label: msg`Last Week`,
|
||||
},
|
||||
{
|
||||
value: 'last-month',
|
||||
label: msg`Last Month`,
|
||||
},
|
||||
{
|
||||
value: 'last-quarter',
|
||||
label: msg`Last Quarter`,
|
||||
},
|
||||
{
|
||||
value: 'last-year',
|
||||
label: msg`Last Year`,
|
||||
},
|
||||
{
|
||||
value: 'all-time',
|
||||
label: msg`All Time`,
|
||||
},
|
||||
];
|
||||
|
||||
export const timePeriodGroups = [
|
||||
{
|
||||
label: msg`Present`,
|
||||
values: ['today', 'this-week', 'this-month', 'this-quarter', 'this-year'],
|
||||
},
|
||||
{
|
||||
label: msg`Past`,
|
||||
values: ['yesterday', 'last-week', 'last-month', 'last-quarter', 'last-year'],
|
||||
},
|
||||
{
|
||||
label: msg``,
|
||||
values: ['all-time'],
|
||||
},
|
||||
];
|
||||
@@ -1,116 +0,0 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
export const timePeriods = [
|
||||
'today',
|
||||
'this-week',
|
||||
'this-month',
|
||||
'this-quarter',
|
||||
'this-year',
|
||||
'yesterday',
|
||||
'last-week',
|
||||
'last-month',
|
||||
'last-quarter',
|
||||
'last-year',
|
||||
'all-time',
|
||||
] as const;
|
||||
|
||||
export type TimePeriod = (typeof timePeriods)[number];
|
||||
|
||||
export function getDateRangeForPeriod(
|
||||
period: TimePeriod,
|
||||
): { start: DateTime; end: DateTime } | null {
|
||||
const now = DateTime.now();
|
||||
|
||||
switch (period) {
|
||||
case 'today':
|
||||
return {
|
||||
start: now.startOf('day'),
|
||||
end: now.endOf('day'),
|
||||
};
|
||||
|
||||
case 'yesterday': {
|
||||
const yesterday = now.minus({ days: 1 });
|
||||
return {
|
||||
start: yesterday.startOf('day'),
|
||||
end: yesterday.endOf('day'),
|
||||
};
|
||||
}
|
||||
|
||||
case 'this-week':
|
||||
return {
|
||||
start: now.startOf('week'),
|
||||
end: now.endOf('week'),
|
||||
};
|
||||
|
||||
case 'last-week': {
|
||||
const lastWeek = now.minus({ weeks: 1 });
|
||||
return {
|
||||
start: lastWeek.startOf('week'),
|
||||
end: lastWeek.endOf('week'),
|
||||
};
|
||||
}
|
||||
|
||||
case 'this-month':
|
||||
return {
|
||||
start: now.startOf('month'),
|
||||
end: now.endOf('month'),
|
||||
};
|
||||
|
||||
case 'last-month': {
|
||||
const lastMonth = now.minus({ months: 1 });
|
||||
return {
|
||||
start: lastMonth.startOf('month'),
|
||||
end: lastMonth.endOf('month'),
|
||||
};
|
||||
}
|
||||
|
||||
case 'this-quarter':
|
||||
return {
|
||||
start: now.startOf('quarter'),
|
||||
end: now.endOf('quarter'),
|
||||
};
|
||||
|
||||
case 'last-quarter': {
|
||||
const lastQuarter = now.minus({ quarters: 1 });
|
||||
return {
|
||||
start: lastQuarter.startOf('quarter'),
|
||||
end: lastQuarter.endOf('quarter'),
|
||||
};
|
||||
}
|
||||
|
||||
case 'this-year':
|
||||
return {
|
||||
start: now.startOf('year'),
|
||||
end: now.endOf('year'),
|
||||
};
|
||||
|
||||
case 'last-year': {
|
||||
const lastYear = now.minus({ years: 1 });
|
||||
return {
|
||||
start: lastYear.startOf('year'),
|
||||
end: lastYear.endOf('year'),
|
||||
};
|
||||
}
|
||||
|
||||
case 'all-time':
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isDateInPeriod(date: Date, period: TimePeriod): boolean {
|
||||
if (period === 'all-time') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dateTime = DateTime.fromJSDate(date);
|
||||
const range = getDateRangeForPeriod(period);
|
||||
|
||||
if (!range) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return dateTime >= range.start && dateTime <= range.end;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { DocumentMeta, Signature, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta, Signature } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
@@ -27,7 +27,7 @@ type FieldIconProps = {
|
||||
fieldMeta?: TFieldMetaSchema | null;
|
||||
signature?: Signature | null;
|
||||
};
|
||||
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
|
||||
documentMeta?: Pick<DocumentMeta, 'dateFormat'>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user