Compare commits

..

23 Commits

Author SHA1 Message Date
cb880fbd50 chore: fix conflicts 2025-08-22 16:17:59 +00:00
a8b8721b22 chore: fix conflicts 2025-08-22 15:34:30 +00:00
67501b45cf feat: create document in a specific folder (#1965) 2025-08-23 00:12:17 +10:00
17b36ac8e4 feat: sync organization name with stripe (#1974) 2025-08-22 23:28:04 +10:00
80e452afa2 fix: get accurate pdf page size (#1980)
Handles edge cases with PDF media boxes and crop boxes, deals with
certain documents that had been uploaded with weird combos of sizings.
2025-08-22 22:50:41 +10:00
1cb9de8083 chore: remove 'use client' directives (#1979) 2025-08-22 02:20:41 +00:00
231ef9c27e chore: add support option (#1853) 2025-08-19 20:59:03 +10:00
6f35342a83 feat: reset user 2fa from admin panel (#1943) 2025-08-19 13:09:05 +10:00
3b2cb681fd chore: refactor 2025-07-25 10:36:50 +00:00
582fe91b14 fix: translation patterns 2025-07-24 16:42:24 +00:00
87e0ea2ee3 fix: build errors 2025-07-24 16:16:29 +00:00
ef3885d407 Merge branch 'main' into feat/document-table-filters 2025-07-23 14:41:41 +10:00
1b39799fc3 Merge branch 'main' into feat/document-table-filters 2025-07-10 09:09:28 +00:00
0f3c9dafa8 fix: cannot find source column 2025-06-19 15:35:31 +00:00
8484783ec5 fix: merge conflicts 2025-06-19 15:16:16 +00:00
5545fb36e8 Merge branch 'main' into feat/document-table-filters 2025-06-05 12:58:48 +00:00
c5bc3a32f8 chore: clean up unused imports and improve accessibility in pagination 2025-06-05 12:55:14 +00:00
64695fad32 chore: add translations 2025-06-05 12:31:53 +00:00
de45a63c97 chore: minor changes 2025-06-05 12:07:19 +00:00
2c064d5aff chore: minor changes 2025-06-05 11:41:26 +00:00
9739a0ca96 feat: use data-table on template pages 2025-06-05 10:53:53 +00:00
9ccd8e0397 fix: table empty state and use the table somewhere else 2025-06-05 07:42:39 +00:00
f5365554ab feat: rework document table filters 2025-05-30 18:17:50 +00:00
62 changed files with 2358 additions and 825 deletions

View File

@ -136,3 +136,5 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# OPTIONAL: The file to save the logger output to. Will disable stdout if provided.
NEXT_PRIVATE_LOGGER_FILE_PATH=
# [[PLAIN SUPPORT]]
NEXT_PRIVATE_PLAIN_API_KEY=

View File

@ -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 Next.js 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 Remix start command
For local docker run

View File

@ -0,0 +1,159 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminUserResetTwoFactorDialogProps = {
className?: string;
user: User;
};
export const AdminUserResetTwoFactorDialog = ({
className,
user,
}: AdminUserResetTwoFactorDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const [email, setEmail] = useState('');
const [open, setOpen] = useState(false);
const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } =
trpc.admin.user.resetTwoFactor.useMutation();
const onResetTwoFactor = async () => {
try {
await resetTwoFactor({
userId: user.id,
});
toast({
title: _(msg`2FA Reset`),
description: _(msg`The user's two factor authentication has been reset successfully.`),
duration: 5000,
});
await revalidate();
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
.with(
AppErrorCode.UNAUTHORIZED,
() => msg`You are not authorized to reset two factor authentcation for this user.`,
)
.otherwise(
() => msg`An error occurred while resetting two factor authentication for the user.`,
);
toast({
title: _(msg`Error`),
description: _(errorMessage),
variant: 'destructive',
duration: 7500,
});
}
};
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (!newOpen) {
setEmail('');
}
};
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral"
>
<div>
<AlertTitle>Reset Two Factor Authentication</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Reset the users two factor authentication. This action is irreversible and will
disable two factor authentication for the user.
</Trans>
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="destructive">
<Trans>Reset 2FA</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Reset Two Factor Authentication</Trans>
</DialogTitle>
</DialogHeader>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>
This action is irreversible. Please ensure you have informed the user before
proceeding.
</Trans>
</AlertDescription>
</Alert>
<div>
<DialogDescription>
<Trans>
To confirm, please enter the accounts email address <br />({user.email}).
</Trans>
</DialogDescription>
<Input
className="mt-2"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<DialogFooter>
<Button
variant="destructive"
disabled={email !== user.email}
onClick={onResetTwoFactor}
loading={isResettingTwoFactor}
>
<Trans>Reset 2FA</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Alert>
</div>
);
};

View File

@ -0,0 +1,138 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZSupportTicketSchema = z.object({
subject: z.string().min(3, 'Subject is required'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
type TSupportTicket = z.infer<typeof ZSupportTicketSchema>;
export type SupportTicketFormProps = {
organisationId: string;
teamId?: string | null;
onSuccess?: () => void;
onClose?: () => void;
};
export const SupportTicketForm = ({
organisationId,
teamId,
onSuccess,
onClose,
}: SupportTicketFormProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { mutateAsync: submitSupportTicket, isPending } =
trpc.profile.submitSupportTicket.useMutation();
const form = useForm<TSupportTicket>({
resolver: zodResolver(ZSupportTicketSchema),
defaultValues: {
subject: '',
message: '',
},
});
const isLoading = form.formState.isLoading || isPending;
const onSubmit = async (data: TSupportTicket) => {
const { subject, message } = data;
try {
await submitSupportTicket({
subject,
message,
organisationId,
teamId,
});
toast({
title: t`Support ticket created`,
description: t`Your support request has been submitted. We'll get back to you soon!`,
});
if (onSuccess) {
onSuccess();
}
form.reset();
} catch (err) {
toast({
title: t`Failed to create support ticket`,
description: t`An error occurred. Please try again later.`,
variant: 'destructive',
});
}
};
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={isLoading} className="flex flex-col gap-4">
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Subject</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Message</Trans>
</FormLabel>
<FormControl>
<Textarea rows={5} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-2 flex flex-row gap-2">
<Button type="submit" size="sm" loading={isLoading}>
<Trans>Submit</Trans>
</Button>
{onClose && (
<Button variant="outline" size="sm" type="button" onClick={onClose}>
<Trans>Close</Trans>
</Button>
)}
</div>
</fieldset>
</form>
</Form>
</>
);
};

View File

@ -1,5 +1,3 @@
'use client';
import { DateTime } from 'luxon';
import type { TooltipProps } from 'recharts';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';

View File

@ -36,7 +36,6 @@ export type DocumentSigningSignatureFieldProps = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
keyboardSignatureEnabled?: boolean;
};
export const DocumentSigningSignatureField = ({
@ -46,7 +45,6 @@ export const DocumentSigningSignatureField = ({
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
keyboardSignatureEnabled,
}: DocumentSigningSignatureFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -285,7 +283,6 @@ export const DocumentSigningSignatureField = ({
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled}
drawSignatureEnabled={drawSignatureEnabled}
keyboardSignatureEnabled={keyboardSignatureEnabled}
/>
<DocumentSigningDisclosure />

View File

@ -321,6 +321,19 @@ export const OrgMenuSwitcher = () => {
<Trans>Language</Trans>
</DropdownMenuItem>
{currentOrganisation && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link
to={{
pathname: `/o/${currentOrganisation.url}/support`,
search: currentTeam ? `?team=${currentTeam.id}` : '',
}}
>
<Trans>Support</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-muted-foreground hover:!text-muted-foreground px-4 py-2"
onSelect={async () => authClient.signOut()}

View File

@ -1,70 +0,0 @@
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>
);
};

View File

@ -1,80 +1,136 @@
import { useMemo } from 'react';
import { useMemo, useTransition } 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, DocumentStatus as DocumentStatusEnum } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { DocumentSource } from '@prisma/client';
import { InfoIcon, Loader } 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 { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
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 { SelectItem } from '@documenso/ui/primitives/select';
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 { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
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 { DocumentStatus as DocumentStatusComponent } 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 { _, i18n } = useLingui();
const { _ } = useLingui();
const [searchParams] = useSearchParams();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
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 { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery(
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(
{
templateId,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
query: parsedSearchParams.query,
source: parsedSearchParams.source,
status: parsedSearchParams.status,
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,
},
{
placeholderData: (previousData) => previousData,
@ -82,9 +138,11 @@ export const TemplatePageViewDocumentsTable = ({
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
@ -95,6 +153,13 @@ export const TemplatePageViewDocumentsTable = ({
totalPages: 1,
};
const getEmptyStateStatus = (): ExtendedDocumentStatus => {
if (selectedStatusValues.length > 0) {
return selectedStatusValues[0] as ExtendedDocumentStatus;
}
return ExtendedDocumentStatus.ALL;
};
const columns = useMemo(() => {
return [
{
@ -102,12 +167,21 @@ 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',
@ -121,8 +195,14 @@ export const TemplatePageViewDocumentsTable = ({
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
cell: ({ row }) => <DocumentStatusComponent 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: () => (
@ -161,79 +241,51 @@ export const TemplatePageViewDocumentsTable = ({
</Tooltip>
</div>
),
accessorKey: 'type',
accessorKey: 'source',
cell: ({ row }) => (
<div className="flex flex-row items-center">
{_(DOCUMENT_SOURCE_LABELS[row.original.source])}
{_(DOCUMENT_SOURCE_LABELS[row.original.source as DocumentSource])}
</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 space-x-2">
<div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} />
</div>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
] satisfies DataTableColumnDef<DocumentsTableRow>[];
}, [_, team?.url]);
return (
<div>
<div className="mb-4 flex flex-row space-x-4">
<DocumentSearch />
<SearchParamSelector
paramKey="status"
isValueValid={(value) =>
[...DocumentStatusEnum.COMPLETED].includes(value as unknown as string)
}
>
<SelectItem value="all">
<Trans>Any Status</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.COMPLETED}>
<Trans>Completed</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.PENDING}>
<Trans>Pending</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.DRAFT}>
<Trans>Draft</Trans>
</SelectItem>
</SearchParamSelector>
<SearchParamSelector
paramKey="source"
isValueValid={(value) =>
[...DocumentSource.TEMPLATE].includes(value as unknown as string)
}
>
<SelectItem value="all">
<Trans>Any Source</Trans>
</SelectItem>
<SelectItem value={DocumentSource.TEMPLATE}>
<Trans>Template</Trans>
</SelectItem>
<SelectItem value={DocumentSource.TEMPLATE_DIRECT_LINK}>
<Trans>Direct Link</Trans>
</SelectItem>
</SearchParamSelector>
<PeriodSelector />
</div>
<div className="relative">
<DataTable
columns={columns}
data={results.data}
columns={columns}
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,
}}
@ -265,9 +317,19 @@ 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>
);
};

View File

@ -1,6 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2 } from 'lucide-react';
import { Bird, CheckCircle2, XCircle } from 'lucide-react';
import { match } from 'ts-pattern';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@ -25,6 +25,21 @@ 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.`,
@ -38,7 +53,7 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
return (
<div
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
className="text-muted-foreground/60 mt-12 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} />

View File

@ -1,51 +1,100 @@
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 } from 'react-router';
import { Link, useSearchParams } 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 type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
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,
} 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 DocumentsTableProps = {
data?: TFindDocumentsResponse;
export type DataTableProps = {
data?: TFindDocumentsInternalResponse;
isLoading?: boolean;
isLoadingError?: boolean;
onMoveDocument?: (documentId: number) => void;
};
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
type DocumentsTableRow = TFindDocumentsInternalResponse['data'][number];
export const DocumentsTable = ({
export function DocumentsDataTable({
data,
isLoading,
isLoadingError,
onMoveDocument,
}: DocumentsTableProps) => {
const { _, i18n } = useLingui();
}: DataTableProps) {
const { _ } = useLingui();
const team = useCurrentTeam();
const [isPending, startTransition] = useTransition();
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 [
@ -54,9 +103,19 @@ export const DocumentsTable = ({
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} />,
},
{
@ -79,6 +138,12 @@ export const DocumentsTable = ({
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`),
@ -112,18 +177,34 @@ export const DocumentsTable = ({
totalPages: 1,
};
const getEmptyStateStatus = (): ExtendedDocumentStatus => {
if (selectedStatusValues.length > 0) {
return selectedStatusValues[0] as ExtendedDocumentStatus;
}
return ExtendedDocumentStatus.ALL;
};
return (
<div className="relative">
<DataTable
columns={columns}
data={results.data}
columns={columns}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
columnVisibility={{
sender: team !== undefined,
}}
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}
error={{
enable: isLoadingError || false,
}}
@ -152,6 +233,10 @@ export const DocumentsTable = ({
</>
),
}}
emptyState={{
enable: !isLoading && !isLoadingError,
component: <DocumentsTableEmptyState status={getEmptyStateStatus()} />,
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
@ -163,14 +248,14 @@ export const DocumentsTable = ({
)}
</div>
);
};
}
type DataTableTitleProps = {
row: DocumentsTableRow;
teamUrl: string;
};
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const { user } = useSession();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);

View File

@ -0,0 +1,70 @@
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>
);
};

View File

@ -27,6 +27,7 @@ import { AdminOrganisationCreateDialog } from '~/components/dialogs/admin-organi
import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog';
import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog';
import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-dialog';
import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
@ -219,10 +220,11 @@ const AdminUserPage = ({ user }: { user: User }) => {
/>
</div>
<div className="mt-16 flex flex-col items-center gap-4">
{user && <AdminUserDeleteDialog user={user} />}
<div className="mt-16 flex flex-col gap-4">
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
{user && <AdminUserDeleteDialog user={user} />}
</div>
</div>
);

View File

@ -0,0 +1,125 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { BookIcon, HelpCircleIcon, Link2Icon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { Button } from '@documenso/ui/primitives/button';
import { SupportTicketForm } from '~/components/forms/support-ticket-form';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Support');
}
export default function SupportPage() {
const [showForm, setShowForm] = useState(false);
const { user } = useSession();
const organisation = useCurrentOrganisation();
const [searchParams] = useSearchParams();
const teamId = searchParams.get('team');
const subscriptionStatus = organisation.subscription?.status;
const handleSuccess = () => {
setShowForm(false);
};
const handleCloseForm = () => {
setShowForm(false);
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="mb-8">
<h1 className="flex flex-row items-center gap-2 text-3xl font-bold">
<HelpCircleIcon className="text-muted-foreground h-8 w-8" />
<Trans>Support</Trans>
</h1>
<p className="text-muted-foreground mt-2">
<Trans>Your current plan includes the following support channels:</Trans>
</p>
<div className="mt-6 flex flex-col gap-4">
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<BookIcon className="text-muted-foreground h-5 w-5" />
<Link
to="https://docs.documenso.com"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<Trans>Documentation</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>Read our documentation to get started with Documenso.</Trans>
</p>
</div>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Link
to="https://documen.so/discord"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<Trans>Discord</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>
Join our community on{' '}
<Link
to="https://documen.so/discord"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
Discord
</Link>{' '}
for community support and discussion.
</Trans>
</p>
</div>
{organisation && IS_BILLING_ENABLED() && subscriptionStatus && (
<>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Trans>Contact us</Trans>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>We'll get back to you as soon as possible via email.</Trans>
</p>
<div className="mt-4">
{!showForm ? (
<Button variant="outline" size="sm" onClick={() => setShowForm(true)}>
<Trans>Create a support ticket</Trans>
</Button>
) : (
<SupportTicketForm
organisationId={organisation.id}
teamId={teamId}
onSuccess={handleSuccess}
onClose={handleCloseForm}
/>
)}
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -1,33 +1,21 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { FolderType, OrganisationType } from '@prisma/client';
import { FolderType } 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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import {
type TFindDocumentsInternalResponse,
ZFindDocumentsInternalRequestSchema,
} from '@documenso/trpc/server/document-router/schema';
import { 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 { 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 { DocumentsDataTable } from '~/components/tables/documents-table';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@ -36,17 +24,26 @@ 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();
@ -55,15 +52,6 @@ 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],
@ -74,42 +62,6 @@ 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">
@ -128,72 +80,18 @@ 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">
<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>
<DocumentsDataTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
onMoveDocument={(documentId) => {
setDocumentToMove(documentId);
setIsMovingDocument(true);
}}
/>
</div>
{documentToMove && (

View File

@ -1,28 +1,28 @@
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 team = useCurrentTeam();
const { folderId } = useParams();
const [searchParams] = useSearchParams();
const { folderId } = useParams();
const team = useCurrentTeam();
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;

View File

@ -1,5 +1,6 @@
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';
@ -90,19 +91,25 @@ 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">{_(msg`Document ID`)}</span>
<span className="font-medium">
<Trans>Document ID</Trans>
</span>
<span className="mt-1 block break-words">{document.id}</span>
</p>
<p>
<span className="font-medium">{_(msg`Enclosed Document`)}</span>
<span className="font-medium">
<Trans>Enclosed Document</Trans>
</span>
<span className="mt-1 block break-words">{document.title}</span>
</p>
<p>
<span className="font-medium">{_(msg`Status`)}</span>
<span className="font-medium">
<Trans>Status</Trans>
</span>
<span className="mt-1 block">
{_(
@ -112,7 +119,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
</p>
<p>
<span className="font-medium">{_(msg`Owner`)}</span>
<span className="font-medium">
<Trans>Owner</Trans>
</span>
<span className="mt-1 block break-words">
{document.user.name} ({document.user.email})
@ -120,7 +129,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
</p>
<p>
<span className="font-medium">{_(msg`Created At`)}</span>
<span className="font-medium">
<Trans>Created At</Trans>
</span>
<span className="mt-1 block">
{DateTime.fromJSDate(document.createdAt)
@ -130,7 +141,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
</p>
<p>
<span className="font-medium">{_(msg`Last Updated`)}</span>
<span className="font-medium">
<Trans>Last Updated</Trans>
</span>
<span className="mt-1 block">
{DateTime.fromJSDate(document.updatedAt)
@ -140,7 +153,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
</p>
<p>
<span className="font-medium">{_(msg`Time Zone`)}</span>
<span className="font-medium">
<Trans>Time Zone</Trans>
</span>
<span className="mt-1 block break-words">
{document.documentMeta?.timezone ?? 'N/A'}
@ -148,7 +163,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
</p>
<div>
<p className="font-medium">{_(msg`Recipients`)}</p>
<p className="font-medium">
<Trans>Recipients</Trans>
</p>
<ul className="mt-1 list-inside list-disc">
{document.recipients.map((recipient) => (

View File

@ -1,5 +1,6 @@
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';
@ -199,7 +200,9 @@ 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">{_(msg`Signing Certificate`)}</h1>
<h1 className="my-8 text-2xl font-bold">
<Trans>Signing Certificate</Trans>
</h1>
</div>
<Card>
@ -207,9 +210,15 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>{_(msg`Signer Events`)}</TableHead>
<TableHead>{_(msg`Signature`)}</TableHead>
<TableHead>{_(msg`Details`)}</TableHead>
<TableHead>
<Trans>Signer Events</Trans>
</TableHead>
<TableHead>
<Trans>Signature</Trans>
</TableHead>
<TableHead>
<Trans>Details</Trans>
</TableHead>
{/* <TableHead>Security</TableHead> */}
</TableRow>
</TableHeader>
@ -229,7 +238,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">{_(msg`Authentication Level`)}:</span>{' '}
<span className="font-medium">
<Trans>Authentication Level</Trans>:
</span>{' '}
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
</p>
</TableCell>
@ -259,7 +270,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">{_(msg`Signature ID`)}:</span>{' '}
<span className="font-medium">
<Trans>Signature ID</Trans>:
</span>{' '}
<span className="block font-mono uppercase">
{signature.secondaryId}
</span>
@ -270,14 +283,18 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
)}
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
<span className="font-medium">
<Trans>IP Address</Trans>:
</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">{_(msg`Device`)}:</span>{' '}
<span className="font-medium">
<Trans>Device</Trans>:
</span>{' '}
<span className="inline-block">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
</span>
@ -287,7 +304,9 @@ 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">{_(msg`Sent`)}:</span>{' '}
<span className="font-medium">
<Trans>Sent</Trans>:
</span>{' '}
<span className="inline-block">
{logs.EMAIL_SENT[0]
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
@ -298,7 +317,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Viewed`)}:</span>{' '}
<span className="font-medium">
<Trans>Viewed</Trans>:
</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_OPENED[0]
? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt)
@ -310,7 +331,9 @@ 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">{_(msg`Rejected`)}:</span>{' '}
<span className="font-medium">
<Trans>Rejected</Trans>:
</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_REJECTED[0]
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt)
@ -321,7 +344,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</p>
) : (
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
<span className="font-medium">
<Trans>Signed</Trans>:
</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
? DateTime.fromJSDate(
@ -335,7 +360,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
)}
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
<span className="font-medium">
<Trans>Reason</Trans>:
</span>{' '}
<span className="inline-block">
{recipient.signingStatus === SigningStatus.REJECTED
? recipient.rejectionReason
@ -371,7 +398,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">
{_(msg`Signing certificate provided by`)}:
<Trans>Signing certificate provided by</Trans>:
</p>
<BrandingLogo className="max-h-6 print:max-h-4" />
</div>

View File

@ -1,5 +1,6 @@
import { useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router';
@ -51,8 +52,8 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
if (!configuration || !configuration.documentData) {
toast({
variant: 'destructive',
title: _('Error'),
description: _('Please configure the document first'),
title: _(msg`Error`),
description: _(msg`Please configure the document first`),
});
return;
@ -103,8 +104,8 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
});
toast({
title: _('Success'),
description: _('Document created successfully'),
title: _(msg`Success`),
description: _(msg`Document created successfully`),
});
// Send a message to the parent window with the document details
@ -130,8 +131,8 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
toast({
variant: 'destructive',
title: _('Error'),
description: _('Failed to create document'),
title: _(msg`Error`),
description: _(msg`Failed to create document`),
});
}
};

View File

@ -1,5 +1,6 @@
import { useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router';
@ -49,8 +50,8 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
if (!configuration || !configuration.documentData) {
toast({
variant: 'destructive',
title: _('Error'),
description: _('Please configure the template first'),
title: _(msg`Error`),
description: _(msg`Please configure the template first`),
});
return;
@ -93,8 +94,8 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
});
toast({
title: _('Success'),
description: _('Template created successfully'),
title: _(msg`Success`),
description: _(msg`Template created successfully`),
});
// Send a message to the parent window with the template details
@ -120,8 +121,8 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
toast({
variant: 'destructive',
title: _('Error'),
description: _('Failed to create template'),
title: _(msg`Error`),
description: _(msg`Failed to create template`),
});
}
};

54
package-lock.json generated
View File

@ -3522,6 +3522,15 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@graphql-typed-document-node/core": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
"license": "MIT",
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.3.tgz",
@ -11826,6 +11835,20 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@team-plain/typescript-sdk": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/@team-plain/typescript-sdk/-/typescript-sdk-5.9.0.tgz",
"integrity": "sha512-AHSXyt1kDt74m9YKZBCRCd6cQjB8QjUNr9cehtR2QHzZ/8yXJPzawPJDqOQ3ms5KvwuYrBx2qT3e6C/zrQ5UtA==",
"license": "MIT",
"dependencies": {
"@graphql-typed-document-node/core": "^3.2.0",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"graphql": "^16.6.0",
"lodash.get": "^4.4.2",
"zod": "3.22.4"
}
},
"node_modules/@theguild/remark-mermaid": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@theguild/remark-mermaid/-/remark-mermaid-0.0.5.tgz",
@ -13235,7 +13258,6 @@
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@ -13248,6 +13270,23 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@ -18771,7 +18810,6 @@
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true,
"funding": [
{
"type": "github",
@ -19847,6 +19885,15 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"license": "MIT"
},
"node_modules/graphql": {
"version": "16.11.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
"integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==",
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
@ -22329,7 +22376,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
@ -30570,7 +30616,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -36583,6 +36628,7 @@
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",

View File

@ -330,6 +330,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
userId: user.id,
teamId: team?.id,
formValues: body.formValues,
folderId: body.folderId,
documentDataId: documentData.id,
requestMetadata: metadata,
});
@ -736,6 +737,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
teamId: team?.id,
recipients: body.recipients,
prefillFields: body.prefillFields,
folderId: body.folderId,
override: {
title: body.title,
...body.meta,

View File

@ -136,6 +136,12 @@ 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),
@ -287,6 +293,12 @@ 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({

View File

@ -69,11 +69,4 @@ export const DOCUMENT_SIGNATURE_TYPES = {
}),
value: DocumentSignatureType.UPLOAD,
},
[DocumentSignatureType.KEYBOARD]: {
label: msg({
message: `Keyboard`,
context: `Keyboard signatute type`,
}),
value: DocumentSignatureType.KEYBOARD,
},
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;

View File

@ -33,6 +33,7 @@
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",

View File

@ -0,0 +1,7 @@
import { PlainClient } from '@team-plain/typescript-sdk';
import { env } from '@documenso/lib/utils/env';
export const plainClient = new PlainClient({
apiKey: env('NEXT_PRIVATE_PLAIN_API_KEY') ?? '',
});

View File

@ -1,6 +1,7 @@
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
import {
DocumentSource,
FolderType,
RecipientRole,
SendStatus,
SigningStatus,
@ -45,6 +46,7 @@ export type CreateDocumentOptions = {
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
folderId?: string;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
@ -59,7 +61,7 @@ export const createDocumentV2 = async ({
meta,
requestMetadata,
}: CreateDocumentOptions) => {
const { title, formValues } = data;
const { title, formValues, folderId } = data;
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
@ -78,6 +80,22 @@ 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,
@ -164,6 +182,7 @@ export const createDocumentV2 = async ({
teamId,
authOptions,
visibility,
folderId,
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {

View File

@ -5,27 +5,26 @@ 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?: PeriodSelectorValue;
period?: TimePeriod;
senderIds?: number[];
query?: string;
folderId?: string;
@ -36,7 +35,7 @@ export const findDocuments = async ({
teamId,
templateId,
source,
status = ExtendedDocumentStatus.ALL,
status = [ExtendedDocumentStatus.ALL],
page = 1,
perPage = 10,
orderBy,
@ -106,10 +105,30 @@ export const findDocuments = async ({
},
];
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user, folderId);
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 };
}
}
if (team) {
filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId);
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 };
}
}
}
if (filters === null) {
@ -197,13 +216,73 @@ export const findDocuments = async ({
AND: whereAndClause,
};
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
if (period && period !== 'all-time') {
const now = DateTime.now();
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
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 }),
}));
whereClause.createdAt = {
gte: startOfPeriod.toJSDate(),
gte: startDate.toJSDate(),
lt: endDate.toJSDate(),
};
}

View File

@ -1,19 +1,17 @@
import { TeamMemberRole } from '@prisma/client';
import type { Prisma, User } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DateTime } from 'luxon';
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@prisma/client';
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?: PeriodSelectorValue;
period?: TimePeriod;
search?: string;
folderId?: string;
};
@ -27,14 +25,15 @@ export const getStats = async ({
}: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
if (period && period !== 'all-time') {
const dateRange = getDateRangeForPeriod(period);
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
createdAt = {
gte: startOfPeriod.toJSDate(),
};
if (dateRange) {
createdAt = {
gte: dateRange.start.toJSDate(),
lte: dateRange.end.toJSDate(),
};
}
}
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team

View File

@ -3,6 +3,7 @@ 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.
@ -27,7 +28,7 @@ export async function addRejectionStampToPdf(
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const { width, height } = page.getSize();
const { width, height } = getPageSize(page);
// Draw the "REJECTED" text
const rejectedTitleText = 'DOCUMENT REJECTED';

View File

@ -0,0 +1,18 @@
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;
};

View File

@ -33,6 +33,7 @@ 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([
@ -77,7 +78,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
// 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.

View File

@ -26,6 +26,7 @@ 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([
@ -63,7 +64,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
// 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.

View File

@ -2,6 +2,7 @@ import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/c
import {
DocumentSource,
type Field,
FolderType,
type Recipient,
RecipientRole,
SendStatus,
@ -69,6 +70,7 @@ export type CreateDocumentFromTemplateOptions = {
email: string;
signingOrder?: number | null;
}[];
folderId?: string;
prefillFields?: TFieldMetaPrefillFieldsSchema[];
customDocumentDataId?: string;
@ -274,6 +276,7 @@ export const createDocumentFromTemplate = async ({
customDocumentDataId,
override,
requestMetadata,
folderId,
prefillFields,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
@ -298,6 +301,22 @@ 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,
@ -368,6 +387,7 @@ export const createDocumentFromTemplate = async ({
externalId: externalId || template.externalId,
templateId: template.id,
userId,
folderId,
teamId: template.teamId,
title: override?.title || template.title,
documentDataId: documentData.id,

View File

@ -0,0 +1,72 @@
import { plainClient } from '@documenso/lib/plain/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { getTeamById } from '../team/get-team';
type SubmitSupportTicketOptions = {
subject: string;
message: string;
userId: number;
organisationId: string;
teamId?: number | null;
};
export const submitSupportTicket = async ({
subject,
message,
userId,
organisationId,
teamId,
}: SubmitSupportTicketOptions) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
const team = teamId
? await getTeamById({
userId,
teamId,
})
: null;
const customMessage = `
Organisation: ${organisation.name} (${organisation.id})
Team: ${team ? `${team.name} (${team.id})` : 'No team provided'}
${message}`;
const res = await plainClient.createThread({
title: subject,
customerIdentifier: { emailAddress: user.email },
components: [{ componentText: { text: customMessage } }],
});
if (res.error) {
throw new Error(res.error.message);
}
return res;
};

View File

@ -18,7 +18,6 @@ export enum DocumentSignatureType {
DRAW = 'draw',
TYPE = 'type',
UPLOAD = 'upload',
KEYBOARD = 'keyboard',
}
export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
@ -84,16 +83,10 @@ export const extractTeamSignatureSettings = (
typedSignatureEnabled: boolean | null;
drawSignatureEnabled: boolean | null;
uploadSignatureEnabled: boolean | null;
keyboardSignatureEnabled?: boolean | null;
} | null,
) => {
if (!settings) {
return [
DocumentSignatureType.TYPE,
DocumentSignatureType.UPLOAD,
DocumentSignatureType.DRAW,
DocumentSignatureType.KEYBOARD,
];
return [DocumentSignatureType.TYPE, DocumentSignatureType.UPLOAD, DocumentSignatureType.DRAW];
}
const signatureTypes: DocumentSignatureType[] = [];
@ -110,10 +103,6 @@ export const extractTeamSignatureSettings = (
signatureTypes.push(DocumentSignatureType.UPLOAD);
}
if (settings.keyboardSignatureEnabled !== false) {
signatureTypes.push(DocumentSignatureType.KEYBOARD);
}
return signatureTypes;
};
@ -186,7 +175,6 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
typedSignatureEnabled: null,
uploadSignatureEnabled: null,
drawSignatureEnabled: null,
keyboardSignatureEnabled: null,
brandingEnabled: null,
brandingLogo: null,

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "keyboardSignatureEnabled" BOOLEAN;

View File

@ -776,7 +776,6 @@ model TeamGlobalSettings {
typedSignatureEnabled Boolean?
uploadSignatureEnabled Boolean?
drawSignatureEnabled Boolean?
keyboardSignatureEnabled Boolean?
emailId String?
email OrganisationEmail? @relation(fields: [emailId], references: [id])

View File

@ -0,0 +1,50 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZResetTwoFactorRequestSchema,
ZResetTwoFactorResponseSchema,
} from './reset-two-factor-authentication.types';
export const resetTwoFactorRoute = adminProcedure
.input(ZResetTwoFactorRequestSchema)
.output(ZResetTwoFactorResponseSchema)
.mutation(async ({ input, ctx }) => {
const { userId } = input;
ctx.logger.info({
input: {
userId,
},
});
return await resetTwoFactor({ userId });
});
export type ResetTwoFactorOptions = {
userId: number;
};
export const resetTwoFactor = async ({ userId }: ResetTwoFactorOptions) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'User not found' });
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: false,
twoFactorBackupCodes: null,
twoFactorSecret: null,
},
});
};

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZResetTwoFactorRequestSchema = z.object({
userId: z.number(),
});
export const ZResetTwoFactorResponseSchema = z.void();
export type TResetTwoFactorRequest = z.infer<typeof ZResetTwoFactorRequestSchema>;
export type TResetTwoFactorResponse = z.infer<typeof ZResetTwoFactorResponseSchema>;

View File

@ -21,6 +21,7 @@ import { deleteSubscriptionClaimRoute } from './delete-subscription-claim';
import { findAdminOrganisationsRoute } from './find-admin-organisations';
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { getAdminOrganisationRoute } from './get-admin-organisation';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import {
ZAdminDeleteDocumentMutationSchema,
ZAdminDeleteUserMutationSchema,
@ -51,6 +52,9 @@ export const adminRouter = router({
stripe: {
createCustomer: createStripeCustomerRoute,
},
user: {
resetTwoFactor: resetTwoFactorRoute,
},
// Todo: migrate old routes
findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => {

View File

@ -137,7 +137,7 @@ export const documentRouter = router({
templateId,
query,
source,
status,
status: status ? [status] : undefined,
page,
perPage,
folderId,
@ -284,6 +284,7 @@ export const documentRouter = router({
globalActionAuth,
recipients,
meta,
folderId,
} = input;
const { remaining } = await getServerLimits({ userId: user.id, teamId });
@ -316,6 +317,7 @@ export const documentRouter = router({
globalAccessAuth,
globalActionAuth,
recipients,
folderId,
},
meta,
requestMetadata: ctx.metadata,

View File

@ -142,9 +142,23 @@ export const ZFindDocumentsResponseSchema = ZFindResultResponse.extend({
export type TFindDocumentsResponse = z.infer<typeof ZFindDocumentsResponseSchema>;
export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.extend({
period: z.enum(['7d', '14d', '30d']).optional(),
period: z
.enum([
'today',
'yesterday',
'this-week',
'last-week',
'this-month',
'last-month',
'this-quarter',
'last-quarter',
'this-year',
'last-year',
'all-time',
])
.optional(),
senderIds: z.array(z.number()).optional(),
status: z.nativeEnum(ExtendedDocumentStatus).optional(),
status: z.array(z.nativeEnum(ExtendedDocumentStatus)).optional(),
folderId: z.string().optional(),
});
@ -209,6 +223,12 @@ 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({

View File

@ -1,5 +1,6 @@
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';
@ -38,7 +39,7 @@ export const updateOrganisationRoute = authenticatedProcedure
});
}
await prisma.organisation.update({
const updatedOrganisation = await prisma.organisation.update({
where: {
id: organisationId,
},
@ -47,4 +48,12 @@ export const updateOrganisationRoute = authenticatedProcedure
url: data.url,
},
});
if (updatedOrganisation.customerId) {
await stripe.customers.update(updatedOrganisation.customerId, {
metadata: {
organisationName: data.name,
},
});
}
});

View File

@ -1,8 +1,10 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { SetAvatarImageOptions } from '@documenso/lib/server-only/profile/set-avatar-image';
import { setAvatarImage } from '@documenso/lib/server-only/profile/set-avatar-image';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { submitSupportTicket } from '@documenso/lib/server-only/user/submit-support-ticket';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { adminProcedure, authenticatedProcedure, router } from '../trpc';
@ -10,6 +12,7 @@ import {
ZFindUserSecurityAuditLogsSchema,
ZRetrieveUserByIdQuerySchema,
ZSetProfileImageMutationSchema,
ZSubmitSupportTicketMutationSchema,
ZUpdateProfileMutationSchema,
} from './schema';
@ -91,4 +94,28 @@ export const profileRouter = router({
requestMetadata: ctx.metadata,
});
}),
submitSupportTicket: authenticatedProcedure
.input(ZSubmitSupportTicketMutationSchema)
.mutation(async ({ input, ctx }) => {
const { subject, message, organisationId, teamId } = input;
const userId = ctx.user.id;
const parsedTeamId = teamId ? Number(teamId) : null;
if (Number.isNaN(parsedTeamId)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid team ID provided',
});
}
return await submitSupportTicket({
subject,
message,
userId,
organisationId,
teamId: parsedTeamId,
});
}),
});

View File

@ -27,3 +27,12 @@ export const ZSetProfileImageMutationSchema = z.object({
});
export type TSetProfileImageMutationSchema = z.infer<typeof ZSetProfileImageMutationSchema>;
export const ZSubmitSupportTicketMutationSchema = z.object({
organisationId: z.string(),
teamId: z.string().min(1).nullish(),
subject: z.string().min(3, 'Subject is required'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
export type TSupportTicketRequest = z.infer<typeof ZSubmitSupportTicketMutationSchema>;

View File

@ -339,8 +339,14 @@ export const templateRouter = router({
.output(ZCreateDocumentFromTemplateResponseSchema)
.mutation(async ({ ctx, input }) => {
const { teamId } = ctx;
const { templateId, recipients, distributeDocument, customDocumentDataId, prefillFields } =
input;
const {
templateId,
recipients,
distributeDocument,
customDocumentDataId,
prefillFields,
folderId,
} = input;
ctx.logger.info({
input: {
@ -361,6 +367,7 @@ export const templateRouter = router({
recipients,
customDocumentDataId,
requestMetadata: ctx.metadata,
folderId,
prefillFields,
});

View File

@ -117,6 +117,12 @@ 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(

View File

@ -1,6 +1,7 @@
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';
@ -45,8 +46,8 @@ export const DocumentDownloadButton = ({
setIsLoading(false);
toast({
title: _('Something went wrong'),
description: _('An error occurred while downloading your document.'),
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}

View File

@ -86,7 +86,9 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<Trans>
<span className="sr-only">Go to first page</span>
</Trans>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
@ -95,7 +97,9 @@ export function DataTablePagination<TData>({
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<Trans>
<span className="sr-only">Go to previous page</span>
</Trans>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
@ -104,7 +108,9 @@ export function DataTablePagination<TData>({
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<Trans>
<span className="sr-only">Go to next page</span>
</Trans>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
@ -113,7 +119,9 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<Trans>
<span className="sr-only">Go to last page</span>
</Trans>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>

View File

@ -0,0 +1,145 @@
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>
);
}

View File

@ -0,0 +1,98 @@
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>
);
}

View File

@ -0,0 +1,139 @@
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>
);
}

View File

@ -0,0 +1,128 @@
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>
);
}

View File

@ -0,0 +1,236 @@
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>
)}
</>
);
}

View File

@ -0,0 +1,119 @@
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'],
},
];

View File

@ -0,0 +1,116 @@
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;
}

View File

@ -1,5 +1,3 @@
'use client';
import * as React from 'react';
import { useEffect } from 'react';

View File

@ -1,207 +0,0 @@
export enum KeyboardLayout {
QWERTY = 'QWERTY',
}
export type CurveType =
| 'linear'
| 'simple-curve'
| 'quadratic-bezier'
| 'cubic-bezier'
| 'catmull-rom';
export enum StrokeStyle {
SOLID = 'solid',
GRADIENT = 'gradient',
}
export interface StrokeConfig {
style: StrokeStyle;
color: string;
gradientStart: string;
gradientEnd: string;
width: number;
}
export interface Point {
x: number;
y: number;
}
export const getKeyboardLayout = (
layout: KeyboardLayout,
includeNumbers: boolean = false,
): Record<string, Point> => {
const qwertyLayout: Record<string, Point> = {
Q: { x: 0, y: includeNumbers ? 1 : 0 },
W: { x: 1, y: includeNumbers ? 1 : 0 },
E: { x: 2, y: includeNumbers ? 1 : 0 },
R: { x: 3, y: includeNumbers ? 1 : 0 },
T: { x: 4, y: includeNumbers ? 1 : 0 },
Y: { x: 5, y: includeNumbers ? 1 : 0 },
U: { x: 6, y: includeNumbers ? 1 : 0 },
I: { x: 7, y: includeNumbers ? 1 : 0 },
O: { x: 8, y: includeNumbers ? 1 : 0 },
P: { x: 9, y: includeNumbers ? 1 : 0 },
A: { x: 0.5, y: includeNumbers ? 2 : 1 },
S: { x: 1.5, y: includeNumbers ? 2 : 1 },
D: { x: 2.5, y: includeNumbers ? 2 : 1 },
F: { x: 3.5, y: includeNumbers ? 2 : 1 },
G: { x: 4.5, y: includeNumbers ? 2 : 1 },
H: { x: 5.5, y: includeNumbers ? 2 : 1 },
J: { x: 6.5, y: includeNumbers ? 2 : 1 },
K: { x: 7.5, y: includeNumbers ? 2 : 1 },
L: { x: 8.5, y: includeNumbers ? 2 : 1 },
Z: { x: 1, y: includeNumbers ? 3 : 2 },
X: { x: 2, y: includeNumbers ? 3 : 2 },
C: { x: 3, y: includeNumbers ? 3 : 2 },
V: { x: 4, y: includeNumbers ? 3 : 2 },
B: { x: 5, y: includeNumbers ? 3 : 2 },
N: { x: 6, y: includeNumbers ? 3 : 2 },
M: { x: 7, y: includeNumbers ? 3 : 2 },
};
if (includeNumbers) {
const numberRow = {
'1': { x: 0, y: 0 },
'2': { x: 1, y: 0 },
'3': { x: 2, y: 0 },
'4': { x: 3, y: 0 },
'5': { x: 4, y: 0 },
'6': { x: 5, y: 0 },
'7': { x: 6, y: 0 },
'8': { x: 7, y: 0 },
'9': { x: 8, y: 0 },
'0': { x: 9, y: 0 },
};
return { ...numberRow, ...qwertyLayout };
}
return qwertyLayout;
};
export const generatePath = (points: Point[], curveType: CurveType): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
switch (curveType) {
case 'linear':
return generateLinearPath(points);
case 'simple-curve':
return generateSimpleCurvePath(points);
case 'quadratic-bezier':
return generateQuadraticBezierPath(points);
case 'cubic-bezier':
return generateCubicBezierPath(points);
case 'catmull-rom':
return generateCatmullRomPath(points);
default:
return generateLinearPath(points);
}
};
const generateLinearPath = (points: Point[]): string => {
if (points.length === 0) return '';
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
path += ` L ${points[i].x} ${points[i].y}`;
}
return path;
};
const generateSimpleCurvePath = (points: Point[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const midX = (prev.x + curr.x) / 2;
const midY = (prev.y + curr.y) / 2;
if (i === 1) {
path += ` Q ${prev.x} ${prev.y} ${midX} ${midY}`;
} else {
path += ` T ${midX} ${midY}`;
}
}
const lastPoint = points[points.length - 1];
path += ` T ${lastPoint.x} ${lastPoint.y}`;
return path;
};
const generateQuadraticBezierPath = (points: Point[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const controlX = prev.x + (curr.x - prev.x) * 0.5;
const controlY = prev.y - Math.abs(curr.x - prev.x) * 0.3;
path += ` Q ${controlX} ${controlY} ${curr.x} ${curr.y}`;
}
return path;
};
const generateCubicBezierPath = (points: Point[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
const cp1x = prev.x + dx * 0.25;
const cp1y = prev.y + dy * 0.25 - Math.abs(dx) * 0.2;
const cp2x = prev.x + dx * 0.75;
const cp2y = prev.y + dy * 0.75 - Math.abs(dx) * 0.2;
path += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${curr.x} ${curr.y}`;
}
return path;
};
const generateCatmullRomPath = (points: Point[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
if (points.length === 2) return generateLinearPath(points);
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const p0 = i === 1 ? points[0] : points[i - 2];
const p1 = points[i - 1];
const p2 = points[i];
const p3 = i === points.length - 1 ? points[i] : points[i + 1];
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
path += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`;
}
return path;
};

View File

@ -1,146 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '../../lib/utils';
import { KeyboardLayout, StrokeStyle, generatePath, getKeyboardLayout } from './keyboard-utils';
export type SignaturePadKeyboardProps = {
className?: string;
onChange: (_value: string) => void;
};
export const SignaturePadKeyboard = ({ className, onChange }: SignaturePadKeyboardProps) => {
const [name, setName] = useState('');
const [currentKeyboardLayout] = useState<KeyboardLayout>(KeyboardLayout.QWERTY);
const curveType = 'linear';
const includeNumbers = false;
const strokeConfig = {
style: StrokeStyle.SOLID,
color: '#000000',
gradientStart: '#ff6b6b',
gradientEnd: '#4ecdc4',
width: 3,
};
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isInputFocused = document.activeElement === inputRef.current;
const isAnyInputFocused = document.activeElement?.tagName === 'INPUT';
if (!isInputFocused && !isAnyInputFocused) {
const regex = includeNumbers ? /^[a-zA-Z0-9]$/ : /^[a-zA-Z]$/;
if (regex.test(e.key) || e.key === 'Backspace') {
inputRef.current?.focus();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [includeNumbers]);
// Generate signature path
const signaturePath = useMemo(() => {
if (!name) return '';
const points = [];
const currentLayout = getKeyboardLayout(currentKeyboardLayout, includeNumbers);
for (const char of name.toUpperCase()) {
if (char in currentLayout) {
const { x, y } = currentLayout[char];
const yOffset = includeNumbers ? 100 : 40;
points.push({ x: x * 60 + 28, y: y * 60 + yOffset });
}
}
if (points.length === 0) return '';
return generatePath(points, curveType);
}, [name, currentKeyboardLayout, curveType, includeNumbers]);
// Update parent component when signature changes
useEffect(() => {
if (signaturePath && name) {
// Convert SVG to data URL for consistency with other signature types
const svgData = generateSVGDataURL(signaturePath);
onChange(svgData);
} else {
onChange('');
}
}, [signaturePath, name, onChange]);
const generateSVGDataURL = (path: string): string => {
const height = includeNumbers ? 260 : 200;
const gradients =
strokeConfig.style === StrokeStyle.GRADIENT
? `<linearGradient id="pathGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:${strokeConfig.gradientStart};stop-opacity:1" />
<stop offset="100%" style="stop-color:${strokeConfig.gradientEnd};stop-opacity:1" />
</linearGradient>`
: '';
const strokeColor =
strokeConfig.style === StrokeStyle.SOLID ? strokeConfig.color : 'url(#pathGradient)';
const svgContent = `<svg width="650" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs>${gradients}</defs>
<path d="${path}" stroke="${strokeColor}" stroke-width="${strokeConfig.width}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
};
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center', className)}>
<input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
className="sr-only"
autoFocus
/>
<div className="relative w-full max-w-lg">
<svg
className="pointer-events-none w-full"
viewBox="0 0 650 200"
preserveAspectRatio="xMidYMid meet"
style={{ height: '150px' }}
>
<defs>
{strokeConfig.style === StrokeStyle.GRADIENT && (
<linearGradient id="pathGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={strokeConfig.gradientStart} stopOpacity={1} />
<stop offset="100%" stopColor={strokeConfig.gradientEnd} stopOpacity={1} />
</linearGradient>
)}
</defs>
{signaturePath && (
<path
d={signaturePath}
stroke={
strokeConfig.style === StrokeStyle.SOLID ? strokeConfig.color : 'url(#pathGradient)'
}
strokeWidth={strokeConfig.width}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
</div>
<div className="mt-4 w-full max-w-lg">
<div className="text-muted-foreground/70 font-mono text-xs">{name}</div>
</div>
</div>
);
};

View File

@ -1,7 +1,7 @@
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import { Keyboard, KeyboardIcon, UploadCloudIcon } from 'lucide-react';
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
@ -10,7 +10,6 @@ import { isBase64Image } from '@documenso/lib/constants/signatures';
import { SignatureIcon } from '../../icons/signature';
import { cn } from '../../lib/utils';
import { SignaturePadDraw } from './signature-pad-draw';
import { SignaturePadKeyboard } from './signature-pad-keyboard';
import { SignaturePadType } from './signature-pad-type';
import { SignaturePadUpload } from './signature-pad-upload';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs';
@ -29,7 +28,6 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
keyboardSignatureEnabled?: boolean;
onValidityChange?: (isValid: boolean) => void;
};
@ -41,12 +39,10 @@ export const SignaturePad = ({
typedSignatureEnabled = true,
uploadSignatureEnabled = true,
drawSignatureEnabled = true,
keyboardSignatureEnabled = true,
}: SignaturePadProps) => {
const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : '');
const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : '');
const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value);
const [keyboardSignature, setKeyboardSignature] = useState(isBase64Image(value) ? value : '');
/**
* This is cooked.
@ -55,7 +51,7 @@ export const SignaturePad = ({
* the first enabled tab.
*/
const [tab, setTab] = useState(
((): 'draw' | 'text' | 'image' | 'keyboard' => {
((): 'draw' | 'text' | 'image' => {
// First passthrough to check to see if there's a signature for a given tab.
if (drawSignatureEnabled && drawSignature) {
return 'draw';
@ -69,10 +65,6 @@ export const SignaturePad = ({
return 'image';
}
if (keyboardSignatureEnabled && keyboardSignature) {
return 'keyboard';
}
// Second passthrough to just select the first avaliable tab.
if (drawSignatureEnabled) {
return 'draw';
@ -86,10 +78,6 @@ export const SignaturePad = ({
return 'image';
}
if (keyboardSignatureEnabled) {
return 'keyboard';
}
throw new Error('No signature enabled');
})(),
);
@ -121,16 +109,7 @@ export const SignaturePad = ({
});
};
const onKeyboardSignatureChange = (value: string) => {
setKeyboardSignature(value);
onChange?.({
type: DocumentSignatureType.KEYBOARD,
value,
});
};
const onTabChange = (value: 'draw' | 'text' | 'image' | 'keyboard') => {
const onTabChange = (value: 'draw' | 'text' | 'image') => {
if (disabled) {
return;
}
@ -147,18 +126,10 @@ export const SignaturePad = ({
.with('image', () => {
onImageSignatureChange(imageSignature);
})
.with('keyboard', () => {
onKeyboardSignatureChange(keyboardSignature);
})
.exhaustive();
};
if (
!drawSignatureEnabled &&
!typedSignatureEnabled &&
!uploadSignatureEnabled &&
!keyboardSignatureEnabled
) {
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) {
return null;
}
@ -169,7 +140,7 @@ export const SignaturePad = ({
'pointer-events-none': disabled,
})}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image' | 'keyboard')}
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image')}
>
<TabsList>
{drawSignatureEnabled && (
@ -192,13 +163,6 @@ export const SignaturePad = ({
Upload
</TabsTrigger>
)}
{keyboardSignatureEnabled && (
<TabsTrigger value="keyboard">
<Keyboard className="mr-2 size-4" />
Keyboard
</TabsTrigger>
)}
</TabsList>
<TabsContent
@ -230,13 +194,6 @@ export const SignaturePad = ({
>
<SignaturePadUpload value={imageSignature} onChange={onImageSignatureChange} />
</TabsContent>
<TabsContent
value="keyboard"
className="border-border aspect-signature-pad dark:bg-background relative flex items-center justify-center rounded-md border bg-neutral-50 text-center"
>
<SignaturePadKeyboard value={keyboardSignature} onChange={onKeyboardSignatureChange} />
</TabsContent>
</Tabs>
);
};

View File

@ -47,6 +47,7 @@
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
"NEXT_PUBLIC_DISABLE_SIGNUP",
"NEXT_PRIVATE_PLAIN_API_KEY",
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
"NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL",