diff --git a/apps/web/public/static/add-user.png b/apps/web/public/static/add-user.png
new file mode 100644
index 000000000..abd337ceb
Binary files /dev/null and b/apps/web/public/static/add-user.png differ
diff --git a/apps/web/public/static/mail-open-alert.png b/apps/web/public/static/mail-open-alert.png
new file mode 100644
index 000000000..1511f0bc5
Binary files /dev/null and b/apps/web/public/static/mail-open-alert.png differ
diff --git a/apps/web/public/static/mail-open.png b/apps/web/public/static/mail-open.png
new file mode 100644
index 000000000..306313b03
Binary files /dev/null and b/apps/web/public/static/mail-open.png differ
diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx
index 83ad81ca1..0fc660968 100644
--- a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx
+++ b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx
@@ -7,9 +7,9 @@ import Link from 'next/link';
import { Loader } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
-import { FindResultSet } from '@documenso/lib/types/find-result-set';
-import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
-import { Document, User } from '@documenso/prisma/client';
+import type { FindResultSet } from '@documenso/lib/types/find-result-set';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
+import type { Document, User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
- ? recipientInitials(row.original.User.name)
+ ? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (
diff --git a/packages/ui/primitives/multiselect-combobox.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx
similarity index 95%
rename from packages/ui/primitives/multiselect-combobox.tsx
rename to apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx
index bac87ce0b..9a25af897 100644
--- a/packages/ui/primitives/multiselect-combobox.tsx
+++ b/apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx
@@ -19,7 +19,7 @@ type ComboboxProps = {
onChange: (_values: string[]) => void;
};
-const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
+const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
const [open, setOpen] = React.useState(false);
const [selectedValues, setSelectedValues] = React.useState([]);
const dbRoles = Object.values(Role);
@@ -79,4 +79,4 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
);
};
-export { MultiSelectCombobox };
+export { MultiSelectRoleCombobox };
diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
index 9ae270d28..3bd909623 100644
--- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
+++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
@@ -18,9 +18,10 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
-import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
import { useToast } from '@documenso/ui/primitives/use-toast';
+import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
+
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
type TUserFormSchema = z.infer;
@@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
+ }
+ enableClearAllButton={true}
+ inputPlaceholder="Search"
+ loading={!isMounted || isInitialLoading}
+ options={comboBoxOptions}
+ selectedValues={senderIds}
+ onChange={onChange}
+ />
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx
index c8adb1422..13b85d526 100644
--- a/apps/web/src/app/(dashboard)/documents/data-table.tsx
+++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx
@@ -7,7 +7,7 @@ import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
-import type { Document, Recipient, User } from '@documenso/prisma/client';
+import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -25,11 +25,18 @@ export type DocumentsDataTableProps = {
Document & {
Recipient: Recipient[];
User: Pick;
+ team: Pick | null;
}
>;
+ showSenderColumn?: boolean;
+ team?: Pick;
};
-export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
+export const DocumentsDataTable = ({
+ results,
+ showSenderColumn,
+ team,
+}: DocumentsDataTableProps) => {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
@@ -61,6 +68,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
header: 'Title',
cell: ({ row }) => ,
},
+ {
+ id: 'sender',
+ header: 'Sender',
+ cell: ({ row }) => row.original.User.name ?? row.original.User.email,
+ },
{
header: 'Recipient',
accessorKey: 'recipient',
@@ -79,8 +91,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
-
-
+
+
),
},
@@ -90,6 +102,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
+ columnVisibility={{
+ sender: Boolean(showSenderColumn),
+ }}
>
{(table) => }
diff --git a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
new file mode 100644
index 000000000..ead3e8f4f
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
@@ -0,0 +1,158 @@
+import Link from 'next/link';
+
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
+import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
+import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
+import { getStats } from '@documenso/lib/server-only/document/get-stats';
+import { parseToIntegerArray } from '@documenso/lib/utils/params';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import type { Team, TeamEmail } from '@documenso/prisma/client';
+import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
+import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
+import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
+import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
+
+import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
+import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
+import { DocumentStatus } from '~/components/formatter/document-status';
+
+import { DocumentsDataTable } from './data-table';
+import { DataTableSenderFilter } from './data-table-sender-filter';
+import { EmptyDocumentState } from './empty-state';
+import { UploadDocument } from './upload-document';
+
+export type DocumentsPageViewProps = {
+ searchParams?: {
+ status?: ExtendedDocumentStatus;
+ period?: PeriodSelectorValue;
+ page?: string;
+ perPage?: string;
+ senderIds?: string;
+ };
+ team?: Team & { teamEmail?: TeamEmail | null };
+};
+
+export default async function DocumentsPageView({
+ searchParams = {},
+ team,
+}: DocumentsPageViewProps) {
+ const { user } = await getRequiredServerComponentSession();
+
+ const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
+ const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
+ const page = Number(searchParams.page) || 1;
+ const perPage = Number(searchParams.perPage) || 20;
+ const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
+ const currentTeam = team ? { id: team.id, url: team.url } : undefined;
+
+ const getStatOptions: GetStatsInput = {
+ user,
+ period,
+ };
+
+ if (team) {
+ getStatOptions.team = {
+ teamId: team.id,
+ teamEmail: team.teamEmail?.email,
+ senderIds,
+ };
+ }
+
+ const stats = await getStats(getStatOptions);
+
+ const results = await findDocuments({
+ userId: user.id,
+ teamId: team?.id,
+ status,
+ orderBy: {
+ column: 'createdAt',
+ direction: 'desc',
+ },
+ page,
+ perPage,
+ period,
+ senderIds,
+ });
+
+ const getTabHref = (value: typeof status) => {
+ const params = new URLSearchParams(searchParams);
+
+ params.set('status', value);
+
+ if (params.has('page')) {
+ params.delete('page');
+ }
+
+ return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
+ };
+
+ return (
+
+
+
+
+
+ {team && (
+
+
+ {team.name.slice(0, 1)}
+
+
+ )}
+
+
Documents
+
+
+
+
+
+ {[
+ ExtendedDocumentStatus.INBOX,
+ ExtendedDocumentStatus.PENDING,
+ ExtendedDocumentStatus.COMPLETED,
+ ExtendedDocumentStatus.DRAFT,
+ ExtendedDocumentStatus.ALL,
+ ].map((value) => (
+
+
+
+
+ {value !== ExtendedDocumentStatus.ALL && (
+
+ {Math.min(stats[value], 99)}
+ {stats[value] > 99 && '+'}
+
+ )}
+
+
+ ))}
+
+
+
+ {team &&
}
+
+
+
+
+
+
+ {results.count > 0 && (
+
+ )}
+ {results.count === 0 && }
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx
index 56c112d75..14370cff8 100644
--- a/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx
+++ b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx
@@ -1,5 +1,7 @@
import { useRouter } from 'next/navigation';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import type { Team } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -16,18 +18,21 @@ type DuplicateDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
+ team?: Pick;
};
export const DuplicateDocumentDialog = ({
id,
open,
onOpenChange,
+ team,
}: DuplicateDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
id,
+ teamId: team?.id,
});
const documentData = document?.documentData
@@ -37,10 +42,12 @@ export const DuplicateDocumentDialog = ({
}
: undefined;
+ const documentsPath = formatDocumentsPath(team?.url);
+
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
- router.push(`/documents/${newId}`);
+ router.push(`${documentsPath}/${newId}`);
toast({
title: 'Document Duplicated',
@@ -54,7 +61,7 @@ export const DuplicateDocumentDialog = ({
const onDuplicate = async () => {
try {
- await duplicateDocument({ id });
+ await duplicateDocument({ id, teamId: team?.id });
} catch {
toast({
title: 'Something went wrong',
diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx
index 5780df1dc..b67ed6f02 100644
--- a/apps/web/src/app/(dashboard)/documents/page.tsx
+++ b/apps/web/src/app/(dashboard)/documents/page.tsx
@@ -1,119 +1,16 @@
import type { Metadata } from 'next';
-import Link from 'next/link';
-import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
-import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
-import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
-import { getStats } from '@documenso/lib/server-only/document/get-stats';
-import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
-import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
-import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
-
-import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
-import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
-import { DocumentStatus } from '~/components/formatter/document-status';
-
-import { DocumentsDataTable } from './data-table';
-import { EmptyDocumentState } from './empty-state';
-import { UploadDocument } from './upload-document';
+import type { DocumentsPageViewProps } from './documents-page-view';
+import DocumentsPageView from './documents-page-view';
export type DocumentsPageProps = {
- searchParams?: {
- status?: ExtendedDocumentStatus;
- period?: PeriodSelectorValue;
- page?: string;
- perPage?: string;
- };
+ searchParams?: DocumentsPageViewProps['searchParams'];
};
export const metadata: Metadata = {
title: 'Documents',
};
-export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
- const { user } = await getRequiredServerComponentSession();
- const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
- const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
- const page = Number(searchParams.page) || 1;
- const perPage = Number(searchParams.perPage) || 20;
-
- const stats = await getStats({
- user,
- period,
- });
-
- const results = await findDocuments({
- userId: user.id,
- status,
- orderBy: {
- column: 'createdAt',
- direction: 'desc',
- },
- page,
- perPage,
- period,
- });
-
- const getTabHref = (value: typeof status) => {
- const params = new URLSearchParams(searchParams);
-
- params.set('status', value);
-
- if (params.has('page')) {
- params.delete('page');
- }
-
- return `/documents?${params.toString()}`;
- };
-
- return (
-
-
-
-
-
Documents
-
-
-
-
- {[
- ExtendedDocumentStatus.INBOX,
- ExtendedDocumentStatus.PENDING,
- ExtendedDocumentStatus.COMPLETED,
- ExtendedDocumentStatus.DRAFT,
- ExtendedDocumentStatus.ALL,
- ].map((value) => (
-
-
-
-
- {value !== ExtendedDocumentStatus.ALL && (
-
- {Math.min(stats[value], 99)}
- {stats[value] > 99 && '+'}
-
- )}
-
-
- ))}
-
-
-
-
-
-
-
-
- {results.count > 0 && }
- {results.count === 0 && }
-
-
- );
+export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
+ return ;
}
diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx
index 444bd1db0..ed91620dc 100644
--- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx
@@ -13,6 +13,7 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -21,9 +22,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type UploadDocumentProps = {
className?: string;
+ team?: {
+ id: number;
+ url: string;
+ };
};
-export const UploadDocument = ({ className }: UploadDocumentProps) => {
+export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const router = useRouter();
const analytics = useAnalytics();
@@ -39,13 +44,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const disabledMessage = useMemo(() => {
if (remaining.documents === 0) {
- return 'You have reached your document limit.';
+ return team
+ ? 'Document upload disabled due to unpaid invoices'
+ : 'You have reached your document limit.';
}
if (!session?.user.emailVerified) {
return 'Verify your email to upload documents.';
}
- }, [remaining.documents, session?.user.emailVerified]);
+ }, [remaining.documents, session?.user.emailVerified, team]);
const onFileDrop = async (file: File) => {
try {
@@ -61,6 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { id } = await createDocument({
title: file.name,
documentDataId,
+ teamId: team?.id,
});
toast({
@@ -75,7 +83,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
timestamp: new Date().toISOString(),
});
- router.push(`/documents/${id}`);
+ router.push(`${formatDocumentsPath(team?.url)}/${id}`);
} catch (error) {
console.error(error);
@@ -117,11 +125,13 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
/>
- {remaining.documents > 0 && Number.isFinite(remaining.documents) && (
-
- {remaining.documents} of {quota.documents} documents remaining this month.
-
- )}
+ {team?.id === undefined &&
+ remaining.documents > 0 &&
+ Number.isFinite(remaining.documents) && (
+
+ {remaining.documents} of {quota.documents} documents remaining this month.
+
+ )}
{isLoading && (
@@ -130,7 +140,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
)}
- {remaining.documents === 0 && (
+ {team?.id === undefined && remaining.documents === 0 && (
diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx
index 433aeb18c..99db66c55 100644
--- a/apps/web/src/app/(dashboard)/layout.tsx
+++ b/apps/web/src/app/(dashboard)/layout.tsx
@@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
@@ -26,13 +27,17 @@ export default async function AuthenticatedDashboardLayout({
redirect('/signin');
}
- const { user } = await getRequiredServerComponentSession();
+ const [{ user }, teams] = await Promise.all([
+ getRequiredServerComponentSession(),
+ getTeams({ userId: session.user.id }),
+ ]);
return (
{!user.emailVerified && }
-
+
+
{children}
diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx
index 8fd78cae3..9ed6a2515 100644
--- a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx
+++ b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx
@@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { createBillingPortal } from './create-billing-portal.action';
-export const BillingPortalButton = () => {
+export type BillingPortalButtonProps = {
+ buttonProps?: React.ComponentProps;
+};
+
+export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
@@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
};
return (
-