Compare commits

...

28 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan cb880fbd50 chore: fix conflicts 2025-08-22 16:17:59 +00:00
Ephraim Atta-Duncan a8b8721b22 chore: fix conflicts 2025-08-22 15:34:30 +00:00
Catalin Pit 67501b45cf feat: create document in a specific folder (#1965) 2025-08-23 00:12:17 +10:00
Catalin Pit 17b36ac8e4 feat: sync organization name with stripe (#1974) 2025-08-22 23:28:04 +10:00
Lucas Smith 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
Ephraim Duncan 1cb9de8083 chore: remove 'use client' directives (#1979) 2025-08-22 02:20:41 +00:00
Catalin Pit 231ef9c27e chore: add support option (#1853) 2025-08-19 20:59:03 +10:00
Catalin Pit 6f35342a83 feat: reset user 2fa from admin panel (#1943) 2025-08-19 13:09:05 +10:00
David Nguyen a51110d276 fix: prevent document unsigning on edit (#1963) 2025-08-18 13:48:51 +10:00
David Nguyen 7f81231467 fix: template e2e tests (#1969) 2025-08-18 12:42:36 +10:00
Lucas Smith 439262fd02 v1.12.2-rc.4 2025-08-16 19:16:29 +10:00
Lucas Smith 93a184355b chore: add translations (#1955) 2025-08-16 19:10:21 +10:00
David Nguyen 1dea0b8fab add dummy teamid (#1968) 2025-08-16 19:09:21 +10:00
Ephraim Atta-Duncan 3b2cb681fd chore: refactor 2025-07-25 10:36:50 +00:00
Ephraim Atta-Duncan 582fe91b14 fix: translation patterns 2025-07-24 16:42:24 +00:00
Ephraim Atta-Duncan 87e0ea2ee3 fix: build errors 2025-07-24 16:16:29 +00:00
David Nguyen ef3885d407 Merge branch 'main' into feat/document-table-filters 2025-07-23 14:41:41 +10:00
Ephraim Duncan 1b39799fc3 Merge branch 'main' into feat/document-table-filters 2025-07-10 09:09:28 +00:00
Ephraim Atta-Duncan 0f3c9dafa8 fix: cannot find source column 2025-06-19 15:35:31 +00:00
Ephraim Atta-Duncan 8484783ec5 fix: merge conflicts 2025-06-19 15:16:16 +00:00
Ephraim Duncan 5545fb36e8 Merge branch 'main' into feat/document-table-filters 2025-06-05 12:58:48 +00:00
Ephraim Atta-Duncan c5bc3a32f8 chore: clean up unused imports and improve accessibility in pagination 2025-06-05 12:55:14 +00:00
Ephraim Atta-Duncan 64695fad32 chore: add translations 2025-06-05 12:31:53 +00:00
Ephraim Atta-Duncan de45a63c97 chore: minor changes 2025-06-05 12:07:19 +00:00
Ephraim Atta-Duncan 2c064d5aff chore: minor changes 2025-06-05 11:41:26 +00:00
Ephraim Atta-Duncan 9739a0ca96 feat: use data-table on template pages 2025-06-05 10:53:53 +00:00
Ephraim Atta-Duncan 9ccd8e0397 fix: table empty state and use the table somewhere else 2025-06-05 07:42:39 +00:00
Ephraim Atta-Duncan f5365554ab feat: rework document table filters 2025-05-30 18:17:50 +00:00
67 changed files with 3927 additions and 1464 deletions
+2
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=
+1 -1
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
@@ -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>
);
};
@@ -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>
</>
);
};
@@ -1,5 +1,3 @@
'use client';
import { DateTime } from 'luxon';
import type { TooltipProps } from 'recharts';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
@@ -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()}
@@ -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>
);
};
@@ -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>
);
};
@@ -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} />
@@ -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);
@@ -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>
);
};
@@ -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>
);
@@ -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>
);
}
@@ -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 && (
@@ -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;
@@ -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) => (
@@ -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>
@@ -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`),
});
}
};
@@ -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`),
});
}
};
+1 -1
View File
@@ -101,5 +101,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.12.2-rc.3"
"version": "1.12.2-rc.4"
}
+53 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"workspaces": [
"apps/*",
"packages/*"
@@ -89,7 +89,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
+2
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,
+12
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({
@@ -144,10 +144,11 @@ test('[TEMPLATES]: use template', async ({ page }) => {
await page.getByRole('button', { name: 'Use Template' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
// Get input with Email label placeholder.
await page.getByLabel('Email').click();
await page.getByLabel('Email').fill(teamMemberUser.email);
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
+1
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",
+7
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') ?? '',
});
@@ -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: {
@@ -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(),
};
}
@@ -111,7 +111,7 @@ export const getDocumentWhereInput = async ({
visibility: {
in: teamVisibilityFilters,
},
teamId,
teamId: team.id,
},
// Or, if they are a recipient of the document.
{
+12 -13
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
@@ -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';
@@ -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;
};
@@ -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.
@@ -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.
@@ -134,6 +134,9 @@ export const setDocumentRecipients = async ({
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
const canPersistedRecipientBeModified =
existing && canRecipientBeModified(existing, document.fields);
if (
existing &&
hasRecipientBeenChanged(existing, recipient) &&
@@ -147,6 +150,7 @@ export const setDocumentRecipients = async ({
return {
...recipient,
_persisted: existing,
canPersistedRecipientBeModified,
};
});
@@ -162,6 +166,13 @@ export const setDocumentRecipients = async ({
});
}
if (recipient._persisted && !recipient.canPersistedRecipientBeModified) {
return {
...recipient._persisted,
clientId: recipient.clientId,
};
}
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
@@ -1,7 +1,5 @@
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@@ -104,10 +102,7 @@ export const updateDocumentRecipients = async ({
});
}
if (
hasRecipientBeenChanged(originalRecipient, recipient) &&
!canRecipientBeModified(originalRecipient, document.fields)
) {
if (!canRecipientBeModified(originalRecipient, document.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document',
});
@@ -203,9 +198,6 @@ export const updateDocumentRecipients = async ({
};
};
/**
* If you change this you MUST update the `hasRecipientBeenChanged` function.
*/
type RecipientData = {
id: number;
email?: string;
@@ -215,19 +207,3 @@ type RecipientData = {
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
const newRecipientActionAuth = newRecipientData.actionAuth || null;
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
);
};
@@ -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,
@@ -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;
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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,
},
});
};
@@ -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>;
@@ -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 }) => {
@@ -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,
+22 -2
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({
@@ -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,
},
});
}
});
@@ -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,
});
}),
});
@@ -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>;
@@ -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,
});
@@ -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(
+1
View File
@@ -141,6 +141,7 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path })
return await next({
ctx: {
...ctx,
teamId: ctx.teamId || -1,
logger: trpcSessionLogger,
user: ctx.user,
session: ctx.session,
@@ -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',
});
}
@@ -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>
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
)}
</>
);
}
@@ -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'],
},
];
@@ -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;
}
-2
View File
@@ -1,5 +1,3 @@
'use client';
import * as React from 'react';
import { useEffect } from 'react';
+1
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",