mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Compare commits
63 Commits
feat/rr7
...
feat/audit
| Author | SHA1 | Date | |
|---|---|---|---|
| 607f22513a | |||
| 99b0ad574e | |||
| 9594e1fee8 | |||
| 5e3a2b8f76 | |||
| f928503a33 | |||
| c389670785 | |||
| 99ad2eb645 | |||
| 2f48679b0b | |||
| e40c5d9d24 | |||
| ab323f149f | |||
| bf1c1ff9dc | |||
| 516e237966 | |||
| ac7d24eb12 | |||
| 0931c472a7 | |||
| 8c9dd5e372 | |||
| e108da546d | |||
| 17370749b4 | |||
| 12ada567f5 | |||
| bdb0b0ea88 | |||
| 6a41a37bd4 | |||
| d78cfec00e | |||
| f0dcf7e9bf | |||
| 6540291055 | |||
| 193325717d | |||
| b94645a451 | |||
| 7e6704faae | |||
| cf17fc61bc | |||
| 6df8b3aac8 | |||
| fdb31772db | |||
| a3dfd81870 | |||
| 755ef697ba | |||
| 37cc41d713 | |||
| dd2ef3a657 | |||
| 435b3ca4f8 | |||
| 278cd8a9de | |||
| f1526315f5 | |||
| 353a7e8e0d | |||
| 34b2504268 | |||
| 566abda36b | |||
| 9121a062b3 | |||
| e613e0e347 | |||
| 95aae52fa4 | |||
| 5958f38719 | |||
| 419bc02171 | |||
| 5e4956f3a2 | |||
| da71613c9f | |||
| 4d6efe091e | |||
| 7e6ac4db40 | |||
| a87af910c7 | |||
| e37b005d7f | |||
| 73f8518b47 | |||
| ac3deb113e | |||
| c82388c40a | |||
| 31be548939 | |||
| 063fd32f18 | |||
| 231f51bd1f | |||
| a8de8368a2 | |||
| 7dd331addf | |||
| c6743a7cec | |||
| efbc097191 | |||
| f1525991dc | |||
| fb173e4d0e | |||
| d422ffa873 |
23
.github/actions/cache-build/action.yml
vendored
23
.github/actions/cache-build/action.yml
vendored
@ -1,23 +0,0 @@
|
|||||||
name: Cache production build binaries
|
|
||||||
description: 'Cache or restore if necessary'
|
|
||||||
inputs:
|
|
||||||
node_version:
|
|
||||||
required: false
|
|
||||||
default: v22.x
|
|
||||||
runs:
|
|
||||||
using: 'composite'
|
|
||||||
steps:
|
|
||||||
- name: Cache production build
|
|
||||||
uses: actions/cache@v3
|
|
||||||
id: production-build-cache
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ github.workspace }}/apps/web/.next
|
|
||||||
**/.turbo/**
|
|
||||||
**/dist/**
|
|
||||||
|
|
||||||
key: prod-build-${{ github.run_id }}-${{ hashFiles('package-lock.json') }}
|
|
||||||
restore-keys: prod-build-
|
|
||||||
|
|
||||||
- run: npm run build
|
|
||||||
shell: bash
|
|
||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@ -26,7 +26,8 @@ jobs:
|
|||||||
- name: Copy env
|
- name: Copy env
|
||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
||||||
- uses: ./.github/actions/cache-build
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
build_docker:
|
build_docker:
|
||||||
name: Build Docker Image
|
name: Build Docker Image
|
||||||
|
|||||||
29
.github/workflows/clean-cache.yml
vendored
29
.github/workflows/clean-cache.yml
vendored
@ -1,29 +0,0 @@
|
|||||||
name: cleanup caches by a branch
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- closed
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cleanup:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Cleanup
|
|
||||||
run: |
|
|
||||||
gh extension install actions/gh-actions-cache
|
|
||||||
|
|
||||||
echo "Fetching list of cache key"
|
|
||||||
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
|
|
||||||
|
|
||||||
## Setting this to not fail the workflow while deleting cache keys.
|
|
||||||
set +e
|
|
||||||
echo "Deleting caches..."
|
|
||||||
for cacheKey in $cacheKeysForPR
|
|
||||||
do
|
|
||||||
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
|
|
||||||
done
|
|
||||||
echo "Done"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge
|
|
||||||
5
.github/workflows/codeql-analysis.yml
vendored
5
.github/workflows/codeql-analysis.yml
vendored
@ -10,7 +10,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
@ -30,7 +30,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/node-install
|
- uses: ./.github/actions/node-install
|
||||||
|
|
||||||
- uses: ./.github/actions/cache-build
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v3
|
||||||
|
|||||||
6
.github/workflows/e2e-tests.yml
vendored
6
.github/workflows/e2e-tests.yml
vendored
@ -28,7 +28,11 @@ jobs:
|
|||||||
- name: Seed the database
|
- name: Seed the database
|
||||||
run: npm run prisma:seed
|
run: npm run prisma:seed
|
||||||
|
|
||||||
- uses: ./.github/actions/cache-build
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install playwright browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: npm run ci
|
run: npm run ci
|
||||||
|
|||||||
@ -1,4 +1 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
npm run commitlint -- $1
|
npm run commitlint -- $1
|
||||||
|
|||||||
@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||||
|
|
||||||
|
|||||||
@ -6,5 +6,6 @@
|
|||||||
"solid": "Solid Integration",
|
"solid": "Solid Integration",
|
||||||
"preact": "Preact Integration",
|
"preact": "Preact Integration",
|
||||||
"angular": "Angular Integration",
|
"angular": "Angular Integration",
|
||||||
"css-variables": "CSS Variables"
|
"css-variables": "CSS Variables",
|
||||||
}
|
"authoring": "Authoring"
|
||||||
|
}
|
||||||
167
apps/documentation/pages/developers/embedding/authoring.mdx
Normal file
167
apps/documentation/pages/developers/embedding/authoring.mdx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
title: Authoring
|
||||||
|
description: Learn how to use embedded authoring to create documents and templates in your application
|
||||||
|
---
|
||||||
|
|
||||||
|
# Embedded Authoring
|
||||||
|
|
||||||
|
In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation directly within your application.
|
||||||
|
|
||||||
|
## How Embedded Authoring Works
|
||||||
|
|
||||||
|
The embedded authoring feature enables your users to create new documents without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions.
|
||||||
|
|
||||||
|
## Creating Documents with Embedded Authoring
|
||||||
|
|
||||||
|
To implement document creation in your application, use the `EmbedCreateDocument` component from our SDK:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
|
||||||
|
|
||||||
|
const DocumentCreator = () => {
|
||||||
|
// You'll need to obtain a presign token using your API key
|
||||||
|
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '800px', width: '100%' }}>
|
||||||
|
<EmbedCreateDocument
|
||||||
|
presignToken={presignToken}
|
||||||
|
externalId="order-12345"
|
||||||
|
onDocumentCreated={(data) => {
|
||||||
|
console.log('Document created with ID:', data.documentId);
|
||||||
|
console.log('External reference ID:', data.externalId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Obtaining a Presign Token
|
||||||
|
|
||||||
|
Before using the `EmbedCreateDocument` component, you'll need to obtain a presign token from your backend. This token authorizes the embedding session.
|
||||||
|
|
||||||
|
You can create a presign token by making a request to:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v2-beta/embedding/create-presign-token
|
||||||
|
```
|
||||||
|
|
||||||
|
This API endpoint requires authentication with your Documenso API key. The token has a default expiration of 1 hour, but you can customize this duration based on your security requirements.
|
||||||
|
|
||||||
|
You can find more details on this request at our [API Documentation](https://openapi.documenso.com/reference#tag/embedding)
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
The `EmbedCreateDocument` component accepts several configuration options:
|
||||||
|
|
||||||
|
| Option | Type | Description |
|
||||||
|
| ------------------ | ------- | ------------------------------------------------------------------ |
|
||||||
|
| `presignToken` | string | **Required**. The authentication token for the embedding session. |
|
||||||
|
| `externalId` | string | Optional reference ID from your system to link with the document. |
|
||||||
|
| `host` | string | Optional custom host URL. Defaults to `https://app.documenso.com`. |
|
||||||
|
| `css` | string | Optional custom CSS to style the embedded component. |
|
||||||
|
| `cssVars` | object | Optional CSS variables for colors, spacing, and more. |
|
||||||
|
| `darkModeDisabled` | boolean | Optional flag to disable dark mode. |
|
||||||
|
| `className` | string | Optional CSS class name for the iframe. |
|
||||||
|
|
||||||
|
## Feature Toggles
|
||||||
|
|
||||||
|
You can customize the authoring experience by enabling or disabling specific features:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<EmbedCreateDocument
|
||||||
|
presignToken="YOUR_PRESIGN_TOKEN"
|
||||||
|
features={{
|
||||||
|
allowConfigureSignatureTypes: true,
|
||||||
|
allowConfigureLanguage: true,
|
||||||
|
allowConfigureDateFormat: true,
|
||||||
|
allowConfigureTimezone: true,
|
||||||
|
allowConfigureRedirectUrl: true,
|
||||||
|
allowConfigureCommunication: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Handling Document Creation Events
|
||||||
|
|
||||||
|
The `onDocumentCreated` callback is triggered when a document is successfully created, providing both the document ID and your external reference ID:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<EmbedCreateDocument
|
||||||
|
presignToken="YOUR_PRESIGN_TOKEN"
|
||||||
|
externalId="order-12345"
|
||||||
|
onDocumentCreated={(data) => {
|
||||||
|
// Navigate to a success page
|
||||||
|
navigate(`/documents/success?id=${data.documentId}`);
|
||||||
|
|
||||||
|
// Or update your database with the document ID
|
||||||
|
updateOrderDocument(data.externalId, data.documentId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling the Embedded Component
|
||||||
|
|
||||||
|
You can customize the appearance of the embedded component using standard CSS classes:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<EmbedCreateDocument
|
||||||
|
className="h-screen w-full rounded-lg border-none shadow-md"
|
||||||
|
presignToken="YOUR_PRESIGN_TOKEN"
|
||||||
|
css={`
|
||||||
|
.documenso-embed {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
cssVars={{
|
||||||
|
primary: '#0000FF',
|
||||||
|
background: '#F5F5F5',
|
||||||
|
radius: '8px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Integration Example
|
||||||
|
|
||||||
|
Here's a complete example of integrating document creation in a React application:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
|
||||||
|
|
||||||
|
function DocumentCreator() {
|
||||||
|
// In a real application, you would fetch this token from your backend
|
||||||
|
// using your API key at /api/v2-beta/embedding/create-presign-token
|
||||||
|
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||||
|
const [documentId, setDocumentId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
if (documentId) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Document Created Successfully!</h2>
|
||||||
|
<p>Document ID: {documentId}</p>
|
||||||
|
<button onClick={() => setDocumentId(null)}>Create Another Document</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '800px', width: '100%' }}>
|
||||||
|
<EmbedCreateDocument
|
||||||
|
presignToken={presignToken}
|
||||||
|
externalId="order-12345"
|
||||||
|
onDocumentCreated={(data) => {
|
||||||
|
setDocumentId(data.documentId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DocumentCreator;
|
||||||
|
```
|
||||||
|
|
||||||
|
With embedded authoring, your users can seamlessly create documents within your application, enhancing the overall user experience and streamlining document workflows.
|
||||||
@ -169,6 +169,19 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
|
|||||||
|
|
||||||
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
|
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
|
||||||
|
|
||||||
|
## Embedded Authoring
|
||||||
|
|
||||||
|
In addition to embedding signing experiences, Documenso now supports **embedded authoring**, allowing your users to create documents and templates directly within your application.
|
||||||
|
|
||||||
|
With embedded authoring, you can:
|
||||||
|
|
||||||
|
- Create new documents with custom fields
|
||||||
|
- Configure document properties and settings
|
||||||
|
- Set up recipients and signing workflows
|
||||||
|
- Customize the authoring experience
|
||||||
|
|
||||||
|
For detailed implementation instructions and code examples, see our [Embedded Authoring](/developers/embedding/authoring) guide.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [React Integration](/developers/embedding/react)
|
- [React Integration](/developers/embedding/react)
|
||||||
@ -178,3 +191,4 @@ If you're using **web components**, the integration process is slightly differen
|
|||||||
- [Preact Integration](/developers/embedding/preact)
|
- [Preact Integration](/developers/embedding/preact)
|
||||||
- [Angular Integration](/developers/embedding/angular)
|
- [Angular Integration](/developers/embedding/angular)
|
||||||
- [CSS Variables](/developers/embedding/css-variables)
|
- [CSS Variables](/developers/embedding/css-variables)
|
||||||
|
- [Embedded Authoring](/developers/embedding/authoring)
|
||||||
|
|||||||
@ -532,3 +532,93 @@ Replace the `text` value with the corresponding field type:
|
|||||||
- For the `SELECT` field it should be `select`. (check this before merge)
|
- For the `SELECT` field it should be `select`. (check this before merge)
|
||||||
|
|
||||||
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
|
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
|
||||||
|
|
||||||
|
## Pre-fill Fields On Document Creation
|
||||||
|
|
||||||
|
The API allows you to pre-fill fields on document creation. This is useful when you want to create a document from an existing template and pre-fill the fields with specific values.
|
||||||
|
|
||||||
|
To pre-fill a field, you need to make a `POST` request to the `/api/v1/templates/{templateId}/generate-document` endpoint with the field information. Here's an example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "my-document.pdf",
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Example User",
|
||||||
|
"email": "example@documenso.com",
|
||||||
|
"signingOrder": 1,
|
||||||
|
"role": "SIGNER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prefillFields": [
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"type": "text",
|
||||||
|
"label": "my-label",
|
||||||
|
"placeholder": "my-placeholder",
|
||||||
|
"value": "my-value"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"type": "number",
|
||||||
|
"label": "my-label",
|
||||||
|
"placeholder": "my-placeholder",
|
||||||
|
"value": "123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"type": "checkbox",
|
||||||
|
"label": "my-label",
|
||||||
|
"placeholder": "my-placeholder",
|
||||||
|
"value": ["option-1", "option-2"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the endpoint in the [API V1 documentation](https://app.documenso.com/api/v1/openapi#:~:text=/%7BtemplateId%7D/-,generate,-%2Ddocument).
|
||||||
|
|
||||||
|
### API V2
|
||||||
|
|
||||||
|
For API V2, you need to make a `POST` request to the `/api/v2-beta/template/use` endpoint with the field(s) information. Here's an example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"templateId": 111,
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Example User",
|
||||||
|
"email": "example@documenso.com",
|
||||||
|
"signingOrder": 1,
|
||||||
|
"role": "SIGNER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prefillFields": [
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"type": "text",
|
||||||
|
"label": "my-label",
|
||||||
|
"placeholder": "my-placeholder",
|
||||||
|
"value": "my-value"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"type": "number",
|
||||||
|
"label": "my-label",
|
||||||
|
"placeholder": "my-placeholder",
|
||||||
|
"value": "123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"type": "checkbox",
|
||||||
|
"label": "my-label",
|
||||||
|
"placeholder": "my-placeholder",
|
||||||
|
"value": ["option-1", "option-2"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the endpoint in the [API V2 documentation](https://openapi.documenso.com/reference#tag/template/POST/template/use).
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import NextPlausibleProvider from 'next-plausible';
|
import NextPlausibleProvider from 'next-plausible';
|
||||||
|
|||||||
54
apps/openpage-api/lib/add-zero-month.ts
Normal file
54
apps/openpage-api/lib/add-zero-month.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
export interface TransformedData {
|
||||||
|
labels: string[];
|
||||||
|
datasets: Array<{
|
||||||
|
label: string;
|
||||||
|
data: number[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addZeroMonth(transformedData: TransformedData): TransformedData {
|
||||||
|
const result = {
|
||||||
|
labels: [...transformedData.labels],
|
||||||
|
datasets: transformedData.datasets.map((dataset) => ({
|
||||||
|
label: dataset.label,
|
||||||
|
data: [...dataset.data],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.labels.length === 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.datasets.every((dataset) => dataset.data[0] === 0)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let firstMonth = DateTime.fromFormat(result.labels[0], 'MMM yyyy');
|
||||||
|
if (!firstMonth.isValid) {
|
||||||
|
const formats = ['MMM yyyy', 'MMMM yyyy', 'MM/yyyy', 'yyyy-MM'];
|
||||||
|
|
||||||
|
for (const format of formats) {
|
||||||
|
firstMonth = DateTime.fromFormat(result.labels[0], format);
|
||||||
|
if (firstMonth.isValid) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstMonth.isValid) {
|
||||||
|
console.warn(`Could not parse date: "${result.labels[0]}"`);
|
||||||
|
return transformedData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeroMonth = firstMonth.minus({ months: 1 }).toFormat('MMM yyyy');
|
||||||
|
result.labels.unshift(zeroMonth);
|
||||||
|
result.datasets.forEach((dataset) => {
|
||||||
|
dataset.data.unshift(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return transformedData;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,8 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { addZeroMonth } from '../add-zero-month';
|
||||||
|
|
||||||
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('Document')
|
.selectFrom('Document')
|
||||||
@ -35,7 +37,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return transformedData;
|
return addZeroMonth(transformedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { addZeroMonth } from '../add-zero-month';
|
||||||
|
|
||||||
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('Recipient')
|
.selectFrom('Recipient')
|
||||||
@ -34,7 +36,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return transformedData;
|
return addZeroMonth(transformedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetSignerConversionMonthlyResult = Awaited<
|
export type GetSignerConversionMonthlyResult = Awaited<
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { addZeroMonth } from '../add-zero-month';
|
||||||
|
|
||||||
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('User')
|
.selectFrom('User')
|
||||||
@ -32,7 +34,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return transformedData;
|
return addZeroMonth(transformedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
|
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { addZeroMonth } from './add-zero-month';
|
||||||
|
|
||||||
type MetricKeys = {
|
type MetricKeys = {
|
||||||
stars: number;
|
stars: number;
|
||||||
forks: number;
|
forks: number;
|
||||||
@ -37,31 +39,77 @@ export function transformData({
|
|||||||
data: DataEntry;
|
data: DataEntry;
|
||||||
metric: MetricKey;
|
metric: MetricKey;
|
||||||
}): TransformData {
|
}): TransformData {
|
||||||
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
try {
|
||||||
const [yearA, monthA] = dateA.split('-').map(Number);
|
if (!data || Object.keys(data).length === 0) {
|
||||||
const [yearB, monthB] = dateB.split('-').map(Number);
|
return {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
|
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
||||||
});
|
try {
|
||||||
|
const [yearA, monthA] = dateA.split('-').map(Number);
|
||||||
|
const [yearB, monthB] = dateB.split('-').map(Number);
|
||||||
|
|
||||||
const labels = sortedEntries.map(([date]) => {
|
if (isNaN(yearA) || isNaN(monthA) || isNaN(yearB) || isNaN(monthB)) {
|
||||||
const [year, month] = date.split('-');
|
console.warn(`Invalid date format: ${dateA} or ${dateB}`);
|
||||||
const dateTime = DateTime.fromObject({
|
return 0;
|
||||||
year: Number(year),
|
}
|
||||||
month: Number(month),
|
|
||||||
|
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sorting entries:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return dateTime.toFormat('MMM yyyy');
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
const labels = sortedEntries.map(([date]) => {
|
||||||
labels,
|
try {
|
||||||
datasets: [
|
const [year, month] = date.split('-');
|
||||||
{
|
|
||||||
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
|
if (!year || !month || isNaN(Number(year)) || isNaN(Number(month))) {
|
||||||
data: sortedEntries.map(([_, stats]) => stats[metric]),
|
console.warn(`Invalid date format: ${date}`);
|
||||||
},
|
return date;
|
||||||
],
|
}
|
||||||
};
|
|
||||||
|
const dateTime = DateTime.fromObject({
|
||||||
|
year: Number(year),
|
||||||
|
month: Number(month),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dateTime.isValid) {
|
||||||
|
console.warn(`Invalid DateTime object for: ${date}`);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateTime.toFormat('MMM yyyy');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error, date);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedData = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
|
||||||
|
data: sortedEntries.map(([_, stats]) => {
|
||||||
|
const value = stats[metric];
|
||||||
|
return typeof value === 'number' && !isNaN(value) ? value : 0;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return addZeroMonth(transformedData);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// To be on the safer side
|
// To be on the safer side
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
# Exit on error.
|
# Exit on error.
|
||||||
set -eo pipefail
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
WEB_APP_DIR="$SCRIPT_DIR/.."
|
WEB_APP_DIR="$SCRIPT_DIR/.."
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -9,64 +14,192 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
|
export type NextSigner = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ConfirmationDialogProps = {
|
type ConfirmationDialogProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: (nextSigner?: NextSigner) => void;
|
||||||
hasUninsertedFields: boolean;
|
hasUninsertedFields: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
|
allowDictateNextSigner?: boolean;
|
||||||
|
defaultNextSigner?: NextSigner;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ZNextSignerFormSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||||
|
|
||||||
export function AssistantConfirmationDialog({
|
export function AssistantConfirmationDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
hasUninsertedFields,
|
hasUninsertedFields,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
allowDictateNextSigner = false,
|
||||||
|
defaultNextSigner,
|
||||||
}: ConfirmationDialogProps) {
|
}: ConfirmationDialogProps) {
|
||||||
|
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
|
resolver: zodResolver(ZNextSignerFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const onOpenChange = () => {
|
const onOpenChange = () => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Validate the form and submit it if dictate signer is enabled.
|
||||||
|
if (allowDictateNextSigner) {
|
||||||
|
void form.handleSubmit(onConfirm)();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirm();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<Form {...form}>
|
||||||
<DialogTitle>
|
<form>
|
||||||
<Trans>Complete Document</Trans>
|
<fieldset disabled={isSubmitting} className="border-none p-0">
|
||||||
</DialogTitle>
|
<DialogHeader>
|
||||||
<DialogDescription>
|
<DialogTitle>
|
||||||
<Trans>
|
<Trans>Complete Document</Trans>
|
||||||
Are you sure you want to complete the document? This action cannot be undone. Please
|
</DialogTitle>
|
||||||
ensure that you have completed prefilling all relevant fields before proceeding.
|
<DialogDescription>
|
||||||
</Trans>
|
<Trans>
|
||||||
</DialogDescription>
|
Are you sure you want to complete the document? This action cannot be undone.
|
||||||
</DialogHeader>
|
Please ensure that you have completed prefilling all relevant fields before
|
||||||
|
proceeding.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
<DocumentSigningDisclosure />
|
{allowDictateNextSigner && (
|
||||||
</div>
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
{!isEditingNextSigner && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The next recipient to sign this document will be{' '}
|
||||||
|
<span className="font-semibold">{form.watch('name')}</span> (
|
||||||
|
<span className="font-semibold">{form.watch('email')}</span>).
|
||||||
|
</p>
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<Button
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
type="button"
|
||||||
Cancel
|
className="mt-2"
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button
|
size="sm"
|
||||||
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||||
onClick={onConfirm}
|
>
|
||||||
disabled={isSubmitting}
|
<Trans>Update Recipient</Trans>
|
||||||
loading={isSubmitting}
|
</Button>
|
||||||
>
|
</div>
|
||||||
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
|
)}
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
{isEditingNextSigner && (
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's name"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="email"
|
||||||
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's email"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DocumentSigningDisclosure className="mt-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
{hasUninsertedFields ? <Trans>Proceed</Trans> : <Trans>Continue</Trans>}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,216 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export type DocumentMoveToFolderDialogProps = {
|
||||||
|
documentId: number;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
currentFolderId?: string;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZMoveDocumentFormSchema = z.object({
|
||||||
|
folderId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TMoveDocumentFormSchema = z.infer<typeof ZMoveDocumentFormSchema>;
|
||||||
|
|
||||||
|
export const DocumentMoveToFolderDialog = ({
|
||||||
|
documentId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
currentFolderId,
|
||||||
|
...props
|
||||||
|
}: DocumentMoveToFolderDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const form = useForm<TMoveDocumentFormSchema>({
|
||||||
|
resolver: zodResolver(ZMoveDocumentFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
folderId: currentFolderId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
||||||
|
{
|
||||||
|
parentId: currentFolderId,
|
||||||
|
type: FolderType.DOCUMENT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: open,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
} else {
|
||||||
|
form.reset({ folderId: currentFolderId });
|
||||||
|
}
|
||||||
|
}, [open, currentFolderId, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
||||||
|
try {
|
||||||
|
await moveDocumentToFolder({
|
||||||
|
documentId,
|
||||||
|
folderId: data.folderId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Document moved`),
|
||||||
|
description: _(msg`The document has been moved successfully.`),
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
if (data.folderId) {
|
||||||
|
void navigate(`${documentsPath}/f/${data.folderId}`);
|
||||||
|
} else {
|
||||||
|
void navigate(documentsPath);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: _(msg`The folder you are trying to move the document to does not exist.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: _(msg`An error occurred while moving the document.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Move Document to Folder</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Select a folder to move this document to.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="folderId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Folder</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{isFoldersLoading ? (
|
||||||
|
<div className="flex h-10 items-center justify-center">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={field.value === null ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => field.onChange(null)}
|
||||||
|
disabled={currentFolderId === null}
|
||||||
|
>
|
||||||
|
<HomeIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Root (No Folder)</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{folders?.data.map((folder) => (
|
||||||
|
<Button
|
||||||
|
key={folder.id}
|
||||||
|
type="button"
|
||||||
|
variant={field.value === folder.id ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => field.onChange(folder.id)}
|
||||||
|
disabled={currentFolderId === folder.id}
|
||||||
|
>
|
||||||
|
<FolderIcon className="mr-2 h-4 w-4" />
|
||||||
|
{folder.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isFoldersLoading || form.formState.isSubmitting || currentFolderId === null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>Move</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -4,14 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Team } from '@prisma/client';
|
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||||
import { type Document, type Recipient, SigningStatus } from '@prisma/client';
|
|
||||||
import { History } from 'lucide-react';
|
import { History } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -43,9 +43,7 @@ import { StackAvatar } from '../general/stack-avatar';
|
|||||||
const FORM_ID = 'resend-email';
|
const FORM_ID = 'resend-email';
|
||||||
|
|
||||||
export type DocumentResendDialogProps = {
|
export type DocumentResendDialogProps = {
|
||||||
document: Document & {
|
document: TDocumentRow;
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
|
||||||
};
|
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
163
apps/remix/app/components/dialogs/folder-create-dialog.tsx
Normal file
163
apps/remix/app/components/dialogs/folder-create-dialog.tsx
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { FolderPlusIcon } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate, useParams } from 'react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
const ZCreateFolderFormSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: 'Folder name is required' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
||||||
|
|
||||||
|
export type CreateFolderDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { folderId } = useParams();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<TCreateFolderFormSchema>({
|
||||||
|
resolver: zodResolver(ZCreateFolderFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: TCreateFolderFormSchema) => {
|
||||||
|
try {
|
||||||
|
const newFolder = await createFolder({
|
||||||
|
name: data.name,
|
||||||
|
parentId: folderId,
|
||||||
|
type: FolderType.DOCUMENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsCreateFolderOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: 'Folder created successfully',
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
void navigate(`${documentsPath}/f/${newFolder.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to create folder',
|
||||||
|
description: _(msg`This folder name is already taken.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Failed to create folder',
|
||||||
|
description: _(msg`An unknown error occurred while creating the folder.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCreateFolderOpen) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [isCreateFolderOpen, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="outline" className="flex items-center space-x-2">
|
||||||
|
<FolderPlusIcon className="h-4 w-4" />
|
||||||
|
<span>Create Folder</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Folder</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter a name for your new folder. Folders help you organize your documents.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Folder Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="My Folder" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setIsCreateFolderOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit">Create</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
159
apps/remix/app/components/dialogs/folder-delete-dialog.tsx
Normal file
159
apps/remix/app/components/dialogs/folder-delete-dialog.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type FolderDeleteDialogProps = {
|
||||||
|
folder: TFolderWithSubfolders | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDeleteDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
|
||||||
|
|
||||||
|
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
|
||||||
|
|
||||||
|
const ZDeleteFolderFormSchema = z.object({
|
||||||
|
confirmText: z.literal(deleteMessage, {
|
||||||
|
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
|
||||||
|
|
||||||
|
const form = useForm<TDeleteFolderFormSchema>({
|
||||||
|
resolver: zodResolver(ZDeleteFolderFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
confirmText: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = async () => {
|
||||||
|
if (!folder) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteFolder({
|
||||||
|
id: folder.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Folder deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||||
|
toast({
|
||||||
|
title: 'Folder not found',
|
||||||
|
description: _(msg`The folder you are trying to delete does not exist.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete folder',
|
||||||
|
description: _(msg`An unknown error occurred while deleting the folder.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Folder</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this folder?
|
||||||
|
{folder && folder._count.documents > 0 && (
|
||||||
|
<span className="text-destructive mt-2 block">
|
||||||
|
This folder contains {folder._count.documents} document(s). Deleting it will also
|
||||||
|
delete all documents in the folder.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{folder && folder._count.subfolders > 0 && (
|
||||||
|
<span className="text-destructive mt-2 block">
|
||||||
|
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
|
||||||
|
delete all subfolders and their contents.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirmText"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>
|
||||||
|
Confirm by typing:{' '}
|
||||||
|
<span className="font-sm text-destructive font-semibold">
|
||||||
|
{deleteMessage}
|
||||||
|
</span>
|
||||||
|
</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder={deleteMessage} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
169
apps/remix/app/components/dialogs/folder-move-dialog.tsx
Normal file
169
apps/remix/app/components/dialogs/folder-move-dialog.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { FolderIcon, HomeIcon } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type FolderMoveDialogProps = {
|
||||||
|
foldersData: TFolderWithSubfolders[] | undefined;
|
||||||
|
folder: TFolderWithSubfolders | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZMoveFolderFormSchema = z.object({
|
||||||
|
targetFolderId: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
|
||||||
|
|
||||||
|
export const FolderMoveDialog = ({
|
||||||
|
foldersData,
|
||||||
|
folder,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: FolderMoveDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<TMoveFolderFormSchema>({
|
||||||
|
resolver: zodResolver(ZMoveFolderFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
targetFolderId: folder?.parentId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
||||||
|
if (!folder) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await moveFolder({
|
||||||
|
id: folder.id,
|
||||||
|
parentId: targetFolderId || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Folder moved successfully',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||||
|
toast({
|
||||||
|
title: 'Folder not found',
|
||||||
|
description: _(msg`The folder you are trying to move does not exist.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Failed to move folder',
|
||||||
|
description: _(msg`An unknown error occurred while moving the folder.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [isOpen, form]);
|
||||||
|
|
||||||
|
// Filter out the current folder and only show folders of the same type
|
||||||
|
const filteredFolders = foldersData?.filter(
|
||||||
|
(f) => f.id !== folder?.id && f.type === folder?.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Move Folder</DialogTitle>
|
||||||
|
<DialogDescription>Select a destination for this folder.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="targetFolderId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={!field.value ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
disabled={!folder?.parentId}
|
||||||
|
onClick={() => field.onChange(null)}
|
||||||
|
>
|
||||||
|
<HomeIcon className="mr-2 h-4 w-4" />
|
||||||
|
Root
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{filteredFolders &&
|
||||||
|
filteredFolders.map((f) => (
|
||||||
|
<Button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
disabled={f.id === folder?.parentId}
|
||||||
|
variant={field.value === f.id ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => field.onChange(f.id)}
|
||||||
|
>
|
||||||
|
<FolderIcon className="mr-2 h-4 w-4" />
|
||||||
|
{f.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
Move Folder
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
173
apps/remix/app/components/dialogs/folder-settings-dialog.tsx
Normal file
173
apps/remix/app/components/dialogs/folder-settings-dialog.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export type FolderSettingsDialogProps = {
|
||||||
|
folder: TFolderWithSubfolders | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const ZUpdateFolderFormSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
|
||||||
|
|
||||||
|
export const FolderSettingsDialog = ({
|
||||||
|
folder,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: FolderSettingsDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||||
|
|
||||||
|
const isTeamContext = !!team;
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
||||||
|
resolver: zodResolver(ZUpdateFolderFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: folder?.name ?? '',
|
||||||
|
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (folder) {
|
||||||
|
form.reset({
|
||||||
|
name: folder.name,
|
||||||
|
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [folder, form]);
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
|
||||||
|
if (!folder) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateFolder({
|
||||||
|
id: folder.id,
|
||||||
|
name: data.name,
|
||||||
|
visibility: isTeamContext
|
||||||
|
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
||||||
|
: DocumentVisibility.EVERYONE,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Folder updated successfully`),
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Folder not found`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Folder Settings</DialogTitle>
|
||||||
|
<DialogDescription>Manage the settings for this folder.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isTeamContext && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="visibility"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Visibility</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select visibility" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
|
||||||
|
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||||
|
Managers and above
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit">Save Changes</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -114,7 +114,7 @@ export const TemplateBulkSendDialog = ({
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button>
|
<Button variant="outline">
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
<Trans>Bulk Send via CSV</Trans>
|
<Trans>Bulk Send via CSV</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -24,11 +24,11 @@ import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type TemplateCreateDialogProps = {
|
type TemplateCreateDialogProps = {
|
||||||
teamId?: number;
|
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
|
folderId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogProps) => {
|
export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCreateDialogProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
@ -53,6 +53,7 @@ export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogP
|
|||||||
const { id } = await createTemplate({
|
const { id } = await createTemplate({
|
||||||
title: file.name,
|
title: file.name,
|
||||||
templateDocumentDataId: response.id,
|
templateDocumentDataId: response.id,
|
||||||
|
folderId: folderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { FolderPlusIcon } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate, useParams } from 'react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||||
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
const ZCreateFolderFormSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: 'Folder name is required' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
||||||
|
|
||||||
|
export type TemplateFolderCreateDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const TemplateFolderCreateDialog = ({
|
||||||
|
trigger,
|
||||||
|
...props
|
||||||
|
}: TemplateFolderCreateDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
const { folderId } = useParams();
|
||||||
|
|
||||||
|
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<TCreateFolderFormSchema>({
|
||||||
|
resolver: zodResolver(ZCreateFolderFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: TCreateFolderFormSchema) => {
|
||||||
|
try {
|
||||||
|
const newFolder = await createFolder({
|
||||||
|
name: data.name,
|
||||||
|
parentId: folderId,
|
||||||
|
type: FolderType.TEMPLATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsCreateFolderOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: _(msg`Folder created successfully`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const templatesPath = formatTemplatesPath(team?.url);
|
||||||
|
|
||||||
|
void navigate(`${templatesPath}/f/${newFolder.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Failed to create folder`),
|
||||||
|
description: _(msg`This folder name is already taken.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Failed to create folder`),
|
||||||
|
description: _(msg`An unknown error occurred while creating the folder.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCreateFolderOpen) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [isCreateFolderOpen, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="outline" className="flex items-center space-x-2">
|
||||||
|
<FolderPlusIcon className="h-4 w-4" />
|
||||||
|
<span>Create Folder</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Folder</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter a name for your new folder. Folders help you organize your templates.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Folder Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="My Folder" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setIsCreateFolderOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Create</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type TemplateFolderDeleteDialogProps = {
|
||||||
|
folder: TFolderWithSubfolders | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const TemplateFolderDeleteDialog = ({
|
||||||
|
folder,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: TemplateFolderDeleteDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
|
||||||
|
|
||||||
|
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
|
||||||
|
|
||||||
|
const ZDeleteFolderFormSchema = z.object({
|
||||||
|
confirmText: z.literal(deleteMessage, {
|
||||||
|
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
|
||||||
|
|
||||||
|
const form = useForm<TDeleteFolderFormSchema>({
|
||||||
|
resolver: zodResolver(ZDeleteFolderFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
confirmText: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = async () => {
|
||||||
|
if (!folder) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteFolder({
|
||||||
|
id: folder.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Folder deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||||
|
toast({
|
||||||
|
title: 'Folder not found',
|
||||||
|
description: _(msg`The folder you are trying to delete does not exist.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete folder',
|
||||||
|
description: _(msg`An unknown error occurred while deleting the folder.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Folder</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this folder?
|
||||||
|
{folder && folder._count.documents > 0 && (
|
||||||
|
<span className="text-destructive mt-2 block">
|
||||||
|
This folder contains {folder._count.documents} document(s). Deleting it will also
|
||||||
|
delete all documents in the folder.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{folder && folder._count.subfolders > 0 && (
|
||||||
|
<span className="text-destructive mt-2 block">
|
||||||
|
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
|
||||||
|
delete all subfolders and their contents.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirmText"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>
|
||||||
|
Confirm by typing:{' '}
|
||||||
|
<span className="font-sm text-destructive font-semibold">
|
||||||
|
{deleteMessage}
|
||||||
|
</span>
|
||||||
|
</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder={deleteMessage} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { FolderIcon, HomeIcon } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type TemplateFolderMoveDialogProps = {
|
||||||
|
foldersData: TFolderWithSubfolders[] | undefined;
|
||||||
|
folder: TFolderWithSubfolders | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZMoveFolderFormSchema = z.object({
|
||||||
|
targetFolderId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
|
||||||
|
|
||||||
|
export const TemplateFolderMoveDialog = ({
|
||||||
|
foldersData,
|
||||||
|
folder,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: TemplateFolderMoveDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<TMoveFolderFormSchema>({
|
||||||
|
resolver: zodResolver(ZMoveFolderFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
targetFolderId: folder?.parentId ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
||||||
|
if (!folder) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await moveFolder({
|
||||||
|
id: folder.id,
|
||||||
|
parentId: targetFolderId ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Folder moved successfully',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||||
|
toast({
|
||||||
|
title: 'Folder not found',
|
||||||
|
description: _(msg`The folder you are trying to move does not exist.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Failed to move folder',
|
||||||
|
description: _(msg`An unknown error occurred while moving the folder.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [isOpen, form]);
|
||||||
|
|
||||||
|
// Filter out the current folder and only show folders of the same type
|
||||||
|
const filteredFolders = foldersData?.filter(
|
||||||
|
(f) => f.id !== folder?.id && f.type === folder?.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Move Folder</DialogTitle>
|
||||||
|
<DialogDescription>Select a destination for this folder.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="targetFolderId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={!field.value ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
disabled={!folder?.parentId}
|
||||||
|
onClick={() => field.onChange(undefined)}
|
||||||
|
>
|
||||||
|
<HomeIcon className="mr-2 h-4 w-4" />
|
||||||
|
Root
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{filteredFolders &&
|
||||||
|
filteredFolders.map((f) => (
|
||||||
|
<Button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
disabled={f.id === folder?.parentId}
|
||||||
|
variant={field.value === f.id ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => field.onChange(f.id)}
|
||||||
|
>
|
||||||
|
<FolderIcon className="mr-2 h-4 w-4" />
|
||||||
|
{f.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
form.formState.isSubmitting ||
|
||||||
|
form.getValues('targetFolderId') === folder?.parentId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Move Folder
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
|
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export type TemplateFolderSettingsDialogProps = {
|
||||||
|
folder: TFolderWithSubfolders | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const ZUpdateFolderFormSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
|
||||||
|
|
||||||
|
export const TemplateFolderSettingsDialog = ({
|
||||||
|
folder,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: TemplateFolderSettingsDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||||
|
|
||||||
|
const isTeamContext = !!team;
|
||||||
|
const isTemplateFolder = folder?.type === FolderType.TEMPLATE;
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
||||||
|
resolver: zodResolver(ZUpdateFolderFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: folder?.name ?? '',
|
||||||
|
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (folder) {
|
||||||
|
form.reset({
|
||||||
|
name: folder.name,
|
||||||
|
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [folder, form]);
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
|
||||||
|
if (!folder) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateFolder({
|
||||||
|
id: folder.id,
|
||||||
|
name: data.name,
|
||||||
|
visibility:
|
||||||
|
isTeamContext && !isTemplateFolder
|
||||||
|
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
||||||
|
: DocumentVisibility.EVERYONE,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Folder updated successfully`),
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Folder not found`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Folder Settings</DialogTitle>
|
||||||
|
<DialogDescription>Manage the settings for this folder.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isTeamContext && !isTemplateFolder && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="visibility"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Visibility</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select visibility" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
|
||||||
|
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||||
|
Managers and above
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit">Save Changes</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,213 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||||
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export type TemplateMoveToFolderDialogProps = {
|
||||||
|
templateId: number;
|
||||||
|
templateTitle: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
currentFolderId?: string | null;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZMoveTemplateFormSchema = z.object({
|
||||||
|
folderId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TMoveTemplateFormSchema = z.infer<typeof ZMoveTemplateFormSchema>;
|
||||||
|
|
||||||
|
export function TemplateMoveToFolderDialog({
|
||||||
|
templateId,
|
||||||
|
templateTitle,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
currentFolderId,
|
||||||
|
...props
|
||||||
|
}: TemplateMoveToFolderDialogProps) {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const form = useForm<TMoveTemplateFormSchema>({
|
||||||
|
resolver: zodResolver(ZMoveTemplateFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
folderId: currentFolderId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
||||||
|
{
|
||||||
|
parentId: currentFolderId ?? null,
|
||||||
|
type: FolderType.TEMPLATE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: isOpen,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
form.reset();
|
||||||
|
} else {
|
||||||
|
form.reset({ folderId: currentFolderId ?? null });
|
||||||
|
}
|
||||||
|
}, [isOpen, currentFolderId, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
||||||
|
try {
|
||||||
|
await moveTemplateToFolder({
|
||||||
|
templateId,
|
||||||
|
folderId: data.folderId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Template moved`),
|
||||||
|
description: _(msg`The template has been moved successfully.`),
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
const templatesPath = formatTemplatesPath(team?.url);
|
||||||
|
|
||||||
|
if (data.folderId) {
|
||||||
|
void navigate(`${templatesPath}/f/${data.folderId}`);
|
||||||
|
} else {
|
||||||
|
void navigate(templatesPath);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: _(msg`The folder you are trying to move the template to does not exist.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: _(msg`An error occurred while moving the template.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Move Template to Folder</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Move "{templateTitle}" to a folder</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="folderId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Folder</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{isFoldersLoading ? (
|
||||||
|
<div className="flex h-10 items-center justify-center">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={field.value === null ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => field.onChange(null)}
|
||||||
|
disabled={currentFolderId === null}
|
||||||
|
>
|
||||||
|
<HomeIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Root (No Folder)</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{folders?.data?.map((folder) => (
|
||||||
|
<Button
|
||||||
|
key={folder.id}
|
||||||
|
type="button"
|
||||||
|
variant={field.value === folder.id ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => field.onChange(folder.id)}
|
||||||
|
disabled={currentFolderId === folder.id}
|
||||||
|
>
|
||||||
|
<FolderIcon className="mr-2 h-4 w-4" />
|
||||||
|
{folder.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isFoldersLoading || form.formState.isSubmitting}>
|
||||||
|
<Trans>Move</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,355 @@
|
|||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentDistributionMethod } from '@prisma/client';
|
||||||
|
import { InfoIcon } from 'lucide-react';
|
||||||
|
import type { Control } from 'react-hook-form';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||||
|
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
|
||||||
|
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||||
|
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
||||||
|
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||||
|
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
import { useConfigureDocument } from './configure-document-context';
|
||||||
|
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||||
|
|
||||||
|
interface ConfigureDocumentAdvancedSettingsProps {
|
||||||
|
control: Control<TConfigureEmbedFormSchema>;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigureDocumentAdvancedSettings = ({
|
||||||
|
control,
|
||||||
|
isSubmitting,
|
||||||
|
}: ConfigureDocumentAdvancedSettingsProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const form = useFormContext<TConfigureEmbedFormSchema>();
|
||||||
|
const { features } = useConfigureDocument();
|
||||||
|
|
||||||
|
const { watch, setValue } = form;
|
||||||
|
|
||||||
|
// Lift watch() calls to reduce re-renders
|
||||||
|
const distributionMethod = watch('meta.distributionMethod');
|
||||||
|
const emailSettings = watch('meta.emailSettings');
|
||||||
|
const isEmailDistribution = distributionMethod === DocumentDistributionMethod.EMAIL;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground mb-1 text-lg font-medium">
|
||||||
|
<Trans>Advanced Settings</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-6 text-sm">
|
||||||
|
<Trans>Configure additional options and preferences</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Tabs defaultValue="general">
|
||||||
|
<TabsList className="mb-6 inline-flex">
|
||||||
|
<TabsTrigger value="general" className="px-4">
|
||||||
|
<Trans>General</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
{features.allowConfigureCommunication && (
|
||||||
|
<TabsTrigger value="communication" className="px-4">
|
||||||
|
<Trans>Communication</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="general" className="mt-0">
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
{/* <FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.externalId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
<Trans>External ID</Trans>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||||
|
<Trans>
|
||||||
|
Add an external ID to the document. This can be used to identify the
|
||||||
|
document in external systems.
|
||||||
|
</Trans>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} disabled={isSubmitting} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
{features.allowConfigureSignatureTypes && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.signatureTypes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Allowed Signature Types</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
||||||
|
label: _(option.label),
|
||||||
|
value: option.value,
|
||||||
|
}))}
|
||||||
|
selectedValues={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
className="bg-background w-full"
|
||||||
|
emptySelectionPlaceholder="Select signature types"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.allowConfigureLanguage && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.language"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Language</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
|
||||||
|
<SelectItem key={code} value={code}>
|
||||||
|
{language.full}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.allowConfigureDateFormat && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.dateFormat"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Date Format</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DATE_FORMATS.map((format) => (
|
||||||
|
<SelectItem key={format.key} value={format.value}>
|
||||||
|
{format.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.allowConfigureTimezone && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.timezone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Time Zone</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Combobox
|
||||||
|
className="bg-background"
|
||||||
|
options={TIME_ZONES}
|
||||||
|
{...field}
|
||||||
|
onChange={(value) => value && field.onChange(value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.allowConfigureRedirectUrl && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.redirectUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
<Trans>Redirect URL</Trans>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||||
|
<Trans>
|
||||||
|
Add a URL to redirect the user to once the document is signed
|
||||||
|
</Trans>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} disabled={isSubmitting} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{features.allowConfigureCommunication && (
|
||||||
|
<TabsContent value="communication" className="mt-0">
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.distributionMethod"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Distribution Method</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={DocumentDistributionMethod.EMAIL}>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={DocumentDistributionMethod.NONE}>
|
||||||
|
<Trans>None</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
Choose how to distribute your document to recipients. Email will send
|
||||||
|
notifications, None will generate signing links for manual distribution.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
className="flex flex-col space-y-6 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={!isEmailDistribution}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.subject"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="subject">
|
||||||
|
<Trans>
|
||||||
|
Subject <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
disabled={isSubmitting || !isEmailDistribution}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.message"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="message">
|
||||||
|
<Trans>
|
||||||
|
Message <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
id="message"
|
||||||
|
className="bg-background mt-2 h-32 resize-none"
|
||||||
|
disabled={isSubmitting || !isEmailDistribution}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentSendEmailMessageHelper />
|
||||||
|
|
||||||
|
<DocumentEmailCheckboxes
|
||||||
|
className={`mt-2 ${!isEmailDistribution ? 'pointer-events-none' : ''}`}
|
||||||
|
value={emailSettings}
|
||||||
|
onChange={(value) => setValue('meta.emailSettings', value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export type ConfigureDocumentContext = {
|
||||||
|
// General
|
||||||
|
isTemplate: boolean;
|
||||||
|
isPersisted: boolean;
|
||||||
|
// Features
|
||||||
|
features: {
|
||||||
|
allowConfigureSignatureTypes?: boolean;
|
||||||
|
allowConfigureLanguage?: boolean;
|
||||||
|
allowConfigureDateFormat?: boolean;
|
||||||
|
allowConfigureTimezone?: boolean;
|
||||||
|
allowConfigureRedirectUrl?: boolean;
|
||||||
|
allowConfigureCommunication?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConfigureDocumentContext = createContext<ConfigureDocumentContext | null>(null);
|
||||||
|
|
||||||
|
export type ConfigureDocumentProviderProps = {
|
||||||
|
isTemplate?: boolean;
|
||||||
|
isPersisted?: boolean;
|
||||||
|
features: {
|
||||||
|
allowConfigureSignatureTypes?: boolean;
|
||||||
|
allowConfigureLanguage?: boolean;
|
||||||
|
allowConfigureDateFormat?: boolean;
|
||||||
|
allowConfigureTimezone?: boolean;
|
||||||
|
allowConfigureRedirectUrl?: boolean;
|
||||||
|
allowConfigureCommunication?: boolean;
|
||||||
|
};
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConfigureDocumentProvider = ({
|
||||||
|
isTemplate,
|
||||||
|
isPersisted,
|
||||||
|
features,
|
||||||
|
children,
|
||||||
|
}: ConfigureDocumentProviderProps) => {
|
||||||
|
return (
|
||||||
|
<ConfigureDocumentContext.Provider
|
||||||
|
value={{
|
||||||
|
isTemplate: isTemplate ?? false,
|
||||||
|
isPersisted: isPersisted ?? false,
|
||||||
|
features: {
|
||||||
|
allowConfigureSignatureTypes: features.allowConfigureSignatureTypes ?? true,
|
||||||
|
allowConfigureLanguage: features.allowConfigureLanguage ?? true,
|
||||||
|
allowConfigureDateFormat: features.allowConfigureDateFormat ?? true,
|
||||||
|
allowConfigureTimezone: features.allowConfigureTimezone ?? true,
|
||||||
|
allowConfigureRedirectUrl: features.allowConfigureRedirectUrl ?? true,
|
||||||
|
allowConfigureCommunication: features.allowConfigureCommunication ?? true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ConfigureDocumentContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConfigureDocument = () => {
|
||||||
|
const context = useContext(ConfigureDocumentContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useConfigureDocument must be used within a ConfigureDocumentProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@ -0,0 +1,413 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
|
||||||
|
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { GripVertical, HelpCircle, Plus, Trash } from 'lucide-react';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { Control } from 'react-hook-form';
|
||||||
|
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
import { useConfigureDocument } from './configure-document-context';
|
||||||
|
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||||
|
|
||||||
|
// Define a type for signer items
|
||||||
|
type SignerItem = TConfigureEmbedFormSchema['signers'][number];
|
||||||
|
|
||||||
|
export interface ConfigureDocumentRecipientsProps {
|
||||||
|
control: Control<TConfigureEmbedFormSchema>;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigureDocumentRecipients = ({
|
||||||
|
control,
|
||||||
|
isSubmitting,
|
||||||
|
}: ConfigureDocumentRecipientsProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { isTemplate } = useConfigureDocument();
|
||||||
|
|
||||||
|
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
fields: signers,
|
||||||
|
append: appendSigner,
|
||||||
|
remove: removeSigner,
|
||||||
|
replace,
|
||||||
|
move,
|
||||||
|
} = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: 'signers',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getValues, watch, setValue } = useFormContext<TConfigureEmbedFormSchema>();
|
||||||
|
|
||||||
|
const signingOrder = watch('meta.signingOrder');
|
||||||
|
|
||||||
|
const { errors } = useFormState({
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onAddSigner = useCallback(() => {
|
||||||
|
const signerNumber = signers.length + 1;
|
||||||
|
const recipientSigningOrder =
|
||||||
|
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1;
|
||||||
|
|
||||||
|
appendSigner({
|
||||||
|
formId: nanoid(8),
|
||||||
|
name: isTemplate ? `Recipient ${signerNumber}` : '',
|
||||||
|
email: isTemplate ? `recipient.${signerNumber}@document.com` : '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
signingOrder:
|
||||||
|
signingOrder === DocumentSigningOrder.SEQUENTIAL ? recipientSigningOrder : undefined,
|
||||||
|
});
|
||||||
|
}, [appendSigner, signers]);
|
||||||
|
|
||||||
|
const isSigningOrderEnabled = signingOrder === DocumentSigningOrder.SEQUENTIAL;
|
||||||
|
|
||||||
|
const handleSigningOrderChange = useCallback(
|
||||||
|
(index: number, newOrderString: string) => {
|
||||||
|
const trimmedOrderString = newOrderString.trim();
|
||||||
|
if (!trimmedOrderString) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOrder = Number(trimmedOrderString);
|
||||||
|
if (!Number.isInteger(newOrder) || newOrder < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current form values to preserve unsaved input data
|
||||||
|
const currentSigners = getValues('signers') || [...signers];
|
||||||
|
const signer = currentSigners[index];
|
||||||
|
|
||||||
|
// Remove signer from current position and insert at new position
|
||||||
|
const remainingSigners = currentSigners.filter((_: unknown, idx: number) => idx !== index);
|
||||||
|
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
|
||||||
|
remainingSigners.splice(newPosition, 0, signer);
|
||||||
|
|
||||||
|
// Update signing order for each item
|
||||||
|
const updatedSigners = remainingSigners.map((s: SignerItem, idx: number) => ({
|
||||||
|
...s,
|
||||||
|
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? idx + 1 : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update the form
|
||||||
|
replace(updatedSigners);
|
||||||
|
},
|
||||||
|
[signers, replace, getValues],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragEnd = useCallback(
|
||||||
|
(result: DropResult) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
|
||||||
|
// Use the move function from useFieldArray which preserves input values
|
||||||
|
move(result.source.index, result.destination.index);
|
||||||
|
|
||||||
|
// Update signing orders after move
|
||||||
|
const currentSigners = getValues('signers');
|
||||||
|
const updatedSigners = currentSigners.map((signer: SignerItem, index: number) => ({
|
||||||
|
...signer,
|
||||||
|
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? index + 1 : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update the form with new ordering
|
||||||
|
replace(updatedSigners);
|
||||||
|
},
|
||||||
|
[move, replace, getValues],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSigningOrderChange = (signingOrder: DocumentSigningOrder) => {
|
||||||
|
setValue('meta.signingOrder', signingOrder);
|
||||||
|
|
||||||
|
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||||
|
signers.forEach((_signer, index) => {
|
||||||
|
setValue(`signers.${index}.signingOrder`, index + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground mb-1 text-lg font-medium">
|
||||||
|
<Trans>Recipients</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-6 text-sm">
|
||||||
|
<Trans>Add signers and configure signing preferences</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.signingOrder"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
{...field}
|
||||||
|
id="signingOrder"
|
||||||
|
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onSigningOrderChange(
|
||||||
|
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel
|
||||||
|
htmlFor="signingOrder"
|
||||||
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<Trans>Enable signing order</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.allowDictateNextSigner"
|
||||||
|
render={({ field: { value, ...field } }) => (
|
||||||
|
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
{...field}
|
||||||
|
id="allowDictateNextSigner"
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={isSubmitting || !isSigningOrderEnabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FormLabel
|
||||||
|
htmlFor="allowDictateNextSigner"
|
||||||
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<Trans>Allow signers to dictate next signer</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="text-muted-foreground ml-1 cursor-help">
|
||||||
|
<HelpCircle className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-80 p-4">
|
||||||
|
<p>
|
||||||
|
<Trans>
|
||||||
|
When enabled, signers can choose who should sign next in the sequence instead
|
||||||
|
of following the predefined order.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DragDropContext
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
sensors={[
|
||||||
|
(api: SensorAPI) => {
|
||||||
|
$sensorApi.current = api;
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Droppable droppableId="signers">
|
||||||
|
{(provided) => (
|
||||||
|
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
|
||||||
|
{signers.map((signer, index) => (
|
||||||
|
<Draggable
|
||||||
|
key={signer.id}
|
||||||
|
draggableId={signer.id}
|
||||||
|
index={index}
|
||||||
|
isDragDisabled={!isSigningOrderEnabled || isSubmitting || signer.disabled}
|
||||||
|
>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<fieldset
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
disabled={signer.disabled}
|
||||||
|
className={cn('py-1', {
|
||||||
|
'bg-widget-foreground pointer-events-none rounded-md pt-2':
|
||||||
|
snapshot.isDragging,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className={cn('flex items-end gap-2 pb-2', {
|
||||||
|
'border-destructive/50': errors?.signers?.[index],
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isSigningOrderEnabled && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`signers.${index}.signingOrder`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('flex w-16 flex-none items-center gap-x-1', {
|
||||||
|
'mb-6':
|
||||||
|
errors?.signers?.[index] &&
|
||||||
|
!errors?.signers?.[index]?.signingOrder,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-5 w-5 flex-shrink-0 opacity-40" />
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
max={signers.length}
|
||||||
|
min={1}
|
||||||
|
className="w-full text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
|
{...field}
|
||||||
|
disabled={isSubmitting || snapshot.isDragging}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onBlur();
|
||||||
|
handleSigningOrderChange(index, e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`signers.${index}.name`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('flex-1', {
|
||||||
|
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.name,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormLabel className="sr-only">
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={_(msg`Name`)}
|
||||||
|
className="w-full"
|
||||||
|
{...field}
|
||||||
|
disabled={isSubmitting || snapshot.isDragging}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`signers.${index}.email`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('flex-1', {
|
||||||
|
'mb-6':
|
||||||
|
errors?.signers?.[index] && !errors?.signers?.[index]?.email,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormLabel className="sr-only">
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder={_(msg`Email`)}
|
||||||
|
className="w-full"
|
||||||
|
{...field}
|
||||||
|
disabled={isSubmitting || snapshot.isDragging}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`signers.${index}.role`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('flex-none', {
|
||||||
|
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.role,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormLabel className="sr-only">
|
||||||
|
<Trans>Role</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RecipientRoleSelect
|
||||||
|
{...field}
|
||||||
|
isAssistantEnabled={isSigningOrderEnabled}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={isSubmitting || snapshot.isDragging || signer.disabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
signers.length === 1 ||
|
||||||
|
snapshot.isDragging ||
|
||||||
|
signer.disabled
|
||||||
|
}
|
||||||
|
onClick={() => removeSigner(index)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-auto"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onAddSigner}
|
||||||
|
>
|
||||||
|
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||||
|
<Trans>Add Signer</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,238 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Cloud, FileText, Loader, X } from 'lucide-react';
|
||||||
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useConfigureDocument } from './configure-document-context';
|
||||||
|
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||||
|
|
||||||
|
export interface ConfigureDocumentUploadProps {
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocumentUploadProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { isPersisted } = useConfigureDocument();
|
||||||
|
|
||||||
|
const form = useFormContext<TConfigureEmbedFormSchema>();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Watch the documentData field from the form
|
||||||
|
const documentData = form.watch('documentData');
|
||||||
|
|
||||||
|
const onFileDrop = async (acceptedFiles: File[]) => {
|
||||||
|
try {
|
||||||
|
const file = acceptedFiles[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Convert file to UInt8Array
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
|
// Store file metadata and UInt8Array in form data
|
||||||
|
form.setValue('documentData', {
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
data: uint8Array, // Store as UInt8Array
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-populate title if it's empty
|
||||||
|
const currentTitle = form.getValues('title');
|
||||||
|
|
||||||
|
if (!currentTitle) {
|
||||||
|
// Get filename without extension
|
||||||
|
const fileNameWithoutExtension = file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
form.setValue('title', fileNameWithoutExtension);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading file', error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error uploading file`),
|
||||||
|
description: _(msg`There was an error uploading your file. Please try again.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDropRejected = () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Your document failed to upload.`),
|
||||||
|
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemoveFile = () => {
|
||||||
|
if (isPersisted) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Cannot remove document`),
|
||||||
|
description: _(msg`The document is already saved and cannot be changed.`),
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.unregister('documentData');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
accept: {
|
||||||
|
'application/pdf': ['.pdf'],
|
||||||
|
},
|
||||||
|
maxSize: APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024,
|
||||||
|
multiple: false,
|
||||||
|
disabled: isSubmitting || isLoading || isPersisted,
|
||||||
|
onDrop: (files) => {
|
||||||
|
void onFileDrop(files);
|
||||||
|
},
|
||||||
|
onDropRejected,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="documentData"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Upload Document</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{!documentData ? (
|
||||||
|
<div className="relative">
|
||||||
|
<FormControl>
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={cn(
|
||||||
|
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
|
||||||
|
{
|
||||||
|
'border-primary/50 bg-primary/5': isDragActive,
|
||||||
|
'hover:bg-muted/30':
|
||||||
|
!isDragActive && !isSubmitting && !isLoading && !isPersisted,
|
||||||
|
'cursor-not-allowed opacity-60': isSubmitting || isLoading || isPersisted,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center gap-y-2 px-4 py-4 text-center">
|
||||||
|
<Cloud
|
||||||
|
className={cn('h-10 w-10', {
|
||||||
|
'text-primary': isDragActive,
|
||||||
|
'text-muted-foreground': !isDragActive,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col space-y-1', {
|
||||||
|
'text-primary': isDragActive,
|
||||||
|
'text-muted-foreground': !isDragActive,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{isDragActive ? (
|
||||||
|
<Trans>Drop your document here</Trans>
|
||||||
|
) : isPersisted ? (
|
||||||
|
<Trans>Document is already uploaded</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Drag and drop or click to upload</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
{isPersisted ? (
|
||||||
|
<Trans>This document cannot be changed</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
.PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||||
|
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 rounded-lg border p-4">
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
|
||||||
|
<FileText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium">{documentData.name}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{formatFileSize(documentData.size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isPersisted && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRemoveFile}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentDistributionMethod, DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
|
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 { ConfigureDocumentAdvancedSettings } from './configure-document-advanced-settings';
|
||||||
|
import { useConfigureDocument } from './configure-document-context';
|
||||||
|
import { ConfigureDocumentRecipients } from './configure-document-recipients';
|
||||||
|
import { ConfigureDocumentUpload } from './configure-document-upload';
|
||||||
|
import {
|
||||||
|
type TConfigureEmbedFormSchema,
|
||||||
|
ZConfigureEmbedFormSchema,
|
||||||
|
} from './configure-document-view.types';
|
||||||
|
|
||||||
|
export interface ConfigureDocumentViewProps {
|
||||||
|
onSubmit: (data: TConfigureEmbedFormSchema) => void | Promise<void>;
|
||||||
|
defaultValues?: Partial<TConfigureEmbedFormSchema>;
|
||||||
|
disableUpload?: boolean;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigureDocumentView = ({
|
||||||
|
onSubmit,
|
||||||
|
defaultValues,
|
||||||
|
disableUpload,
|
||||||
|
}: ConfigureDocumentViewProps) => {
|
||||||
|
const { isTemplate } = useConfigureDocument();
|
||||||
|
|
||||||
|
const form = useForm<TConfigureEmbedFormSchema>({
|
||||||
|
resolver: zodResolver(ZConfigureEmbedFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: defaultValues?.title || '',
|
||||||
|
signers: defaultValues?.signers || [
|
||||||
|
{
|
||||||
|
formId: nanoid(8),
|
||||||
|
name: isTemplate ? `Recipient ${1}` : '',
|
||||||
|
email: isTemplate ? `recipient.${1}@document.com` : '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
signingOrder: 1,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
subject: defaultValues?.meta?.subject || '',
|
||||||
|
message: defaultValues?.meta?.message || '',
|
||||||
|
distributionMethod:
|
||||||
|
defaultValues?.meta?.distributionMethod || DocumentDistributionMethod.EMAIL,
|
||||||
|
emailSettings: defaultValues?.meta?.emailSettings || ZDocumentEmailSettingsSchema.parse({}),
|
||||||
|
dateFormat: defaultValues?.meta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
timezone: defaultValues?.meta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE,
|
||||||
|
redirectUrl: defaultValues?.meta?.redirectUrl || '',
|
||||||
|
language: defaultValues?.meta?.language || 'en',
|
||||||
|
signatureTypes: defaultValues?.meta?.signatureTypes || [],
|
||||||
|
signingOrder: defaultValues?.meta?.signingOrder || DocumentSigningOrder.PARALLEL,
|
||||||
|
allowDictateNextSigner: defaultValues?.meta?.allowDictateNextSigner || false,
|
||||||
|
externalId: defaultValues?.meta?.externalId || '',
|
||||||
|
},
|
||||||
|
documentData: defaultValues?.documentData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control, handleSubmit } = form;
|
||||||
|
|
||||||
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
|
|
||||||
|
const onFormSubmit = handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-foreground mb-1 text-xl font-semibold">
|
||||||
|
{isTemplate ? <Trans>Configure Template</Trans> : <Trans>Configure Document</Trans>}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{isTemplate ? (
|
||||||
|
<Trans>Set up your template properties and recipient information</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Set up your document properties and recipient information</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<div className="flex flex-col space-y-8">
|
||||||
|
<div>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Title</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} disabled={isSubmitting} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!disableUpload && <ConfigureDocumentUpload isSubmitting={isSubmitting} />}
|
||||||
|
<ConfigureDocumentRecipients control={control} isSubmitting={isSubmitting} />
|
||||||
|
<ConfigureDocumentAdvancedSettings control={control} isSubmitting={isSubmitting} />
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onFormSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
|
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||||
|
import {
|
||||||
|
ZDocumentMetaDateFormatSchema,
|
||||||
|
ZDocumentMetaLanguageSchema,
|
||||||
|
} from '@documenso/trpc/server/document-router/schema';
|
||||||
|
|
||||||
|
// Define the schema for configuration
|
||||||
|
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
|
||||||
|
|
||||||
|
export const ZConfigureEmbedFormSchema = z.object({
|
||||||
|
title: z.string().min(1, { message: 'Title is required' }),
|
||||||
|
signers: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
nativeId: z.number().optional(),
|
||||||
|
formId: z.string(),
|
||||||
|
name: z.string().min(1, { message: 'Name is required' }),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
|
||||||
|
signingOrder: z.number().optional(),
|
||||||
|
disabled: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, { message: 'At least one signer is required' }),
|
||||||
|
meta: z.object({
|
||||||
|
subject: z.string().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||||
|
emailSettings: ZDocumentEmailSettingsSchema,
|
||||||
|
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||||
|
timezone: z.string().min(1, 'Timezone is required'),
|
||||||
|
redirectUrl: z.string().optional(),
|
||||||
|
language: ZDocumentMetaLanguageSchema.optional(),
|
||||||
|
signatureTypes: z.array(z.string()).default([]),
|
||||||
|
signingOrder: z.enum(['SEQUENTIAL', 'PARALLEL']),
|
||||||
|
allowDictateNextSigner: z.boolean().default(false).optional(),
|
||||||
|
externalId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
documentData: z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
size: z.number(),
|
||||||
|
data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
@ -0,0 +1,672 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { DocumentData, FieldType } from '@prisma/client';
|
||||||
|
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||||
|
import { ChevronsUpDown } from 'lucide-react';
|
||||||
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
|
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||||
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
|
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
|
||||||
|
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
|
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
|
||||||
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
|
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||||
|
import type { TConfigureFieldsFormSchema } from './configure-fields-view.types';
|
||||||
|
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
|
||||||
|
|
||||||
|
const MIN_HEIGHT_PX = 12;
|
||||||
|
const MIN_WIDTH_PX = 36;
|
||||||
|
|
||||||
|
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
|
||||||
|
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
||||||
|
|
||||||
|
export type ConfigureFieldsViewProps = {
|
||||||
|
configData: TConfigureEmbedFormSchema;
|
||||||
|
documentData?: DocumentData;
|
||||||
|
defaultValues?: Partial<TConfigureFieldsFormSchema>;
|
||||||
|
onBack: (data: TConfigureFieldsFormSchema) => void;
|
||||||
|
onSubmit: (data: TConfigureFieldsFormSchema) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConfigureFieldsView = ({
|
||||||
|
configData,
|
||||||
|
documentData,
|
||||||
|
defaultValues,
|
||||||
|
onBack,
|
||||||
|
onSubmit,
|
||||||
|
}: ConfigureFieldsViewProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
||||||
|
|
||||||
|
// Track if we're on a mobile device
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
// State for managing the mobile drawer
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
// Check for mobile viewport on component mount and resize
|
||||||
|
useEffect(() => {
|
||||||
|
const checkIfMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
checkIfMobile();
|
||||||
|
|
||||||
|
// Add resize listener
|
||||||
|
window.addEventListener('resize', checkIfMobile);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkIfMobile);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const normalizedDocumentData = useMemo(() => {
|
||||||
|
if (documentData) {
|
||||||
|
return documentData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!configData.documentData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = base64.encode(configData.documentData?.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'preview',
|
||||||
|
type: 'BYTES_64',
|
||||||
|
data,
|
||||||
|
initialData: data,
|
||||||
|
} satisfies DocumentData;
|
||||||
|
}, [configData.documentData]);
|
||||||
|
|
||||||
|
const recipients = useMemo(() => {
|
||||||
|
return configData.signers.map<Recipient>((signer, index) => ({
|
||||||
|
id: signer.nativeId || index,
|
||||||
|
name: signer.name || '',
|
||||||
|
email: signer.email || '',
|
||||||
|
role: signer.role,
|
||||||
|
signingOrder: signer.signingOrder || null,
|
||||||
|
documentId: null,
|
||||||
|
templateId: null,
|
||||||
|
token: '',
|
||||||
|
documentDeletedAt: null,
|
||||||
|
expired: null,
|
||||||
|
signedAt: null,
|
||||||
|
authOptions: null,
|
||||||
|
rejectionReason: null,
|
||||||
|
sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||||
|
readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
|
||||||
|
signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
|
}));
|
||||||
|
}, [configData.signers]);
|
||||||
|
|
||||||
|
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
|
||||||
|
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
|
||||||
|
);
|
||||||
|
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||||
|
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
||||||
|
const [coords, setCoords] = useState({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const [activeFieldId, setActiveFieldId] = useState<string | null>(null);
|
||||||
|
const [lastActiveField, setLastActiveField] = useState<
|
||||||
|
TConfigureFieldsFormSchema['fields'][0] | null
|
||||||
|
>(null);
|
||||||
|
const [fieldClipboard, setFieldClipboard] = useState<
|
||||||
|
TConfigureFieldsFormSchema['fields'][0] | null
|
||||||
|
>(null);
|
||||||
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||||
|
const [currentField, setCurrentField] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldBounds = useRef({
|
||||||
|
height: DEFAULT_HEIGHT_PX,
|
||||||
|
width: DEFAULT_WIDTH_PX,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
|
||||||
|
const selectedRecipientStyles = useRecipientColors(
|
||||||
|
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<TConfigureFieldsFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
fields: defaultValues?.fields ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control, handleSubmit } = form;
|
||||||
|
|
||||||
|
const onFormSubmit = handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
const {
|
||||||
|
append,
|
||||||
|
remove,
|
||||||
|
update,
|
||||||
|
fields: localFields,
|
||||||
|
} = useFieldArray({
|
||||||
|
control: control,
|
||||||
|
name: 'fields',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFieldCopy = useCallback(
|
||||||
|
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||||
|
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||||
|
|
||||||
|
if (lastActiveField) {
|
||||||
|
event?.preventDefault();
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||||
|
...structuredClone(lastActiveField),
|
||||||
|
nativeId: undefined,
|
||||||
|
formId: nanoid(12),
|
||||||
|
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
||||||
|
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
||||||
|
pageX: lastActiveField.pageX + 3,
|
||||||
|
pageY: lastActiveField.pageY + 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
append(newField);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateAll) {
|
||||||
|
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||||
|
|
||||||
|
pages.forEach((_, index) => {
|
||||||
|
const pageNumber = index + 1;
|
||||||
|
|
||||||
|
if (pageNumber === lastActiveField.pageNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||||
|
...structuredClone(lastActiveField),
|
||||||
|
nativeId: undefined,
|
||||||
|
formId: nanoid(12),
|
||||||
|
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
||||||
|
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
||||||
|
pageNumber,
|
||||||
|
};
|
||||||
|
|
||||||
|
append(newField);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFieldClipboard(lastActiveField);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Copied field',
|
||||||
|
description: 'Copied field to clipboard',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFieldPaste = useCallback(
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
if (fieldClipboard) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const copiedField = structuredClone(fieldClipboard);
|
||||||
|
|
||||||
|
append({
|
||||||
|
...copiedField,
|
||||||
|
nativeId: undefined,
|
||||||
|
formId: nanoid(12),
|
||||||
|
signerEmail: selectedRecipient?.email ?? copiedField.signerEmail,
|
||||||
|
recipientId: selectedRecipient?.id ?? copiedField.recipientId,
|
||||||
|
pageX: copiedField.pageX + 3,
|
||||||
|
pageY: copiedField.pageY + 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[append, fieldClipboard, selectedRecipient?.email, selectedRecipient?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
||||||
|
useHotkeys(['ctrl+v', 'meta+v'], (evt) => onFieldPaste(evt));
|
||||||
|
useHotkeys(['ctrl+d', 'meta+d'], (evt) => onFieldCopy(evt, { duplicate: true }));
|
||||||
|
|
||||||
|
const onMouseMove = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
if (!selectedField) return;
|
||||||
|
|
||||||
|
setIsFieldWithinBounds(
|
||||||
|
isWithinPageBounds(
|
||||||
|
event,
|
||||||
|
PDF_VIEWER_PAGE_SELECTOR,
|
||||||
|
fieldBounds.current.width,
|
||||||
|
fieldBounds.current.height,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
setCoords({
|
||||||
|
x: event.clientX - fieldBounds.current.width / 2,
|
||||||
|
y: event.clientY - fieldBounds.current.height / 2,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isWithinPageBounds, selectedField],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onMouseClick = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
if (!selectedField || !selectedRecipient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!$page ||
|
||||||
|
!isWithinPageBounds(
|
||||||
|
event,
|
||||||
|
PDF_VIEWER_PAGE_SELECTOR,
|
||||||
|
fieldBounds.current.width,
|
||||||
|
fieldBounds.current.height,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { top, left, height, width } = getBoundingClientRect($page);
|
||||||
|
|
||||||
|
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||||
|
|
||||||
|
// Calculate x and y as a percentage of the page width and height
|
||||||
|
let pageX = ((event.pageX - left) / width) * 100;
|
||||||
|
let pageY = ((event.pageY - top) / height) * 100;
|
||||||
|
|
||||||
|
// Get the bounds as a percentage of the page width and height
|
||||||
|
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
|
||||||
|
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
|
||||||
|
|
||||||
|
// And center it based on the bounds
|
||||||
|
pageX -= fieldPageWidth / 2;
|
||||||
|
pageY -= fieldPageHeight / 2;
|
||||||
|
|
||||||
|
const field = {
|
||||||
|
formId: nanoid(12),
|
||||||
|
type: selectedField,
|
||||||
|
pageNumber,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
pageWidth: fieldPageWidth,
|
||||||
|
pageHeight: fieldPageHeight,
|
||||||
|
recipientId: selectedRecipient.id,
|
||||||
|
signerEmail: selectedRecipient.email,
|
||||||
|
fieldMeta: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
append(field);
|
||||||
|
|
||||||
|
// Automatically open advanced settings for field types that need configuration
|
||||||
|
if (ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING.includes(selectedField)) {
|
||||||
|
setCurrentField(field);
|
||||||
|
setShowAdvancedSettings(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedField(null);
|
||||||
|
},
|
||||||
|
[append, getPage, isWithinPageBounds, selectedField, selectedRecipient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFieldResize = useCallback(
|
||||||
|
(node: HTMLElement, index: number) => {
|
||||||
|
const field = localFields[index];
|
||||||
|
|
||||||
|
const $page = window.document.querySelector<HTMLElement>(
|
||||||
|
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
x: pageX,
|
||||||
|
y: pageY,
|
||||||
|
width: pageWidth,
|
||||||
|
height: pageHeight,
|
||||||
|
} = getFieldPosition($page, node);
|
||||||
|
|
||||||
|
update(index, {
|
||||||
|
...field,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getFieldPosition, localFields, update],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFieldMove = useCallback(
|
||||||
|
(node: HTMLElement, index: number) => {
|
||||||
|
const field = localFields[index];
|
||||||
|
|
||||||
|
const $page = window.document.querySelector<HTMLElement>(
|
||||||
|
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x: pageX, y: pageY } = getFieldPosition($page, node);
|
||||||
|
|
||||||
|
update(index, {
|
||||||
|
...field,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getFieldPosition, localFields, update],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdateFieldMeta = useCallback(
|
||||||
|
(formId: string, fieldMeta: TFieldMetaSchema) => {
|
||||||
|
const fieldIndex = localFields.findIndex((field) => field.formId === formId);
|
||||||
|
|
||||||
|
if (fieldIndex !== -1) {
|
||||||
|
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta);
|
||||||
|
|
||||||
|
update(fieldIndex, {
|
||||||
|
...localFields[fieldIndex],
|
||||||
|
fieldMeta: parsedFieldMeta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[localFields, update],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedField) {
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('mouseup', onMouseClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('mouseup', onMouseClick);
|
||||||
|
};
|
||||||
|
}, [onMouseClick, onMouseMove, selectedField]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver((_mutations) => {
|
||||||
|
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldBounds.current = {
|
||||||
|
height: Math.max(DEFAULT_HEIGHT_PX),
|
||||||
|
width: Math.max(DEFAULT_WIDTH_PX),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close drawer when a field is selected on mobile
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile && selectedField) {
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
}
|
||||||
|
}, [isMobile, selectedField]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid w-full grid-cols-12 gap-4">
|
||||||
|
{/* Desktop sidebar */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="order-2 col-span-12 md:order-1 md:col-span-4">
|
||||||
|
<div className="bg-widget border-border sticky top-4 max-h-[calc(100vh-2rem)] rounded-lg border p-4 pb-6">
|
||||||
|
<h2 className="mb-1 text-lg font-medium">
|
||||||
|
<Trans>Configure Fields</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-6 text-sm">
|
||||||
|
<Trans>Configure the fields you want to place on the document.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<RecipientSelector
|
||||||
|
selectedRecipient={selectedRecipient}
|
||||||
|
onSelectedRecipientChange={setSelectedRecipient}
|
||||||
|
recipients={recipients}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr className="my-6" />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FieldSelector
|
||||||
|
selectedField={selectedField}
|
||||||
|
onSelectedFieldChange={setSelectedField}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!selectedRecipient}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
onClick={() => onBack(form.getValues())}
|
||||||
|
>
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
type="button"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
onClick={async () => onFormSubmit()}
|
||||||
|
>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn('order-1 col-span-12 md:order-2', !isMobile && 'md:col-span-8')}>
|
||||||
|
<div className="relative">
|
||||||
|
{selectedField && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200 [container-type:size]',
|
||||||
|
selectedRecipientStyles.base,
|
||||||
|
{
|
||||||
|
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||||
|
'dark:text-black/60': isFieldWithinBounds,
|
||||||
|
},
|
||||||
|
selectedField === 'SIGNATURE' && 'font-signature',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: coords.y,
|
||||||
|
left: coords.x,
|
||||||
|
height: fieldBounds.current.height,
|
||||||
|
width: fieldBounds.current.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
|
||||||
|
{_(FRIENDLY_FIELD_TYPE[selectedField])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
{normalizedDocumentData && (
|
||||||
|
<div>
|
||||||
|
<PDFViewer documentData={normalizedDocumentData} />
|
||||||
|
|
||||||
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
|
{localFields.map((field, index) => {
|
||||||
|
const recipientIndex = recipients.findIndex(
|
||||||
|
(r) => r.id === field.recipientId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldItem
|
||||||
|
key={field.formId}
|
||||||
|
field={field}
|
||||||
|
minHeight={MIN_HEIGHT_PX}
|
||||||
|
minWidth={MIN_WIDTH_PX}
|
||||||
|
defaultHeight={DEFAULT_HEIGHT_PX}
|
||||||
|
defaultWidth={DEFAULT_WIDTH_PX}
|
||||||
|
onResize={(node) => onFieldResize(node, index)}
|
||||||
|
onMove={(node) => onFieldMove(node, index)}
|
||||||
|
onRemove={() => remove(index)}
|
||||||
|
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||||
|
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||||
|
onFocus={() => setLastActiveField(field)}
|
||||||
|
onBlur={() => setLastActiveField(null)}
|
||||||
|
onAdvancedSettings={() => {
|
||||||
|
setCurrentField(field);
|
||||||
|
setShowAdvancedSettings(true);
|
||||||
|
}}
|
||||||
|
recipientIndex={recipientIndex}
|
||||||
|
active={activeFieldId === field.formId}
|
||||||
|
onFieldActivate={() => setActiveFieldId(field.formId)}
|
||||||
|
onFieldDeactivate={() => setActiveFieldId(null)}
|
||||||
|
disabled={selectedRecipient?.id !== field.recipientId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ElementVisible>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Floating Action Bar and Drawer */}
|
||||||
|
{isMobile && (
|
||||||
|
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<div className="bg-widget border-border fixed bottom-6 left-6 right-6 z-50 flex items-center justify-between gap-2 rounded-lg border p-4">
|
||||||
|
<span className="text-lg font-medium">
|
||||||
|
<Trans>Configure Fields</Trans>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="border-border text-muted-foreground inline-flex h-10 w-10 items-center justify-center rounded-lg border"
|
||||||
|
>
|
||||||
|
<ChevronsUpDown className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SheetTrigger>
|
||||||
|
|
||||||
|
<SheetContent
|
||||||
|
position="bottom"
|
||||||
|
size="xl"
|
||||||
|
className="bg-widget h-fit max-h-[80vh] overflow-y-auto rounded-t-xl p-4"
|
||||||
|
>
|
||||||
|
<h2 className="mb-1 text-lg font-medium">
|
||||||
|
<Trans>Configure Fields</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-6 text-sm">
|
||||||
|
<Trans>Configure the fields you want to place on the document.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<RecipientSelector
|
||||||
|
selectedRecipient={selectedRecipient}
|
||||||
|
onSelectedRecipientChange={setSelectedRecipient}
|
||||||
|
recipients={recipients}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr className="my-6" />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FieldSelector
|
||||||
|
selectedField={selectedField}
|
||||||
|
onSelectedFieldChange={(field) => {
|
||||||
|
setSelectedField(field);
|
||||||
|
if (field) {
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!selectedRecipient}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
onClick={() => onBack(form.getValues())}
|
||||||
|
>
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
type="button"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
onClick={async () => onFormSubmit()}
|
||||||
|
>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldAdvancedSettingsDrawer
|
||||||
|
isOpen={showAdvancedSettings}
|
||||||
|
onOpenChange={setShowAdvancedSettings}
|
||||||
|
currentField={currentField}
|
||||||
|
fields={localFields}
|
||||||
|
onFieldUpdate={handleUpdateFieldMeta}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const ZConfigureFieldsFormSchema = z.object({
|
||||||
|
fields: z.array(
|
||||||
|
z.object({
|
||||||
|
nativeId: z.number().optional(),
|
||||||
|
formId: z.string().min(1),
|
||||||
|
type: z.nativeEnum(FieldType),
|
||||||
|
signerEmail: z.string().min(1),
|
||||||
|
inserted: z.boolean().optional(),
|
||||||
|
recipientId: z.number().min(0),
|
||||||
|
pageNumber: z.number().min(1),
|
||||||
|
pageX: z.number().min(0),
|
||||||
|
pageY: z.number().min(0),
|
||||||
|
pageWidth: z.number().min(0),
|
||||||
|
pageHeight: z.number().min(0),
|
||||||
|
fieldMeta: ZFieldMetaSchema.optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TConfigureFieldsFormSchema = z.infer<typeof ZConfigureFieldsFormSchema>;
|
||||||
|
|
||||||
|
export type TConfigureFieldsFormSchemaField = z.infer<
|
||||||
|
typeof ZConfigureFieldsFormSchema
|
||||||
|
>['fields'][number];
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
|
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
||||||
|
import { FieldAdvancedSettings } from '@documenso/ui/primitives/document-flow/field-item-advanced-settings';
|
||||||
|
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
|
||||||
|
|
||||||
|
import type { TConfigureFieldsFormSchemaField } from './configure-fields-view.types';
|
||||||
|
|
||||||
|
export type FieldAdvancedSettingsDrawerProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
currentField: TConfigureFieldsFormSchemaField | null;
|
||||||
|
fields: TConfigureFieldsFormSchemaField[];
|
||||||
|
onFieldUpdate: (formId: string, fieldMeta: FieldMeta) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FieldAdvancedSettingsDrawer = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
currentField,
|
||||||
|
fields,
|
||||||
|
onFieldUpdate,
|
||||||
|
}: FieldAdvancedSettingsDrawerProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
if (!currentField) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent position="right" size="lg" className="w-9/12 max-w-sm overflow-y-auto">
|
||||||
|
<SheetTitle className="sr-only">
|
||||||
|
{parseMessageDescriptor(
|
||||||
|
_,
|
||||||
|
msg`Configure ${parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])} Field`,
|
||||||
|
)}
|
||||||
|
</SheetTitle>
|
||||||
|
|
||||||
|
<FieldAdvancedSettings
|
||||||
|
title={msg`Advanced settings`}
|
||||||
|
description={msg`Configure the ${parseMessageDescriptor(
|
||||||
|
_,
|
||||||
|
FRIENDLY_FIELD_TYPE[currentField.type],
|
||||||
|
)} field`}
|
||||||
|
field={currentField}
|
||||||
|
fields={fields}
|
||||||
|
onAdvancedSettings={() => onOpenChange(false)}
|
||||||
|
onSave={(fieldMeta) => {
|
||||||
|
onFieldUpdate(currentField.formId, fieldMeta);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,7 +1,11 @@
|
|||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
export const EmbedClientLoading = () => {
|
export const EmbedClientLoading = () => {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
|
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
|
||||||
Loading...
|
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|
||||||
|
<span>Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,7 +10,6 @@ export type EmbedDocumentCompletedPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||||
console.log({ signature });
|
|
||||||
return (
|
return (
|
||||||
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
|
|||||||
@ -15,10 +15,12 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
|||||||
|
|
||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
@ -47,6 +49,7 @@ export type EmbedSignDocumentClientPageProps = {
|
|||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
completedFields: DocumentField[];
|
||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | TemplateMeta | null;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
@ -60,6 +63,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
documentData,
|
documentData,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
|
completedFields,
|
||||||
metadata,
|
metadata,
|
||||||
isCompleted,
|
isCompleted,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
@ -85,6 +89,8 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||||
|
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||||
|
|
||||||
@ -202,6 +208,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
// a to be provided by the parent application, unlike direct templates.
|
// a to be provided by the parent application, unlike direct templates.
|
||||||
setIsNameLocked(!!data.lockName);
|
setIsNameLocked(!!data.lockName);
|
||||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||||
|
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
|
||||||
|
|
||||||
if (data.darkModeDisabled) {
|
if (data.darkModeDisabled) {
|
||||||
document.documentElement.classList.add('dark-mode-disabled');
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
@ -468,6 +475,9 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
|
|
||||||
{/* Fields */}
|
{/* Fields */}
|
||||||
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
||||||
|
|
||||||
|
{/* Completed fields */}
|
||||||
|
<DocumentReadOnlyFields documentMeta={metadata || undefined} fields={completedFields} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hidePoweredBy && (
|
{!hidePoweredBy && (
|
||||||
|
|||||||
@ -44,6 +44,7 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
|
|||||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||||
includeSenderDetails: z.boolean(),
|
includeSenderDetails: z.boolean(),
|
||||||
includeSigningCertificate: z.boolean(),
|
includeSigningCertificate: z.boolean(),
|
||||||
|
includeAuditLog: z.boolean(),
|
||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
message: msg`At least one signature type must be enabled`.id,
|
||||||
}),
|
}),
|
||||||
@ -77,6 +78,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
: 'en',
|
: 'en',
|
||||||
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
||||||
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
||||||
|
includeAuditLog: settings?.includeAuditLog ?? false,
|
||||||
signatureTypes: extractTeamSignatureSettings(settings),
|
signatureTypes: extractTeamSignatureSettings(settings),
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
||||||
@ -91,6 +93,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
signatureTypes,
|
signatureTypes,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
@ -101,6 +104,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
@ -307,7 +311,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
Controls whether the signing certificate will be included in the document when
|
Controls whether the signing certificate will be included with the document when
|
||||||
it is downloaded. The signing certificate can still be downloaded from the logs
|
it is downloaded. The signing certificate can still be downloaded from the logs
|
||||||
page separately.
|
page separately.
|
||||||
</Trans>
|
</Trans>
|
||||||
@ -316,6 +320,36 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="includeAuditLog"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Include the Audit Log in the Document</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormControl className="block">
|
||||||
|
<Switch
|
||||||
|
ref={field.ref}
|
||||||
|
name={field.name}
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
Controls whether the audit log will be included with the document when it is
|
||||||
|
downloaded. The audit log can still be downloaded from the logs page separately.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
<div className="flex flex-row justify-end space-x-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Update</Trans>
|
<Trans>Update</Trans>
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import type { TTemplate } from '@documenso/lib/types/template';
|
import type { TTemplate } from '@documenso/lib/types/template';
|
||||||
|
import {
|
||||||
|
DocumentReadOnlyFields,
|
||||||
|
mapFieldsWithRecipients,
|
||||||
|
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import {
|
import {
|
||||||
DocumentFlowFormContainerActions,
|
DocumentFlowFormContainerActions,
|
||||||
DocumentFlowFormContainerContent,
|
DocumentFlowFormContainerContent,
|
||||||
@ -16,7 +20,6 @@ import {
|
|||||||
DocumentFlowFormContainerHeader,
|
DocumentFlowFormContainerHeader,
|
||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item';
|
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@ -97,14 +100,16 @@ export const DirectTemplateConfigureForm = ({
|
|||||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||||
|
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
{isDocumentPdfLoaded &&
|
{isDocumentPdfLoaded && (
|
||||||
directTemplateRecipient.fields.map((field, index) => (
|
<DocumentReadOnlyFields
|
||||||
<ShowFieldItem
|
fields={mapFieldsWithRecipients(
|
||||||
key={index}
|
directTemplateRecipient.fields,
|
||||||
field={field}
|
recipientsWithBlankDirectRecipientEmail,
|
||||||
recipients={recipientsWithBlankDirectRecipientEmail}
|
)}
|
||||||
/>
|
recipientIds={recipients.map((recipient) => recipient.id)}
|
||||||
))}
|
showRecipientColors={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<fieldset
|
<fieldset
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useNavigate, useSearchParams } from 'react-router';
|
|||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import type { TTemplate } from '@documenso/lib/types/template';
|
import type { TTemplate } from '@documenso/lib/types/template';
|
||||||
|
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
@ -103,17 +104,27 @@ export const DirectTemplatePageView = ({
|
|||||||
directRecipientEmail: recipient.email,
|
directRecipientEmail: recipient.email,
|
||||||
templateUpdatedAt: template.updatedAt,
|
templateUpdatedAt: template.updatedAt,
|
||||||
signedFieldValues: fields.map((field) => {
|
signedFieldValues: fields.map((field) => {
|
||||||
if (!field.signedValue) {
|
if (isRequiredField(field) && !field.signedValue) {
|
||||||
throw new Error('Invalid configuration');
|
throw new Error('Invalid configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
return field.signedValue;
|
return {
|
||||||
|
token: field.signedValue?.token ?? '',
|
||||||
|
fieldId: field.signedValue?.fieldId ?? 0,
|
||||||
|
value: field.signedValue?.value,
|
||||||
|
isBase64: field.signedValue?.isBase64,
|
||||||
|
authOptions: field.signedValue?.authOptions,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const redirectUrl = template.templateMeta?.redirectUrl;
|
const redirectUrl = template.templateMeta?.redirectUrl;
|
||||||
|
|
||||||
await (redirectUrl ? navigate(redirectUrl) : navigate(`/sign/${token}/complete`));
|
if (redirectUrl) {
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
} else {
|
||||||
|
await navigate(`/sign/${token}/complete`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
ZTextFieldMeta,
|
ZTextFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import type { TTemplate } from '@documenso/lib/types/template';
|
import type { TTemplate } from '@documenso/lib/types/template';
|
||||||
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type {
|
import type {
|
||||||
TRemovedSignedFieldWithTokenMutationSchema,
|
TRemovedSignedFieldWithTokenMutationSchema,
|
||||||
@ -78,6 +79,10 @@ export const DirectTemplateSigningForm = ({
|
|||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const fieldsRequiringValidation = useMemo(() => {
|
||||||
|
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
|
||||||
|
}, [localFields]);
|
||||||
|
|
||||||
const { currentStep, totalSteps, previousStep } = useStep();
|
const { currentStep, totalSteps, previousStep } = useStep();
|
||||||
|
|
||||||
const onSignField = (value: TSignFieldWithTokenMutationSchema) => {
|
const onSignField = (value: TSignFieldWithTokenMutationSchema) => {
|
||||||
@ -134,18 +139,18 @@ export const DirectTemplateSigningForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const uninsertedFields = useMemo(() => {
|
const uninsertedFields = useMemo(() => {
|
||||||
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
|
return sortFieldsByPosition(fieldsRequiringValidation);
|
||||||
}, [localFields]);
|
}, [localFields]);
|
||||||
|
|
||||||
const fieldsValidated = () => {
|
const fieldsValidated = () => {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
validateFieldsInserted(localFields);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(localFields);
|
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
|
||||||
if (!isFieldsValid) {
|
if (!isFieldsValid) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -97,6 +97,12 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
|
|
||||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
|
// Do nothing, this should only happen when the user clicks the field, but
|
||||||
|
// misses the checkbox which triggers this callback.
|
||||||
|
if (checkedValues.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isLengthConditionMet) {
|
if (!isLengthConditionMet) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -270,21 +276,26 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
{validationSign?.label} {checkboxValidationLength}
|
{validationSign?.label} {checkboxValidationLength}
|
||||||
</FieldToolTip>
|
</FieldToolTip>
|
||||||
)}
|
)}
|
||||||
<div className="z-50 flex flex-col gap-y-2">
|
<div className="z-50 my-0.5 flex flex-col gap-y-1">
|
||||||
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
||||||
const itemValue = item.value || `empty-value-${item.id}`;
|
const itemValue = item.value || `empty-value-${item.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center gap-x-1.5">
|
<div key={index} className="flex items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="h-4 w-4"
|
className="h-3 w-3"
|
||||||
id={`checkbox-${index}`}
|
id={`checkbox-${field.id}-${item.id}`}
|
||||||
checked={checkedValues.includes(itemValue)}
|
checked={checkedValues.includes(itemValue)}
|
||||||
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
|
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={`checkbox-${index}`}>
|
{!item.value.includes('empty-value-') && item.value && (
|
||||||
{item.value.includes('empty-value-') ? '' : item.value}
|
<Label
|
||||||
</Label>
|
htmlFor={`checkbox-${field.id}-${item.id}`}
|
||||||
|
className="text-foreground ml-1.5 text-xs font-normal"
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -293,22 +304,27 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<div className="flex flex-col gap-y-1">
|
<div className="my-0.5 flex flex-col gap-y-1">
|
||||||
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
||||||
const itemValue = item.value || `empty-value-${item.id}`;
|
const itemValue = item.value || `empty-value-${item.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center gap-x-1.5">
|
<div key={index} className="flex items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="h-3 w-3"
|
className="h-3 w-3"
|
||||||
id={`checkbox-${index}`}
|
id={`checkbox-${field.id}-${item.id}`}
|
||||||
checked={parsedCheckedValues.includes(itemValue)}
|
checked={parsedCheckedValues.includes(itemValue)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onCheckedChange={() => void handleCheckboxOptionClick(item)}
|
onCheckedChange={() => void handleCheckboxOptionClick(item)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={`checkbox-${index}`} className="text-xs">
|
{!item.value.includes('empty-value-') && item.value && (
|
||||||
{item.value.includes('empty-value-') ? '' : item.value}
|
<Label
|
||||||
</Label>
|
htmlFor={`checkbox-${field.id}-${item.id}`}
|
||||||
|
className="text-foreground ml-1.5 text-xs font-normal"
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Field } from '@prisma/client';
|
import type { Field } from '@prisma/client';
|
||||||
import { RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -14,6 +17,15 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
@ -22,11 +34,23 @@ export type DocumentSigningCompleteDialogProps = {
|
|||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
allowDictateNextSigner?: boolean;
|
||||||
|
defaultNextSigner?: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ZNextSignerFormSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||||
|
|
||||||
export const DocumentSigningCompleteDialog = ({
|
export const DocumentSigningCompleteDialog = ({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
documentTitle,
|
documentTitle,
|
||||||
@ -35,19 +59,54 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
role,
|
role,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
allowDictateNextSigner = false,
|
||||||
|
defaultNextSigner,
|
||||||
}: DocumentSigningCompleteDialogProps) => {
|
}: DocumentSigningCompleteDialogProps) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
|
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||||
|
defaultValues: {
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (isSubmitting || !isComplete) {
|
if (form.formState.isSubmitting || !isComplete) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
form.reset({
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEditingNextSigner(false);
|
||||||
setShowDialog(open);
|
setShowDialog(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||||
|
console.log('data', data);
|
||||||
|
console.log('form.formState.errors', form.formState.errors);
|
||||||
|
try {
|
||||||
|
if (allowDictateNextSigner && data.name && data.email) {
|
||||||
|
await onSignatureComplete({ name: data.name, email: data.email });
|
||||||
|
} else {
|
||||||
|
await onSignatureComplete();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error completing signature:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@ -71,110 +130,184 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogTitle>
|
<Form {...form}>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
{match(role)
|
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
<DialogTitle>
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
<div className="text-foreground text-xl font-semibold">
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
{match(role)
|
||||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||||
.exhaustive()}
|
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||||
</div>
|
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
||||||
</DialogTitle>
|
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
||||||
|
.exhaustive()}
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
{match(role)
|
{match(role)
|
||||||
.with(RecipientRole.VIEWER, () => (
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
<Trans>
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
You are about to complete viewing "
|
You are about to complete viewing "
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
{documentTitle}
|
{documentTitle}
|
||||||
|
</span>
|
||||||
|
".
|
||||||
|
</span>
|
||||||
|
<br /> Are you sure?
|
||||||
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
".
|
))
|
||||||
</span>
|
.with(RecipientRole.SIGNER, () => (
|
||||||
<br /> Are you sure?
|
<span>
|
||||||
</Trans>
|
<Trans>
|
||||||
</span>
|
<span className="inline-flex flex-wrap">
|
||||||
))
|
You are about to complete signing "
|
||||||
.with(RecipientRole.SIGNER, () => (
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
<span>
|
{documentTitle}
|
||||||
<Trans>
|
</span>
|
||||||
<span className="inline-flex flex-wrap">
|
".
|
||||||
You are about to complete signing "
|
</span>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<br /> Are you sure?
|
||||||
{documentTitle}
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
".
|
))
|
||||||
</span>
|
.with(RecipientRole.APPROVER, () => (
|
||||||
<br /> Are you sure?
|
<span>
|
||||||
</Trans>
|
<Trans>
|
||||||
</span>
|
<span className="inline-flex flex-wrap">
|
||||||
))
|
You are about to complete approving{' '}
|
||||||
.with(RecipientRole.APPROVER, () => (
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
<span>
|
"{documentTitle}"
|
||||||
<Trans>
|
</span>
|
||||||
<span className="inline-flex flex-wrap">
|
.
|
||||||
You are about to complete approving{' '}
|
</span>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<br /> Are you sure?
|
||||||
"{documentTitle}"
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
.
|
))
|
||||||
</span>
|
.otherwise(() => (
|
||||||
<br /> Are you sure?
|
<span>
|
||||||
</Trans>
|
<Trans>
|
||||||
</span>
|
<span className="inline-flex flex-wrap">
|
||||||
))
|
You are about to complete viewing "
|
||||||
.otherwise(() => (
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
<span>
|
{documentTitle}
|
||||||
<Trans>
|
</span>
|
||||||
<span className="inline-flex flex-wrap">
|
".
|
||||||
You are about to complete viewing "
|
</span>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<br /> Are you sure?
|
||||||
{documentTitle}
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
".
|
))}
|
||||||
</span>
|
</div>
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
{allowDictateNextSigner && (
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
{!isEditingNextSigner && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The next recipient to sign this document will be{' '}
|
||||||
|
<span className="font-semibold">{form.watch('name')}</span> (
|
||||||
|
<span className="font-semibold">{form.watch('email')}</span>).
|
||||||
|
</p>
|
||||||
|
|
||||||
<DialogFooter>
|
<Button
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
type="button"
|
||||||
<Button
|
className="mt-2"
|
||||||
type="button"
|
variant="outline"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
size="sm"
|
||||||
variant="secondary"
|
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||||
onClick={() => {
|
>
|
||||||
setShowDialog(false);
|
<Trans>Update Recipient</Trans>
|
||||||
}}
|
</Button>
|
||||||
>
|
</div>
|
||||||
<Trans>Cancel</Trans>
|
)}
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
{isEditingNextSigner && (
|
||||||
type="button"
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
className="flex-1"
|
<FormField
|
||||||
disabled={!isComplete}
|
control={form.control}
|
||||||
loading={isSubmitting}
|
name="name"
|
||||||
onClick={onSignatureComplete}
|
render={({ field }) => (
|
||||||
>
|
<FormItem className="flex-1">
|
||||||
{match(role)
|
<FormLabel>
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
<Trans>Name</Trans>
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
</FormLabel>
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
<FormControl>
|
||||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
<Input
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
{...field}
|
||||||
.exhaustive()}
|
className="mt-2"
|
||||||
</Button>
|
placeholder="Enter the next signer's name"
|
||||||
</div>
|
/>
|
||||||
</DialogFooter>
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="email"
|
||||||
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's email"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DocumentSigningDisclosure className="mt-4" />
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex-1"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowDialog(false)}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!isComplete || !isNextSignerValid}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{match(role)
|
||||||
|
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||||
|
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||||
|
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||||
|
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
||||||
|
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||||
|
.exhaustive()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -142,7 +142,7 @@ export const DocumentSigningDateField = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
|
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||||
<Trans>Date</Trans>
|
<Trans>Date</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -151,12 +151,10 @@ export const DocumentSigningDateField = ({
|
|||||||
<div className="flex h-full w-full items-center">
|
<div className="flex h-full w-full items-center">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
'text-foreground w-full whitespace-nowrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||||
{
|
{
|
||||||
'text-left': parsedFieldMeta?.textAlign === 'left',
|
'!text-center': parsedFieldMeta?.textAlign === 'center',
|
||||||
'text-center':
|
'!text-right': parsedFieldMeta?.textAlign === 'right',
|
||||||
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
|
||||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -177,15 +177,11 @@ export const DocumentSigningDropdownField = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200">
|
<p className="group-hover:text-primary text-foreground flex flex-col items-center justify-center duration-200">
|
||||||
<Select value={localChoice} onValueChange={handleSelectItem}>
|
<Select value={localChoice} onValueChange={handleSelectItem}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground z-10 h-full w-full border-none ring-0 focus:ring-0',
|
'text-foreground z-10 h-full w-full border-none ring-0 focus:border-none focus:ring-0',
|
||||||
{
|
|
||||||
'hover:text-red-300': parsedFieldMeta.required,
|
|
||||||
'hover:text-yellow-300': !parsedFieldMeta.required,
|
|
||||||
},
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
@ -205,7 +201,7 @@ export const DocumentSigningDropdownField = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
<p className="text-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||||
{field.customText}
|
{field.customText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
@ -14,10 +13,14 @@ import type {
|
|||||||
TRemovedSignedFieldWithTokenMutationSchema,
|
TRemovedSignedFieldWithTokenMutationSchema,
|
||||||
TSignFieldWithTokenMutationSchema,
|
TSignFieldWithTokenMutationSchema,
|
||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
||||||
|
import {
|
||||||
|
DocumentSigningFieldsInserted,
|
||||||
|
DocumentSigningFieldsLoader,
|
||||||
|
DocumentSigningFieldsUninserted,
|
||||||
|
} from './document-signing-fields';
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
@ -120,34 +123,18 @@ export const DocumentSigningEmailField = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
|
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
|
||||||
{isLoading && (
|
{isLoading && <DocumentSigningFieldsLoader />}
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
|
<DocumentSigningFieldsUninserted>
|
||||||
<Trans>Email</Trans>
|
<Trans>Email</Trans>
|
||||||
</p>
|
</DocumentSigningFieldsUninserted>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<div className="flex h-full w-full items-center">
|
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
||||||
<p
|
{field.customText}
|
||||||
className={cn(
|
</DocumentSigningFieldsInserted>
|
||||||
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
|
||||||
{
|
|
||||||
'text-left': parsedFieldMeta?.textAlign === 'left',
|
|
||||||
'text-center':
|
|
||||||
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
|
||||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.customText}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</DocumentSigningFieldContainer>
|
</DocumentSigningFieldContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,12 +2,14 @@ import React from 'react';
|
|||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { FieldType } from '@prisma/client';
|
import { FieldType } from '@prisma/client';
|
||||||
|
import { TooltipArrow } from '@radix-ui/react-tooltip';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||||
|
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
@ -128,57 +130,51 @@ export const DocumentSigningFieldContainer = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('[container-type:size]', { group: type === 'Checkbox' })}>
|
<div className={cn('[container-type:size]')}>
|
||||||
<FieldRootContainer field={field}>
|
<FieldRootContainer color={RECIPIENT_COLOR_STYLES.green} field={field}>
|
||||||
{!field.inserted && !loading && !readOnlyField && (
|
{!field.inserted && !loading && !readOnlyField && (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="absolute inset-0 z-10 h-full w-full rounded-md border"
|
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
|
||||||
onClick={async () => handleInsertField()}
|
onClick={async () => handleInsertField()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{readOnlyField && (
|
{readOnlyField && (
|
||||||
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
|
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
|
||||||
<span className="bg-foreground/50 dark:bg-background/50 text-background dark:text-foreground rounded-xl p-2">
|
<span className="bg-foreground/50 text-background rounded-xl p-2">
|
||||||
<Trans>Read only field</Trans>
|
<Trans>Read only field</Trans>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'Date' && field.inserted && !loading && !readOnlyField && (
|
|
||||||
<Tooltip delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
|
|
||||||
onClick={onRemoveSignedFieldClick}
|
|
||||||
>
|
|
||||||
<Trans>Remove</Trans>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||||
<button
|
<button
|
||||||
className="dark:bg-background absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
|
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
|
||||||
onClick={() => void onClearCheckBoxValues(type)}
|
onClick={() => void onClearCheckBoxValues(type)}
|
||||||
>
|
>
|
||||||
<span className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
|
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type !== 'Date' && type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||||
<button
|
<Tooltip delayDuration={0}>
|
||||||
className="text-destructive bg-background/50 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
|
<TooltipTrigger asChild>
|
||||||
onClick={onRemoveSignedFieldClick}
|
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
|
||||||
>
|
</TooltipTrigger>
|
||||||
<Trans>Remove</Trans>
|
|
||||||
</button>
|
<TooltipContent
|
||||||
|
className="border-0 bg-orange-300 fill-orange-300 font-bold text-orange-900"
|
||||||
|
sideOffset={2}
|
||||||
|
>
|
||||||
|
{tooltipText && <p>{tooltipText}</p>}
|
||||||
|
|
||||||
|
<Trans>Remove</Trans>
|
||||||
|
<TooltipArrow />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
export const DocumentSigningFieldsLoader = () => {
|
||||||
|
return (
|
||||||
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentSigningFieldsUninserted = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentSigningFieldsInsertedProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text alignment of the field.
|
||||||
|
*
|
||||||
|
* Defaults to left.
|
||||||
|
*/
|
||||||
|
textAlign?: 'left' | 'center' | 'right';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentSigningFieldsInserted = ({
|
||||||
|
children,
|
||||||
|
textAlign = 'left',
|
||||||
|
}: DocumentSigningFieldsInsertedProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-foreground w-full text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||||
|
{
|
||||||
|
'!text-center': textAlign === 'center',
|
||||||
|
'!text-right': textAlign === 'right',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -24,7 +24,10 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group
|
|||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
|
import {
|
||||||
|
AssistantConfirmationDialog,
|
||||||
|
type NextSigner,
|
||||||
|
} from '../../dialogs/assistant-confirmation-dialog';
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
|
|
||||||
@ -64,8 +67,11 @@ export const DocumentSigningForm = ({
|
|||||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||||
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken } =
|
const {
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
mutateAsync: completeDocumentWithToken,
|
||||||
|
isPending,
|
||||||
|
isSuccess,
|
||||||
|
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -73,10 +79,8 @@ export const DocumentSigningForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, formState } = useForm();
|
|
||||||
|
|
||||||
// Keep the loading state going if successful since the redirect may take some time.
|
// Keep the loading state going if successful since the redirect may take some time.
|
||||||
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
const isSubmitting = isPending || isSuccess;
|
||||||
|
|
||||||
const fieldsRequiringValidation = useMemo(
|
const fieldsRequiringValidation = useMemo(
|
||||||
() => fields.filter(isFieldUnsignedAndRequired),
|
() => fields.filter(isFieldUnsignedAndRequired),
|
||||||
@ -98,18 +102,6 @@ export const DocumentSigningForm = ({
|
|||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
|
||||||
setValidateUninsertedFields(true);
|
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
|
||||||
|
|
||||||
if (!isFieldsValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await completeDocument();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAssistantFormSubmit = () => {
|
const onAssistantFormSubmit = () => {
|
||||||
if (uninsertedRecipientFields.length > 0) {
|
if (uninsertedRecipientFields.length > 0) {
|
||||||
return;
|
return;
|
||||||
@ -118,11 +110,11 @@ export const DocumentSigningForm = ({
|
|||||||
setIsConfirmationDialogOpen(true);
|
setIsConfirmationDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssistantConfirmDialogSubmit = async () => {
|
const handleAssistantConfirmDialogSubmit = async (nextSigner?: NextSigner) => {
|
||||||
setIsAssistantSubmitting(true);
|
setIsAssistantSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeDocument();
|
await completeDocument(undefined, nextSigner);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@ -135,12 +127,18 @@ export const DocumentSigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
const completeDocument = async (
|
||||||
await completeDocumentWithToken({
|
authOptions?: TRecipientActionAuth,
|
||||||
|
nextSigner?: { email: string; name: string },
|
||||||
|
) => {
|
||||||
|
const payload = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
authOptions,
|
authOptions,
|
||||||
});
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await completeDocumentWithToken(payload);
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
signerId: recipient.id,
|
signerId: recipient.id,
|
||||||
@ -155,6 +153,29 @@ export const DocumentSigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const nextRecipient = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!document.documentMeta?.signingOrder ||
|
||||||
|
document.documentMeta.signingOrder !== 'SEQUENTIAL'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedRecipients = allRecipients.sort((a, b) => {
|
||||||
|
// Sort by signingOrder first (nulls last), then by id
|
||||||
|
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
||||||
|
if (a.signingOrder === null) return 1;
|
||||||
|
if (b.signingOrder === null) return -1;
|
||||||
|
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
||||||
|
return a.signingOrder - b.signingOrder;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
|
||||||
|
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
||||||
|
? sortedRecipients[currentIndex + 1]
|
||||||
|
: undefined;
|
||||||
|
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -204,12 +225,19 @@ export const DocumentSigningForm = ({
|
|||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
}}
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
disabled={!isRecipientsTurn}
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -288,9 +316,8 @@ export const DocumentSigningForm = ({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
loading={isAssistantSubmitting}
|
loading={isAssistantSubmitting}
|
||||||
disabled={isAssistantSubmitting || uninsertedRecipientFields.length > 0}
|
|
||||||
>
|
>
|
||||||
{isAssistantSubmitting ? <Trans>Submitting...</Trans> : <Trans>Continue</Trans>}
|
<Trans>Continue</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -300,12 +327,20 @@ export const DocumentSigningForm = ({
|
|||||||
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
||||||
onConfirm={handleAssistantConfirmDialogSubmit}
|
onConfirm={handleAssistantConfirmDialogSubmit}
|
||||||
isSubmitting={isAssistantSubmitting}
|
isSubmitting={isAssistantSubmitting}
|
||||||
|
allowDictateNextSigner={
|
||||||
|
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||||
|
}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<div>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
||||||
<Trans>Please review the document before approving.</Trans>
|
<Trans>Please review the document before approving.</Trans>
|
||||||
@ -353,31 +388,41 @@ export const DocumentSigningForm = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
|
||||||
onClick={async () => navigate(-1)}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
|
||||||
documentTitle={document.title}
|
|
||||||
fields={fields}
|
|
||||||
fieldsValidated={fieldsValidated}
|
|
||||||
role={recipient.role}
|
|
||||||
disabled={!isRecipientsTurn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
|
||||||
|
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||||
|
onClick={async () => navigate(-1)}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DocumentSigningCompleteDialog
|
||||||
|
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||||
|
documentTitle={document.title}
|
||||||
|
fields={fields}
|
||||||
|
fieldsValidated={fieldsValidated}
|
||||||
|
disabled={!isRecipientsTurn}
|
||||||
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
}}
|
||||||
|
role={recipient.role}
|
||||||
|
allowDictateNextSigner={
|
||||||
|
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||||
|
}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { ZInitialsFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -17,6 +17,11 @@ import type {
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
||||||
|
import {
|
||||||
|
DocumentSigningFieldsInserted,
|
||||||
|
DocumentSigningFieldsLoader,
|
||||||
|
DocumentSigningFieldsUninserted,
|
||||||
|
} from './document-signing-fields';
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
@ -50,6 +55,9 @@ export const DocumentSigningInitialsField = ({
|
|||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
|
||||||
|
|
||||||
|
const safeFieldMeta = ZInitialsFieldMeta.safeParse(field.fieldMeta);
|
||||||
|
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
|
||||||
|
|
||||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
const value = initials ?? '';
|
const value = initials ?? '';
|
||||||
@ -122,22 +130,18 @@ export const DocumentSigningInitialsField = ({
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
type="Initials"
|
type="Initials"
|
||||||
>
|
>
|
||||||
{isLoading && (
|
{isLoading && <DocumentSigningFieldsLoader />}
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
|
<DocumentSigningFieldsUninserted>
|
||||||
<Trans>Initials</Trans>
|
<Trans>Initials</Trans>
|
||||||
</p>
|
</DocumentSigningFieldsUninserted>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
||||||
{field.customText}
|
{field.customText}
|
||||||
</p>
|
</DocumentSigningFieldsInserted>
|
||||||
)}
|
)}
|
||||||
</DocumentSigningFieldContainer>
|
</DocumentSigningFieldContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
@ -16,7 +15,6 @@ import type {
|
|||||||
TRemovedSignedFieldWithTokenMutationSchema,
|
TRemovedSignedFieldWithTokenMutationSchema,
|
||||||
TSignFieldWithTokenMutationSchema,
|
TSignFieldWithTokenMutationSchema,
|
||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
@ -25,6 +23,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
||||||
|
import {
|
||||||
|
DocumentSigningFieldsInserted,
|
||||||
|
DocumentSigningFieldsLoader,
|
||||||
|
DocumentSigningFieldsUninserted,
|
||||||
|
} from './document-signing-fields';
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
@ -166,34 +169,18 @@ export const DocumentSigningNameField = ({
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
type="Name"
|
type="Name"
|
||||||
>
|
>
|
||||||
{isLoading && (
|
{isLoading && <DocumentSigningFieldsLoader />}
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
<DocumentSigningFieldsUninserted>
|
||||||
<Trans>Name</Trans>
|
<Trans>Name</Trans>
|
||||||
</p>
|
</DocumentSigningFieldsUninserted>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<div className="flex h-full w-full items-center">
|
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
||||||
<p
|
{field.customText}
|
||||||
className={cn(
|
</DocumentSigningFieldsInserted>
|
||||||
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
|
||||||
{
|
|
||||||
'text-left': parsedFieldMeta?.textAlign === 'left',
|
|
||||||
'text-center':
|
|
||||||
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
|
||||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.customText}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
|
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
|
||||||
@ -202,7 +189,7 @@ export const DocumentSigningNameField = ({
|
|||||||
<Trans>
|
<Trans>
|
||||||
Sign as
|
Sign as
|
||||||
<div>
|
<div>
|
||||||
{recipient.name} <div className="text-muted-foreground">({recipient.email})</div>
|
{recipient.name} <div className="text-foreground">({recipient.email})</div>
|
||||||
</div>
|
</div>
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
@ -224,7 +211,7 @@ export const DocumentSigningNameField = ({
|
|||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
className="flex-1"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowFullNameModal(false);
|
setShowFullNameModal(false);
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Hash, Loader } from 'lucide-react';
|
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
||||||
@ -25,6 +24,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
||||||
|
import {
|
||||||
|
DocumentSigningFieldsInserted,
|
||||||
|
DocumentSigningFieldsLoader,
|
||||||
|
DocumentSigningFieldsUninserted,
|
||||||
|
} from './document-signing-fields';
|
||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
type ValidationErrors = {
|
type ValidationErrors = {
|
||||||
@ -245,45 +249,16 @@ export const DocumentSigningNumberField = ({
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
type="Number"
|
type="Number"
|
||||||
>
|
>
|
||||||
{isLoading && (
|
{isLoading && <DocumentSigningFieldsLoader />}
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<p
|
<DocumentSigningFieldsUninserted>{fieldDisplayName}</DocumentSigningFieldsUninserted>
|
||||||
className={cn(
|
|
||||||
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
|
|
||||||
{
|
|
||||||
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
|
|
||||||
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex items-center justify-center gap-x-1">
|
|
||||||
<Hash className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />{' '}
|
|
||||||
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{fieldDisplayName}</span>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<div className="flex h-full w-full items-center">
|
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
||||||
<p
|
{field.customText}
|
||||||
className={cn(
|
</DocumentSigningFieldsInserted>
|
||||||
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
|
||||||
{
|
|
||||||
'text-left': parsedFieldMeta?.textAlign === 'left',
|
|
||||||
'text-center':
|
|
||||||
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
|
||||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.customText}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
|
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
|
||||||
@ -339,7 +314,7 @@ export const DocumentSigningNumberField = ({
|
|||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
className="flex-1"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowNumberModal(false);
|
setShowNumberModal(false);
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
@ -36,13 +37,12 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
|
|||||||
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
|
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
|
||||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||||
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
|
|
||||||
|
|
||||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type SigningPageViewProps = {
|
export type DocumentSigningPageViewProps = {
|
||||||
document: DocumentAndSender;
|
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
|
document: DocumentAndSender;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
completedFields: CompletedField[];
|
completedFields: CompletedField[];
|
||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
@ -50,13 +50,13 @@ export type SigningPageViewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningPageView = ({
|
export const DocumentSigningPageView = ({
|
||||||
document,
|
|
||||||
recipient,
|
recipient,
|
||||||
|
document,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
}: SigningPageViewProps) => {
|
}: DocumentSigningPageViewProps) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||||
@ -157,7 +157,11 @@ export const DocumentSigningPageView = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentReadOnlyFields fields={completedFields} />
|
<DocumentReadOnlyFields
|
||||||
|
documentMeta={documentMeta || undefined}
|
||||||
|
fields={completedFields}
|
||||||
|
showRecipientTooltip={true}
|
||||||
|
/>
|
||||||
|
|
||||||
{recipient.role !== RecipientRole.ASSISTANT && (
|
{recipient.role !== RecipientRole.ASSISTANT && (
|
||||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
@ -21,6 +20,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
||||||
|
import { DocumentSigningFieldsLoader } from './document-signing-fields';
|
||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type DocumentSigningRadioFieldProps = {
|
export type DocumentSigningRadioFieldProps = {
|
||||||
@ -150,44 +150,52 @@ export const DocumentSigningRadioField = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Radio">
|
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Radio">
|
||||||
{isLoading && (
|
{isLoading && <DocumentSigningFieldsLoader />}
|
||||||
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
|
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10">
|
<RadioGroup
|
||||||
|
onValueChange={(value) => handleSelectItem(value)}
|
||||||
|
className="z-10 my-0.5 gap-y-1"
|
||||||
|
>
|
||||||
{values?.map((item, index) => (
|
{values?.map((item, index) => (
|
||||||
<div key={index} className="flex items-center gap-x-1.5">
|
<div key={index} className="flex items-center">
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
className="h-4 w-4 shrink-0"
|
className="h-3 w-3 shrink-0"
|
||||||
value={item.value}
|
value={item.value}
|
||||||
id={`option-${index}`}
|
id={`option-${field.id}-${item.id}`}
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
/>
|
/>
|
||||||
|
{!item.value.includes('empty-value-') && item.value && (
|
||||||
<Label htmlFor={`option-${index}`}>
|
<Label
|
||||||
{item.value.includes('empty-value-') ? '' : item.value}
|
htmlFor={`option-${field.id}-${item.id}`}
|
||||||
</Label>
|
className="text-foreground ml-1.5 text-xs font-normal"
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<RadioGroup className="gap-y-1">
|
<RadioGroup className="my-0.5 gap-y-1">
|
||||||
{values?.map((item, index) => (
|
{values?.map((item, index) => (
|
||||||
<div key={index} className="flex items-center gap-x-1.5">
|
<div key={index} className="flex items-center">
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
className="h-3 w-3"
|
className="h-3 w-3"
|
||||||
value={item.value}
|
value={item.value}
|
||||||
id={`option-${index}`}
|
id={`option-${field.id}-${item.id}`}
|
||||||
checked={item.value === field.customText}
|
checked={item.value === field.customText}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={`option-${index}`} className="text-xs">
|
{!item.value.includes('empty-value-') && item.value && (
|
||||||
{item.value.includes('empty-value-') ? '' : item.value}
|
<Label
|
||||||
</Label>
|
htmlFor={`option-${field.id}-${item.id}`}
|
||||||
|
className="text-foreground ml-1.5 text-xs font-normal"
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
|||||||
@ -31,10 +31,7 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ZRejectDocumentFormSchema = z.object({
|
const ZRejectDocumentFormSchema = z.object({
|
||||||
reason: z
|
reason: z.string().max(500, msg`Reason must be less than 500 characters`),
|
||||||
.string()
|
|
||||||
.min(5, msg`Please provide a reason`)
|
|
||||||
.max(500, msg`Reason must be less than 500 characters`),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
||||||
|
|||||||
@ -242,7 +242,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{state === 'empty' && (
|
{state === 'empty' && (
|
||||||
<p className="group-hover:text-primary font-signature text-muted-foreground text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200 group-hover:text-yellow-300">
|
<p className="group-hover:text-primary font-signature text-muted-foreground group-hover:text-recipient-green text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200">
|
||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -259,7 +259,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
|
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
|
||||||
<p
|
<p
|
||||||
ref={signatureRef}
|
ref={signatureRef}
|
||||||
className="font-signature text-muted-foreground dark:text-background w-full overflow-hidden break-all text-center leading-tight duration-200"
|
className="font-signature text-muted-foreground w-full overflow-hidden break-all text-center leading-tight duration-200"
|
||||||
style={{ fontSize: `${fontSize}rem` }}
|
style={{ fontSize: `${fontSize}rem` }}
|
||||||
>
|
>
|
||||||
{signature?.typedSignature}
|
{signature?.typedSignature}
|
||||||
@ -291,7 +291,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
className="flex-1"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowSignatureModal(false);
|
setShowSignatureModal(false);
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { Loader, Type } from 'lucide-react';
|
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||||
@ -25,6 +24,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
||||||
|
import {
|
||||||
|
DocumentSigningFieldsInserted,
|
||||||
|
DocumentSigningFieldsLoader,
|
||||||
|
DocumentSigningFieldsUninserted,
|
||||||
|
} from './document-signing-fields';
|
||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type DocumentSigningTextFieldProps = {
|
export type DocumentSigningTextFieldProps = {
|
||||||
@ -248,49 +252,20 @@ export const DocumentSigningTextField = ({
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
type="Text"
|
type="Text"
|
||||||
>
|
>
|
||||||
{isLoading && (
|
{isLoading && <DocumentSigningFieldsLoader />}
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<p
|
<DocumentSigningFieldsUninserted>
|
||||||
className={cn(
|
{fieldDisplayName || <Trans>Text</Trans>}
|
||||||
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
|
</DocumentSigningFieldsUninserted>
|
||||||
{
|
|
||||||
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
|
|
||||||
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex items-center justify-center gap-x-1">
|
|
||||||
<Type className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />
|
|
||||||
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
|
|
||||||
{fieldDisplayName || <Trans>Text</Trans>}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<div className="flex h-full w-full items-center">
|
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
||||||
<p
|
{field.customText.length < 20
|
||||||
className={cn(
|
? field.customText
|
||||||
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
: field.customText.substring(0, 20) + '...'}
|
||||||
{
|
</DocumentSigningFieldsInserted>
|
||||||
'text-left': parsedFieldMeta?.textAlign === 'left',
|
|
||||||
'text-center':
|
|
||||||
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
|
||||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.customText.length < 20
|
|
||||||
? field.customText
|
|
||||||
: field.customText.substring(0, 20) + '...'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
|
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
|
||||||
@ -304,11 +279,9 @@ export const DocumentSigningTextField = ({
|
|||||||
id="custom-text"
|
id="custom-text"
|
||||||
placeholder={parsedFieldMeta?.placeholder ?? _(msg`Enter your text here`)}
|
placeholder={parsedFieldMeta?.placeholder ?? _(msg`Enter your text here`)}
|
||||||
className={cn('mt-2 w-full rounded-md', {
|
className={cn('mt-2 w-full rounded-md', {
|
||||||
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
|
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
|
||||||
userInputHasErrors,
|
userInputHasErrors,
|
||||||
'text-left': parsedFieldMeta?.textAlign === 'left',
|
'text-center': parsedFieldMeta?.textAlign === 'center',
|
||||||
'text-center':
|
|
||||||
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
|
||||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||||
})}
|
})}
|
||||||
value={localText}
|
value={localText}
|
||||||
@ -354,8 +327,8 @@ export const DocumentSigningTextField = ({
|
|||||||
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
|
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCustomTextModal(false);
|
setShowCustomTextModal(false);
|
||||||
setLocalCustomText('');
|
setLocalCustomText('');
|
||||||
|
|||||||
@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { DocumentData } from '@prisma/client';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
|
import { ShareDocumentDownloadButton } from '../share-document-download-button';
|
||||||
|
|
||||||
|
export type DocumentCertificateQRViewProps = {
|
||||||
|
documentId: number;
|
||||||
|
title: string;
|
||||||
|
documentData: DocumentData;
|
||||||
|
password?: string | null;
|
||||||
|
recipientCount?: number;
|
||||||
|
completedDate?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentCertificateQRView = ({
|
||||||
|
documentId,
|
||||||
|
title,
|
||||||
|
documentData,
|
||||||
|
password,
|
||||||
|
recipientCount = 0,
|
||||||
|
completedDate,
|
||||||
|
}: DocumentCertificateQRViewProps) => {
|
||||||
|
const { data: documentUrl } = trpc.shareLink.getDocumentInternalUrlForQRCode.useQuery({
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentUrl);
|
||||||
|
|
||||||
|
const formattedDate = completedDate
|
||||||
|
? DateTime.fromJSDate(completedDate).toLocaleString(DateTime.DATETIME_MED)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (documentUrl) {
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}
|
||||||
|
}, [documentUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-md">
|
||||||
|
{/* Dialog for internal document link */}
|
||||||
|
{documentUrl && (
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Document found in your account</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This document is available in your Documenso account. You can view more details,
|
||||||
|
recipients, and audit logs there.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-row justify-end gap-2">
|
||||||
|
<Button asChild>
|
||||||
|
<a href={documentUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Trans>Go to document</Trans>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-xl font-medium">{title}</h1>
|
||||||
|
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
|
||||||
|
<p>
|
||||||
|
<Trans>{recipientCount} recipients</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<Trans>Completed on {formattedDate}</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ShareDocumentDownloadButton title={title} documentData={documentData} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 w-full">
|
||||||
|
<PDFViewer key={documentData.id} documentData={documentData} password={password} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,192 @@
|
|||||||
|
import { type ReactNode, useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||||
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export interface DocumentDropZoneWrapperProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZoneWrapperProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { user } = useSession();
|
||||||
|
const { folderId } = useParams();
|
||||||
|
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const userTimezone =
|
||||||
|
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
||||||
|
DEFAULT_DOCUMENT_TIME_ZONE;
|
||||||
|
|
||||||
|
const { quota, remaining, refreshLimits } = useLimits();
|
||||||
|
|
||||||
|
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||||
|
|
||||||
|
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
|
||||||
|
|
||||||
|
const onFileDrop = async (file: File) => {
|
||||||
|
if (isUploadDisabled && IS_BILLING_ENABLED()) {
|
||||||
|
await navigate('/settings/billing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const response = await putPdfFile(file);
|
||||||
|
|
||||||
|
const { id } = await createDocument({
|
||||||
|
title: file.name,
|
||||||
|
documentDataId: response.id,
|
||||||
|
timezone: userTimezone,
|
||||||
|
folderId: folderId ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
void refreshLimits();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Document uploaded`),
|
||||||
|
description: _(msg`Your document has been uploaded successfully.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
analytics.capture('App: Document Uploaded', {
|
||||||
|
userId: user.id,
|
||||||
|
documentId: id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await navigate(
|
||||||
|
folderId
|
||||||
|
? `${formatDocumentsPath(team?.url)}/f/${folderId}/${id}/edit`
|
||||||
|
: `${formatDocumentsPath(team?.url)}/${id}/edit`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const errorMessage = match(error.code)
|
||||||
|
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
|
||||||
|
.with(
|
||||||
|
AppErrorCode.LIMIT_EXCEEDED,
|
||||||
|
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||||
|
)
|
||||||
|
.otherwise(() => msg`An error occurred while uploading your document.`);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: _(errorMessage),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileDropRejected = () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Your document failed to upload.`),
|
||||||
|
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
accept: {
|
||||||
|
'application/pdf': ['.pdf'],
|
||||||
|
},
|
||||||
|
//disabled: isUploadDisabled,
|
||||||
|
multiple: false,
|
||||||
|
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||||
|
onDrop: ([acceptedFile]) => {
|
||||||
|
if (acceptedFile) {
|
||||||
|
void onFileDrop(acceptedFile);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDropRejected: () => {
|
||||||
|
void onFileDropRejected();
|
||||||
|
},
|
||||||
|
noClick: true,
|
||||||
|
noDragEventsBubbling: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...getRootProps()} className={cn('relative min-h-screen', className)}>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{isDragActive && (
|
||||||
|
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
||||||
|
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
||||||
|
<h2 className="text-foreground text-2xl font-semibold">
|
||||||
|
<Trans>Upload Document</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-md mt-4">
|
||||||
|
<Trans>Drag and drop your PDF file here</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isUploadDisabled && IS_BILLING_ENABLED() && (
|
||||||
|
<Link
|
||||||
|
to="/settings/billing"
|
||||||
|
className="mt-4 text-sm text-amber-500 hover:underline dark:text-amber-400"
|
||||||
|
>
|
||||||
|
<Trans>Upgrade your plan to upload more documents</Trans>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isUploadDisabled &&
|
||||||
|
team?.id === undefined &&
|
||||||
|
remaining.documents > 0 &&
|
||||||
|
Number.isFinite(remaining.documents) && (
|
||||||
|
<p className="text-muted-foreground/80 mt-4 text-sm">
|
||||||
|
<Trans>
|
||||||
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
|
||||||
|
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
||||||
|
<Loader className="text-primary h-12 w-12 animate-spin" />
|
||||||
|
<p className="text-foreground mt-8 font-medium">
|
||||||
|
<Trans>Uploading document...</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -217,6 +217,13 @@ export const DocumentEditForm = ({
|
|||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
meta: {
|
||||||
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
setRecipients({
|
setRecipients({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
recipients: data.signers.map((signer) => ({
|
recipients: data.signers.map((signer) => ({
|
||||||
@ -361,6 +368,7 @@ export const DocumentEditForm = ({
|
|||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
signingOrder={document.documentMeta?.signingOrder}
|
signingOrder={document.documentMeta?.signingOrder}
|
||||||
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentEnterprise={isDocumentEnterprise}
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
|
|||||||
@ -12,7 +12,14 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
|||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
SplitButton,
|
||||||
|
SplitButtonAction,
|
||||||
|
SplitButtonDropdown,
|
||||||
|
SplitButtonDropdownItem,
|
||||||
|
} from '@documenso/ui/primitives/split-button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DocumentPageViewButtonProps = {
|
export type DocumentPageViewButtonProps = {
|
||||||
@ -38,6 +45,15 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(document.team?.url);
|
const documentsPath = formatDocumentsPath(document.team?.url);
|
||||||
|
const formatPath = document.folderId
|
||||||
|
? `${documentsPath}/f/${document.folderId}/${document.id}/edit`
|
||||||
|
: `${documentsPath}/${document.id}/edit`;
|
||||||
|
|
||||||
|
const { mutateAsync: downloadCertificate, isPending: isDownloadingCertificate } =
|
||||||
|
trpc.document.downloadCertificate.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: downloadAuditLogs, isPending: isDownloadingAuditLogs } =
|
||||||
|
trpc.document.downloadAuditLogs.useMutation();
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
@ -68,6 +84,125 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDownloadOriginalClick = async () => {
|
||||||
|
try {
|
||||||
|
const documentWithData = await trpcClient.document.getDocumentById.query(
|
||||||
|
{
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
teamId: document.team?.id?.toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const documentData = documentWithData?.documentData;
|
||||||
|
|
||||||
|
if (!documentData) {
|
||||||
|
throw new Error('No document available');
|
||||||
|
}
|
||||||
|
|
||||||
|
await downloadPDF({ documentData, fileName: documentWithData.title, version: 'original' });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDownloadCertificateClick = async () => {
|
||||||
|
try {
|
||||||
|
const { url } = await downloadCertificate({ documentId: document.id });
|
||||||
|
|
||||||
|
const iframe = Object.assign(window.document.createElement('iframe'), {
|
||||||
|
src: url,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(iframe.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '0',
|
||||||
|
left: '0',
|
||||||
|
width: '0',
|
||||||
|
height: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLoaded = () => {
|
||||||
|
if (iframe.contentDocument?.readyState === 'complete') {
|
||||||
|
iframe.contentWindow?.print();
|
||||||
|
|
||||||
|
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||||
|
window.document.body.removeChild(iframe);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||||
|
iframe.addEventListener('load', onLoaded);
|
||||||
|
|
||||||
|
window.document.body.appendChild(iframe);
|
||||||
|
|
||||||
|
onLoaded();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(
|
||||||
|
msg`Sorry, we were unable to download the certificate. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDownloadAuditLogClick = async () => {
|
||||||
|
try {
|
||||||
|
const { url } = await downloadAuditLogs({ documentId: document.id });
|
||||||
|
|
||||||
|
const iframe = Object.assign(window.document.createElement('iframe'), {
|
||||||
|
src: url,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(iframe.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '0',
|
||||||
|
left: '0',
|
||||||
|
width: '0',
|
||||||
|
height: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLoaded = () => {
|
||||||
|
if (iframe.contentDocument?.readyState === 'complete') {
|
||||||
|
iframe.contentWindow?.print();
|
||||||
|
|
||||||
|
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||||
|
window.document.body.removeChild(iframe);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||||
|
iframe.addEventListener('load', onLoaded);
|
||||||
|
|
||||||
|
window.document.body.appendChild(iframe);
|
||||||
|
|
||||||
|
onLoaded();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(
|
||||||
|
msg`Sorry, we were unable to download the audit logs. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isRecipient,
|
isRecipient,
|
||||||
isPending,
|
isPending,
|
||||||
@ -101,16 +236,32 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
))
|
))
|
||||||
.with({ isComplete: false }, () => (
|
.with({ isComplete: false }, () => (
|
||||||
<Button className="w-full" asChild>
|
<Button className="w-full" asChild>
|
||||||
<Link to={`${documentsPath}/${document.id}/edit`}>
|
<Link to={formatPath}>
|
||||||
<Trans>Edit</Trans>
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-full" onClick={onDownloadClick}>
|
<SplitButton className="w-full">
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
<SplitButtonAction onClick={onDownloadClick}>
|
||||||
<Trans>Download</Trans>
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
</Button>
|
<Trans>Download</Trans>
|
||||||
|
</SplitButtonAction>
|
||||||
|
|
||||||
|
<SplitButtonDropdown>
|
||||||
|
<SplitButtonDropdownItem onClick={onDownloadOriginalClick}>
|
||||||
|
<Trans>Download Original Document</Trans>
|
||||||
|
</SplitButtonDropdownItem>
|
||||||
|
|
||||||
|
<SplitButtonDropdownItem onClick={onDownloadCertificateClick}>
|
||||||
|
<Trans>Download Document Certificate</Trans>
|
||||||
|
</SplitButtonDropdownItem>
|
||||||
|
|
||||||
|
<SplitButtonDropdownItem onClick={onDownloadAuditLogClick}>
|
||||||
|
<Trans>Download Audit Log</Trans>
|
||||||
|
</SplitButtonDropdownItem>
|
||||||
|
</SplitButtonDropdown>
|
||||||
|
</SplitButton>
|
||||||
))
|
))
|
||||||
.otherwise(() => null);
|
.otherwise(() => null);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
|
||||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
import type { Document, Recipient, Team, User } from '@prisma/client';
|
||||||
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
@ -15,8 +15,7 @@ import {
|
|||||||
Share,
|
Share,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
@ -99,6 +98,35 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDownloadOriginalClick = async () => {
|
||||||
|
try {
|
||||||
|
const documentWithData = await trpcClient.document.getDocumentById.query(
|
||||||
|
{
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
teamId: team?.id?.toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const documentData = documentWithData?.documentData;
|
||||||
|
|
||||||
|
if (!documentData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await downloadPDF({ documentData, fileName: document.title, version: 'original' });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const nonSignedRecipients = document.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
const nonSignedRecipients = document.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -128,6 +156,11 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Download Original</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`${documentsPath}/${document.id}/logs`}>
|
<Link to={`${documentsPath}/${document.id}/logs`}>
|
||||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@ -1,171 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { DocumentMeta } from '@prisma/client';
|
|
||||||
import { FieldType, SigningStatus } from '@prisma/client';
|
|
||||||
import { Clock, EyeOffIcon } from 'lucide-react';
|
|
||||||
import { P, match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import {
|
|
||||||
DEFAULT_DOCUMENT_DATE_FORMAT,
|
|
||||||
convertToLocalSystemFormat,
|
|
||||||
} from '@documenso/lib/constants/date-formats';
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
|
||||||
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
|
|
||||||
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
|
||||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
|
||||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
|
||||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
||||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
|
||||||
|
|
||||||
export type DocumentReadOnlyFieldsProps = {
|
|
||||||
fields: DocumentField[];
|
|
||||||
documentMeta?: DocumentMeta;
|
|
||||||
showFieldStatus?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentReadOnlyFields = ({
|
|
||||||
documentMeta,
|
|
||||||
fields,
|
|
||||||
showFieldStatus = true,
|
|
||||||
}: DocumentReadOnlyFieldsProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const handleHideField = (fieldId: string) => {
|
|
||||||
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
|
||||||
{fields.map(
|
|
||||||
(field) =>
|
|
||||||
!hiddenFieldIds[field.secondaryId] && (
|
|
||||||
<FieldRootContainer
|
|
||||||
field={field}
|
|
||||||
key={field.id}
|
|
||||||
cardClassName="border-gray-300/50 !shadow-none backdrop-blur-[1px] bg-gray-50 ring-0 ring-offset-0"
|
|
||||||
>
|
|
||||||
<div className="absolute -right-3 -top-3">
|
|
||||||
<PopoverHover
|
|
||||||
trigger={
|
|
||||||
<Avatar className="dark:border-foreground h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
|
|
||||||
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
|
|
||||||
{extractInitials(field.recipient.name || field.recipient.email)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
}
|
|
||||||
contentProps={{
|
|
||||||
className: 'relative flex w-fit flex-col p-4 text-sm',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showFieldStatus && (
|
|
||||||
<Badge
|
|
||||||
className="mx-auto mb-1 py-0.5"
|
|
||||||
variant={
|
|
||||||
field.recipient.signingStatus === SigningStatus.SIGNED
|
|
||||||
? 'default'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{field.recipient.signingStatus === SigningStatus.SIGNED ? (
|
|
||||||
<>
|
|
||||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
|
||||||
<Trans>Signed</Trans>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
|
||||||
<Trans>Pending</Trans>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-center font-semibold">
|
|
||||||
<span>{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-1 text-center text-xs">
|
|
||||||
{field.recipient.name
|
|
||||||
? `${field.recipient.name} (${field.recipient.email})`
|
|
||||||
: field.recipient.email}{' '}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
|
|
||||||
onClick={() => handleHideField(field.secondaryId)}
|
|
||||||
title="Hide field"
|
|
||||||
>
|
|
||||||
<EyeOffIcon className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</PopoverHover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground dark:text-background/70 break-all text-sm">
|
|
||||||
{field.recipient.signingStatus === SigningStatus.SIGNED &&
|
|
||||||
match(field)
|
|
||||||
.with({ type: FieldType.SIGNATURE }, (field) =>
|
|
||||||
field.signature?.signatureImageAsBase64 ? (
|
|
||||||
<img
|
|
||||||
src={field.signature.signatureImageAsBase64}
|
|
||||||
alt="Signature"
|
|
||||||
className="h-full w-full object-contain dark:invert"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl">
|
|
||||||
{field.signature?.typedSignature}
|
|
||||||
</p>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with(
|
|
||||||
{
|
|
||||||
type: P.union(
|
|
||||||
FieldType.NAME,
|
|
||||||
FieldType.INITIALS,
|
|
||||||
FieldType.EMAIL,
|
|
||||||
FieldType.NUMBER,
|
|
||||||
FieldType.RADIO,
|
|
||||||
FieldType.CHECKBOX,
|
|
||||||
FieldType.DROPDOWN,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
() => field.customText,
|
|
||||||
)
|
|
||||||
.with({ type: FieldType.TEXT }, () => field.customText.substring(0, 20) + '...')
|
|
||||||
.with({ type: FieldType.DATE }, () =>
|
|
||||||
convertToLocalSystemFormat(
|
|
||||||
field.customText,
|
|
||||||
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
|
||||||
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
|
|
||||||
.exhaustive()}
|
|
||||||
|
|
||||||
{field.recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
|
||||||
<p
|
|
||||||
className={cn('text-muted-foreground text-lg duration-200', {
|
|
||||||
'font-signature sm:text-xl md:text-2xl':
|
|
||||||
field.type === FieldType.SIGNATURE ||
|
|
||||||
field.type === FieldType.FREE_SIGNATURE,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FieldRootContainer>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</ElementVisible>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
@ -17,7 +17,13 @@ import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
|||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@documenso/ui/primitives/tooltip';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
@ -30,6 +36,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
const { folderId } = useParams();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
@ -69,6 +76,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
|||||||
title: file.name,
|
title: file.name,
|
||||||
documentDataId: response.id,
|
documentDataId: response.id,
|
||||||
timezone: userTimezone,
|
timezone: userTimezone,
|
||||||
|
folderId: folderId ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
@ -85,7 +93,11 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
await navigate(
|
||||||
|
folderId
|
||||||
|
? `${formatDocumentsPath(team?.url)}/f/${folderId}/${id}/edit`
|
||||||
|
: `${formatDocumentsPath(team?.url)}/${id}/edit`,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
@ -121,25 +133,31 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
<DocumentDropzone
|
<TooltipProvider>
|
||||||
className="h-[min(400px,50vh)]"
|
<Tooltip>
|
||||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
<TooltipTrigger asChild>
|
||||||
disabledMessage={disabledMessage}
|
<div>
|
||||||
onDrop={onFileDrop}
|
<DocumentDropzone
|
||||||
onDropRejected={onFileDropRejected}
|
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||||
/>
|
disabledMessage={disabledMessage}
|
||||||
|
onDrop={onFileDrop}
|
||||||
<div className="absolute -bottom-6 right-0">
|
onDropRejected={onFileDropRejected}
|
||||||
{team?.id === undefined &&
|
/>
|
||||||
remaining.documents > 0 &&
|
</div>
|
||||||
Number.isFinite(remaining.documents) && (
|
</TooltipTrigger>
|
||||||
<p className="text-muted-foreground/60 text-xs">
|
{team?.id === undefined &&
|
||||||
<Trans>
|
remaining.documents > 0 &&
|
||||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
Number.isFinite(remaining.documents) && (
|
||||||
</Trans>
|
<TooltipContent>
|
||||||
</p>
|
<p className="text-sm">
|
||||||
)}
|
<Trans>
|
||||||
</div>
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||||
|
|||||||
88
apps/remix/app/components/general/folder/folder-card.tsx
Normal file
88
apps/remix/app/components/general/folder/folder-card.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { FolderIcon, PinIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||||
|
import { formatFolderCount } from '@documenso/lib/utils/format-folder-count';
|
||||||
|
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
export type FolderCardProps = {
|
||||||
|
folder: TFolderWithSubfolders;
|
||||||
|
onNavigate: (folderId: string) => void;
|
||||||
|
onMove: (folder: TFolderWithSubfolders) => void;
|
||||||
|
onPin: (folderId: string) => void;
|
||||||
|
onUnpin: (folderId: string) => void;
|
||||||
|
onSettings: (folder: TFolderWithSubfolders) => void;
|
||||||
|
onDelete: (folder: TFolderWithSubfolders) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FolderCard = ({
|
||||||
|
folder,
|
||||||
|
onNavigate,
|
||||||
|
onMove,
|
||||||
|
onPin,
|
||||||
|
onUnpin,
|
||||||
|
onSettings,
|
||||||
|
onDelete,
|
||||||
|
}: FolderCardProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={folder.id}
|
||||||
|
className="border-border hover:border-muted-foreground/40 group relative flex flex-col rounded-lg border p-4 transition-all hover:shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<button
|
||||||
|
className="flex items-center space-x-2 text-left"
|
||||||
|
onClick={() => onNavigate(folder.id)}
|
||||||
|
>
|
||||||
|
<FolderIcon className="text-documenso h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium">{folder.name}</h3>
|
||||||
|
{folder.pinned && <PinIcon className="text-documenso h-3 w-3" />}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex space-x-2 text-xs text-gray-500">
|
||||||
|
<span>
|
||||||
|
{formatFolderCount(
|
||||||
|
folder.type === FolderType.TEMPLATE
|
||||||
|
? folder._count.templates
|
||||||
|
: folder._count.documents,
|
||||||
|
folder.type === FolderType.TEMPLATE ? 'template' : 'document',
|
||||||
|
folder.type === FolderType.TEMPLATE ? 'templates' : 'documents',
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatFolderCount(folder._count.subfolders, 'folder', 'folders')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100">
|
||||||
|
•••
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onMove(folder)}>Move</DropdownMenuItem>
|
||||||
|
{folder.pinned ? (
|
||||||
|
<DropdownMenuItem onClick={() => onUnpin(folder.id)}>Unpin</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={() => onPin(folder.id)}>Pin</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={() => onSettings(folder)}>Settings</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-red-500" onClick={() => onDelete(folder)}>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type LegacyFieldWarningPopoverProps = {
|
||||||
|
type?: 'document' | 'template';
|
||||||
|
documentId?: number;
|
||||||
|
templateId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LegacyFieldWarningPopover = ({
|
||||||
|
type = 'document',
|
||||||
|
documentId,
|
||||||
|
templateId,
|
||||||
|
}: LegacyFieldWarningPopoverProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const revalidator = useRevalidator();
|
||||||
|
|
||||||
|
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } =
|
||||||
|
trpc.template.updateTemplate.useMutation();
|
||||||
|
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } =
|
||||||
|
trpc.document.updateDocument.useMutation();
|
||||||
|
|
||||||
|
const onUpdateFieldsClick = async () => {
|
||||||
|
if (type === 'document') {
|
||||||
|
if (!documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateDocument({
|
||||||
|
documentId,
|
||||||
|
data: {
|
||||||
|
useLegacyFieldInsertion: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'template') {
|
||||||
|
if (!templateId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateTemplate({
|
||||||
|
templateId,
|
||||||
|
data: {
|
||||||
|
useLegacyFieldInsertion: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void revalidator.revalidate();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Fields updated`),
|
||||||
|
description: _(
|
||||||
|
msg`The fields have been updated to the new field insertion method successfully`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverHover
|
||||||
|
side="bottom"
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline" className="h-9 w-9 p-0">
|
||||||
|
<span className="sr-only">
|
||||||
|
{type === 'document' ? (
|
||||||
|
<Trans>Document is using legacy field insertion</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Template is using legacy field insertion</Trans>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{type === 'document' ? (
|
||||||
|
<Trans>
|
||||||
|
This document is using legacy field insertion, we recommend using the new field
|
||||||
|
insertion method for more accurate results.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
This template is using legacy field insertion, we recommend using the new field
|
||||||
|
insertion method for more accurate results.
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-2 flex w-full items-center justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
loading={isUpdatingDocument || isUpdatingTemplate}
|
||||||
|
onClick={onUpdateFieldsClick}
|
||||||
|
>
|
||||||
|
<Trans>Update Fields</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverHover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
|
||||||
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
|
import type { DocumentData } from '@documenso/prisma/client';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type ShareDocumentDownloadButtonProps = {
|
||||||
|
title: string;
|
||||||
|
documentData: DocumentData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShareDocumentDownloadButton = ({
|
||||||
|
title,
|
||||||
|
documentData,
|
||||||
|
}: ShareDocumentDownloadButtonProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const onDownloadClick = async () => {
|
||||||
|
try {
|
||||||
|
setIsDownloading(true);
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadPDF({ documentData, fileName: title });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button loading={isDownloading} onClick={onDownloadClick}>
|
||||||
|
{!isDownloading && <Download className="mr-2 h-4 w-4" />}
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -167,6 +167,7 @@ export const TemplateEditForm = ({
|
|||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
meta: {
|
meta: {
|
||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@ -207,7 +208,11 @@ export const TemplateEditForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate(templateRootPath);
|
const templatePath = template.folderId
|
||||||
|
? `${templateRootPath}/f/${template.folderId}`
|
||||||
|
: templateRootPath;
|
||||||
|
|
||||||
|
await navigate(templatePath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@ -270,6 +275,7 @@ export const TemplateEditForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
signingOrder={template.templateMeta?.signingOrder}
|
signingOrder={template.templateMeta?.signingOrder}
|
||||||
|
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
||||||
templateDirectLink={template.directLink}
|
templateDirectLink={template.directLink}
|
||||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
isEnterprise={isEnterprise}
|
isEnterprise={isEnterprise}
|
||||||
|
|||||||
@ -14,9 +14,13 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
export type SigningVolume = {
|
export type SigningVolume = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
email: string;
|
||||||
signingVolume: number;
|
signingVolume: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
planId: string;
|
planId: string;
|
||||||
|
userId?: number | null;
|
||||||
|
teamId?: number | null;
|
||||||
|
isTeam: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LeaderboardTableProps = {
|
type LeaderboardTableProps = {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
@ -9,6 +8,7 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
@ -18,11 +18,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentsTableActionButtonProps = {
|
export type DocumentsTableActionButtonProps = {
|
||||||
row: Document & {
|
row: TDocumentRow;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
recipients: Recipient[];
|
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
|
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
|
||||||
@ -44,6 +40,9 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team?.url);
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
const formatPath = row.folderId
|
||||||
|
? `${documentsPath}/f/${row.folderId}/${row.id}/edit`
|
||||||
|
: `${documentsPath}/${row.id}/edit`;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
@ -96,7 +95,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||||
() => (
|
() => (
|
||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link to={`${documentsPath}/${row.id}/edit`}>
|
<Link to={formatPath}>
|
||||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
<Trans>Edit</Trans>
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
|
||||||
import { DocumentStatus, RecipientRole } from '@prisma/client';
|
import { DocumentStatus, RecipientRole } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
@ -11,6 +10,8 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
|
FileDown,
|
||||||
|
FolderInput,
|
||||||
Loader,
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
MoveRight,
|
MoveRight,
|
||||||
@ -22,6 +23,7 @@ import { Link } from 'react-router';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
@ -43,14 +45,14 @@ import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/d
|
|||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentsTableActionDropdownProps = {
|
export type DocumentsTableActionDropdownProps = {
|
||||||
row: Document & {
|
row: TDocumentRow;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
onMoveDocument?: () => void;
|
||||||
recipients: Recipient[];
|
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdownProps) => {
|
export const DocumentsTableActionDropdown = ({
|
||||||
|
row,
|
||||||
|
onMoveDocument,
|
||||||
|
}: DocumentsTableActionDropdownProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
@ -73,6 +75,9 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
|||||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team?.url);
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
const formatPath = row.folderId
|
||||||
|
? `${documentsPath}/f/${row.folderId}/${row.id}/edit`
|
||||||
|
: `${documentsPath}/${row.id}/edit`;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
@ -100,6 +105,32 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDownloadOriginalClick = async () => {
|
||||||
|
try {
|
||||||
|
const document = !recipient
|
||||||
|
? await trpcClient.document.getDocumentById.query({
|
||||||
|
documentId: row.id,
|
||||||
|
})
|
||||||
|
: await trpcClient.document.getDocumentByToken.query({
|
||||||
|
token: recipient.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentData = document?.documentData;
|
||||||
|
|
||||||
|
if (!documentData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await downloadPDF({ documentData, fileName: row.title, version: 'original' });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -141,7 +172,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
||||||
<Link to={`${documentsPath}/${row.id}/edit`}>
|
<Link to={formatPath}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<Trans>Edit</Trans>
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
@ -152,6 +183,11 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
|||||||
<Trans>Download</Trans>
|
<Trans>Download</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||||
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Download Original</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
<Trans>Duplicate</Trans>
|
<Trans>Duplicate</Trans>
|
||||||
@ -165,6 +201,13 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onMoveDocument && (
|
||||||
|
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
|
||||||
|
<FolderInput className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Move to Folder</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* No point displaying this if there's no functionality. */}
|
{/* No point displaying this if there's no functionality. */}
|
||||||
{/* <DropdownMenuItem disabled>
|
{/* <DropdownMenuItem disabled>
|
||||||
<XCircle className="mr-2 h-4 w-4" />
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@ -1,16 +1,12 @@
|
|||||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
export type DataTableTitleProps = {
|
export type DataTableTitleProps = {
|
||||||
row: Document & {
|
row: TDocumentRow;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
team: Pick<Team, 'url'> | null;
|
|
||||||
recipients: Recipient[];
|
|
||||||
};
|
|
||||||
teamUrl?: string;
|
teamUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -29,11 +29,17 @@ export type DocumentsTableProps = {
|
|||||||
data?: TFindDocumentsResponse;
|
data?: TFindDocumentsResponse;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isLoadingError?: boolean;
|
isLoadingError?: boolean;
|
||||||
|
onMoveDocument?: (documentId: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
||||||
|
|
||||||
export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTableProps) => {
|
export const DocumentsTable = ({
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isLoadingError,
|
||||||
|
onMoveDocument,
|
||||||
|
}: DocumentsTableProps) => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
@ -80,12 +86,15 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
|
|||||||
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
|
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<DocumentsTableActionButton row={row.original} />
|
<DocumentsTableActionButton row={row.original} />
|
||||||
<DocumentsTableActionDropdown row={row.original} />
|
<DocumentsTableActionDropdown
|
||||||
|
row={row.original}
|
||||||
|
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.id) : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] satisfies DataTableColumnDef<DocumentsTableRow>[];
|
] satisfies DataTableColumnDef<DocumentsTableRow>[];
|
||||||
}, [team]);
|
}, [team, onMoveDocument]);
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@ -171,6 +180,9 @@ const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
|||||||
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
|
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
|
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
|
||||||
|
const formatPath = row.folderId
|
||||||
|
? `${documentsPath}/f/${row.folderId}/${row.id}`
|
||||||
|
: `${documentsPath}/${row.id}`;
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isOwner,
|
isOwner,
|
||||||
@ -179,7 +191,7 @@ const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
|||||||
})
|
})
|
||||||
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
|
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
|
||||||
<Link
|
<Link
|
||||||
to={`${documentsPath}/${row.id}`}
|
to={formatPath}
|
||||||
title={row.title}
|
title={row.title}
|
||||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -2,7 +2,16 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
|
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
|
||||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2, Upload } from 'lucide-react';
|
import {
|
||||||
|
Copy,
|
||||||
|
Edit,
|
||||||
|
FolderIcon,
|
||||||
|
MoreHorizontal,
|
||||||
|
MoveRight,
|
||||||
|
Share2Icon,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
@ -19,6 +28,7 @@ import { TemplateDeleteDialog } from '../dialogs/template-delete-dialog';
|
|||||||
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
|
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
|
||||||
import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog';
|
import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog';
|
||||||
import { TemplateMoveDialog } from '../dialogs/template-move-dialog';
|
import { TemplateMoveDialog } from '../dialogs/template-move-dialog';
|
||||||
|
import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog';
|
||||||
|
|
||||||
export type TemplatesTableActionDropdownProps = {
|
export type TemplatesTableActionDropdownProps = {
|
||||||
row: Template & {
|
row: Template & {
|
||||||
@ -50,13 +60,18 @@ export const TemplatesTableActionDropdown = ({
|
|||||||
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
|
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||||
|
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
|
||||||
|
|
||||||
const isOwner = row.userId === user.id;
|
const isOwner = row.userId === user.id;
|
||||||
const isTeamTemplate = row.teamId === teamId;
|
const isTeamTemplate = row.teamId === teamId;
|
||||||
|
|
||||||
|
const formatPath = row.folderId
|
||||||
|
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
|
||||||
|
: `${templateRootPath}/${row.id}/edit`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger data-testid="template-table-action-btn">
|
||||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
@ -64,7 +79,7 @@ export const TemplatesTableActionDropdown = ({
|
|||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||||
<Link to={`${templateRootPath}/${row.id}/edit`}>
|
<Link to={formatPath}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<Trans>Edit</Trans>
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
@ -83,6 +98,11 @@ export const TemplatesTableActionDropdown = ({
|
|||||||
<Trans>Direct link</Trans>
|
<Trans>Direct link</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={() => setMoveToFolderDialogOpen(true)}>
|
||||||
|
<FolderIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Move to Folder</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{!teamId && !row.teamId && (
|
{!teamId && !row.teamId && (
|
||||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||||
<MoveRight className="mr-2 h-4 w-4" />
|
<MoveRight className="mr-2 h-4 w-4" />
|
||||||
@ -135,6 +155,14 @@ export const TemplatesTableActionDropdown = ({
|
|||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TemplateMoveToFolderDialog
|
||||||
|
templateId={row.id}
|
||||||
|
templateTitle={row.title}
|
||||||
|
isOpen={isMoveToFolderDialogOpen}
|
||||||
|
onOpenChange={setMoveToFolderDialogOpen}
|
||||||
|
currentFolderId={row.folderId}
|
||||||
|
/>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -54,6 +54,11 @@ export const TemplatesTable = ({
|
|||||||
const formatTemplateLink = (row: TemplatesTableRow) => {
|
const formatTemplateLink = (row: TemplatesTableRow) => {
|
||||||
const isCurrentTeamTemplate = team?.url && row.team?.url === team?.url;
|
const isCurrentTeamTemplate = team?.url && row.team?.url === team?.url;
|
||||||
const path = formatTemplatesPath(isCurrentTeamTemplate ? team?.url : undefined);
|
const path = formatTemplatesPath(isCurrentTeamTemplate ? team?.url : undefined);
|
||||||
|
|
||||||
|
if (row.folderId) {
|
||||||
|
return `${path}/f/${row.folderId}/${row.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
return `${path}/${row.id}`;
|
return `${path}/${row.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -90,7 +90,7 @@ export default function AdminDocumentsPage() {
|
|||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Link to={`/admin/users/${row.original.user.id}`}>
|
<Link to={`/admin/users/${row.original.user.id}`}>
|
||||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||||
<AvatarFallback className="text-xs text-gray-400">
|
<AvatarFallback className="text-muted-foreground text-xs">
|
||||||
{avatarFallbackText}
|
{avatarFallbackText}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@ -99,7 +99,7 @@ export default function AdminDocumentsPage() {
|
|||||||
|
|
||||||
<TooltipContent className="flex max-w-xs items-center gap-2">
|
<TooltipContent className="flex max-w-xs items-center gap-2">
|
||||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||||
<AvatarFallback className="text-xs text-gray-400">
|
<AvatarFallback className="text-muted-foreground text-xs">
|
||||||
{avatarFallbackText}
|
{avatarFallbackText}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@ -2,7 +2,10 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
|
|
||||||
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
|
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
|
||||||
|
|
||||||
import { AdminLeaderboardTable } from '~/components/tables/admin-leaderboard-table';
|
import {
|
||||||
|
AdminLeaderboardTable,
|
||||||
|
type SigningVolume,
|
||||||
|
} from '~/components/tables/admin-leaderboard-table';
|
||||||
|
|
||||||
import type { Route } from './+types/leaderboard';
|
import type { Route } from './+types/leaderboard';
|
||||||
|
|
||||||
@ -25,7 +28,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
||||||
const search = url.searchParams.get('search') || '';
|
const search = url.searchParams.get('search') || '';
|
||||||
|
|
||||||
const { leaderboard: signingVolume, totalPages } = await getSigningVolume({
|
const { leaderboard, totalPages } = await getSigningVolume({
|
||||||
search,
|
search,
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
@ -33,8 +36,14 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
|
||||||
|
...item,
|
||||||
|
name: item.name || '',
|
||||||
|
createdAt: item.createdAt || new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
signingVolume,
|
signingVolume: typedSigningVolume,
|
||||||
totalPages,
|
totalPages,
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
@ -48,9 +57,11 @@ export default function Leaderboard({ loaderData }: Route.ComponentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">
|
<div className="flex items-center">
|
||||||
<Trans>Signing Volume</Trans>
|
<h2 className="text-4xl font-semibold">
|
||||||
</h2>
|
<Trans>Signing Volume</Trans>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<AdminLeaderboardTable
|
<AdminLeaderboardTable
|
||||||
signingVolume={signingVolume}
|
signingVolume={signingVolume}
|
||||||
|
|||||||
@ -23,12 +23,12 @@ import {
|
|||||||
getUsersWithSubscriptionsCount,
|
getUsersWithSubscriptionsCount,
|
||||||
} from '@documenso/lib/server-only/admin/get-users-stats';
|
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||||
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
|
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
|
||||||
|
|
||||||
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
|
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
|
||||||
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
|
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
|
||||||
import { CardMetric } from '~/components/general/metric-card';
|
import { CardMetric } from '~/components/general/metric-card';
|
||||||
|
|
||||||
|
import { version } from '../../../../package.json';
|
||||||
import type { Route } from './+types/stats';
|
import type { Route } from './+types/stats';
|
||||||
|
|
||||||
export async function loader() {
|
export async function loader() {
|
||||||
@ -89,7 +89,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
value={usersWithSubscriptionsCount}
|
value={usersWithSubscriptionsCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${env('APP_VERSION')}`} />
|
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${version}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 gap-8">
|
<div className="mt-16 gap-8">
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
|
|||||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@ -24,7 +25,6 @@ import { DocumentPageViewDropdown } from '~/components/general/document/document
|
|||||||
import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
|
import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
|
||||||
import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
|
import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
|
||||||
import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
|
import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
|
||||||
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
|
|
||||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||||
import {
|
import {
|
||||||
DocumentStatus as DocumentStatusComponent,
|
DocumentStatus as DocumentStatusComponent,
|
||||||
@ -64,6 +64,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw redirect(documentRootPath);
|
throw redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document?.folderId) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
const documentVisibility = document?.visibility;
|
const documentVisibility = document?.visibility;
|
||||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||||
@ -200,8 +204,14 @@ export default function DocumentPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{document.status === DocumentStatus.PENDING && (
|
{document.status !== DocumentStatus.COMPLETED && (
|
||||||
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
|
<DocumentReadOnlyFields
|
||||||
|
fields={fields}
|
||||||
|
documentMeta={documentMeta || undefined}
|
||||||
|
showRecipientTooltip={true}
|
||||||
|
showRecipientColors={true}
|
||||||
|
recipientIds={recipients.map((recipient) => recipient.id)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|||||||
|
|
||||||
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
|
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
||||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
@ -48,6 +49,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw redirect(documentRootPath);
|
throw redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document?.folderId) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
const documentVisibility = document?.visibility;
|
const documentVisibility = document?.visibility;
|
||||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||||
@ -82,7 +87,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
document,
|
document: {
|
||||||
|
...document,
|
||||||
|
folder: null,
|
||||||
|
},
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
isDocumentEnterprise,
|
isDocumentEnterprise,
|
||||||
});
|
});
|
||||||
@ -100,29 +108,43 @@ export default function DocumentEditPage() {
|
|||||||
<Trans>Documents</Trans>
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1
|
<div className="mt-4 flex w-full items-end justify-between">
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
<div className="flex-1">
|
||||||
title={document.title}
|
<h1
|
||||||
>
|
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
{document.title}
|
title={document.title}
|
||||||
</h1>
|
>
|
||||||
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
<DocumentStatus
|
||||||
|
inheritColor
|
||||||
|
status={document.status}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
{recipients.length > 0 && (
|
||||||
<div className="text-muted-foreground flex items-center">
|
<div className="text-muted-foreground flex items-center">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
<StackAvatarsWithTooltip
|
<StackAvatarsWithTooltip
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
documentStatus={document.status}
|
documentStatus={document.status}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
||||||
</span>
|
</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{document.useLegacyFieldInsertion && (
|
||||||
|
<div>
|
||||||
|
<LegacyFieldWarningPopover type="document" documentId={document.id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -43,24 +43,26 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw redirect(documentRootPath);
|
throw redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Todo: Get full document instead?
|
const document = await getDocumentById({
|
||||||
const [document, recipients] = await Promise.all([
|
documentId,
|
||||||
getDocumentById({
|
userId: user.id,
|
||||||
documentId,
|
teamId: team?.id,
|
||||||
userId: user.id,
|
}).catch(() => null);
|
||||||
teamId: team?.id,
|
|
||||||
}).catch(() => null),
|
|
||||||
getRecipientsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
if (!document || !document.documentData) {
|
||||||
throw redirect(documentRootPath);
|
throw redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.folderId) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = await getRecipientsForDocument({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
document,
|
document,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { useSearchParams } from 'react-router';
|
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
@ -14,12 +16,21 @@ import {
|
|||||||
type TFindDocumentsInternalResponse,
|
type TFindDocumentsInternalResponse,
|
||||||
ZFindDocumentsInternalRequestSchema,
|
ZFindDocumentsInternalRequestSchema,
|
||||||
} from '@documenso/trpc/server/document-router/schema';
|
} from '@documenso/trpc/server/document-router/schema';
|
||||||
|
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
|
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||||
|
import { CreateFolderDialog } from '~/components/dialogs/folder-create-dialog';
|
||||||
|
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||||
|
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||||
|
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
|
||||||
|
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
|
||||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
||||||
|
import { FolderCard } from '~/components/general/folder/folder-card';
|
||||||
import { PeriodSelector } from '~/components/general/period-selector';
|
import { PeriodSelector } from '~/components/general/period-selector';
|
||||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||||
@ -43,9 +54,22 @@ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
|||||||
|
|
||||||
export default function DocumentsPage() {
|
export default function DocumentsPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||||
|
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
|
||||||
|
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||||
|
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||||
|
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
||||||
|
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||||
|
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||||
|
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||||
|
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||||
|
|
||||||
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
|
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
|
||||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||||
[ExtendedDocumentStatus.PENDING]: 0,
|
[ExtendedDocumentStatus.PENDING]: 0,
|
||||||
@ -66,9 +90,18 @@ export default function DocumentsPage() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refetch the documents when the team URL changes.
|
const {
|
||||||
|
data: foldersData,
|
||||||
|
isLoading: isFoldersLoading,
|
||||||
|
refetch: refetchFolders,
|
||||||
|
} = trpc.folder.getFolders.useQuery({
|
||||||
|
type: FolderType.DOCUMENT,
|
||||||
|
parentId: null,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refetch();
|
void refetch();
|
||||||
|
void refetchFolders();
|
||||||
}, [team?.url]);
|
}, [team?.url]);
|
||||||
|
|
||||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||||
@ -93,76 +126,265 @@ export default function DocumentsPage() {
|
|||||||
}
|
}
|
||||||
}, [data?.stats]);
|
}, [data?.stats]);
|
||||||
|
|
||||||
|
const navigateToFolder = (folderId?: string | null) => {
|
||||||
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
if (folderId) {
|
||||||
|
void navigate(`${documentsPath}/f/${folderId}`);
|
||||||
|
} else {
|
||||||
|
void navigate(documentsPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewAllFolders = () => {
|
||||||
|
void navigate(`${formatDocumentsPath(team?.url)}/folders`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<DocumentDropZoneWrapper>
|
||||||
<DocumentUploadDropzone />
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex flex-1 items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
||||||
|
onClick={() => navigateToFolder(null)}
|
||||||
|
>
|
||||||
|
<HomeIcon className="h-4 w-4" />
|
||||||
|
<span>Home</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
{foldersData?.breadcrumbs.map((folder) => (
|
||||||
<div className="flex flex-row items-center">
|
<div key={folder.id} className="flex items-center space-x-2">
|
||||||
{team && (
|
<span>/</span>
|
||||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
<Button
|
||||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
variant="ghost"
|
||||||
<AvatarFallback className="text-xs text-gray-400">
|
size="sm"
|
||||||
{team.name.slice(0, 1)}
|
className="flex items-center space-x-2 pl-1 hover:bg-transparent"
|
||||||
</AvatarFallback>
|
onClick={() => navigateToFolder(folder.id)}
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h1 className="text-4xl font-semibold">
|
|
||||||
<Trans>Documents</Trans>
|
|
||||||
</h1>
|
|
||||||
</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,
|
|
||||||
].map((value) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={value}
|
|
||||||
className="hover:text-foreground min-w-[60px]"
|
|
||||||
value={value}
|
|
||||||
asChild
|
|
||||||
>
|
>
|
||||||
<Link to={getTabHref(value)} preventScrollReset>
|
<FolderIcon className="h-4 w-4" />
|
||||||
<DocumentStatus status={value} />
|
<span>{folder.name}</span>
|
||||||
|
</Button>
|
||||||
{value !== ExtendedDocumentStatus.ALL && (
|
</div>
|
||||||
<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>
|
||||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
|
||||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||||
|
<DocumentUploadDropzone />
|
||||||
|
<CreateFolderDialog />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
{isFoldersLoading ? (
|
||||||
<div>
|
<div className="mt-6 flex justify-center">
|
||||||
{data && data.count === 0 ? (
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
<DocumentsTableEmptyState
|
</div>
|
||||||
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
) : (
|
||||||
/>
|
<>
|
||||||
) : (
|
{foldersData?.folders?.some((folder) => folder.pinned) && (
|
||||||
<DocumentsTable data={data} isLoading={isLoading} isLoadingError={isLoadingError} />
|
<div className="mt-6">
|
||||||
)}
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{foldersData.folders
|
||||||
|
.filter((folder) => folder.pinned)
|
||||||
|
.map((folder) => (
|
||||||
|
<FolderCard
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
onNavigate={navigateToFolder}
|
||||||
|
onMove={(folder) => {
|
||||||
|
setFolderToMove(folder);
|
||||||
|
setIsMovingFolder(true);
|
||||||
|
}}
|
||||||
|
onPin={(folderId) => void pinFolder({ folderId })}
|
||||||
|
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||||
|
onSettings={(folder) => {
|
||||||
|
setFolderToSettings(folder);
|
||||||
|
setIsSettingsFolderOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={(folder) => {
|
||||||
|
setFolderToDelete(folder);
|
||||||
|
setIsDeletingFolder(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{foldersData?.folders
|
||||||
|
?.filter((folder) => !folder.pinned)
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((folder) => (
|
||||||
|
<FolderCard
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
onNavigate={navigateToFolder}
|
||||||
|
onMove={(folder) => {
|
||||||
|
setFolderToMove(folder);
|
||||||
|
setIsMovingFolder(true);
|
||||||
|
}}
|
||||||
|
onPin={(folderId) => void pinFolder({ folderId })}
|
||||||
|
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||||
|
onSettings={(folder) => {
|
||||||
|
setFolderToSettings(folder);
|
||||||
|
setIsSettingsFolderOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={(folder) => {
|
||||||
|
setFolderToDelete(folder);
|
||||||
|
setIsDeletingFolder(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-center">
|
||||||
|
{foldersData && foldersData.folders?.length > 12 && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => void handleViewAllFolders()}
|
||||||
|
>
|
||||||
|
View all folders
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
{team && (
|
||||||
|
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||||
|
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||||
|
<AvatarFallback className="text-muted-foreground text-xs">
|
||||||
|
{team.name.slice(0, 1)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<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,
|
||||||
|
].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>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<div>
|
||||||
|
{data &&
|
||||||
|
data.count === 0 &&
|
||||||
|
(!foldersData?.folders.length || foldersData.folders.length === 0) ? (
|
||||||
|
<DocumentsTableEmptyState
|
||||||
|
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DocumentsTable
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isLoadingError={isLoadingError}
|
||||||
|
onMoveDocument={(documentId) => {
|
||||||
|
setDocumentToMove(documentId);
|
||||||
|
setIsMovingDocument(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{documentToMove && (
|
||||||
|
<DocumentMoveToFolderDialog
|
||||||
|
documentId={documentToMove}
|
||||||
|
open={isMovingDocument}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsMovingDocument(open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setDocumentToMove(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FolderMoveDialog
|
||||||
|
foldersData={foldersData?.folders}
|
||||||
|
folder={folderToMove}
|
||||||
|
isOpen={isMovingFolder}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsMovingFolder(open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setFolderToMove(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FolderSettingsDialog
|
||||||
|
folder={folderToSettings}
|
||||||
|
isOpen={isSettingsFolderOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsSettingsFolderOpen(open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setFolderToSettings(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FolderDeleteDialog
|
||||||
|
folder={folderToDelete}
|
||||||
|
isOpen={isDeletingFolder}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsDeletingFolder(open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setFolderToDelete(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DocumentDropZoneWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,272 @@
|
|||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||||
|
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
||||||
|
import { Link, redirect } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
|
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
|
import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet';
|
||||||
|
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
||||||
|
import { DocumentPageViewDropdown } from '~/components/general/document/document-page-view-dropdown';
|
||||||
|
import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
|
||||||
|
import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
|
||||||
|
import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
|
||||||
|
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||||
|
import {
|
||||||
|
DocumentStatus as DocumentStatusComponent,
|
||||||
|
FRIENDLY_STATUS_MAP,
|
||||||
|
} from '~/components/general/document/document-status';
|
||||||
|
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/documents.f.$folderId.$id._index';
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { user } = await getSession(request);
|
||||||
|
|
||||||
|
let team: TGetTeamByUrlResponse | null = null;
|
||||||
|
|
||||||
|
if (params.teamUrl) {
|
||||||
|
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, folderId } = params;
|
||||||
|
|
||||||
|
const documentId = Number(id);
|
||||||
|
|
||||||
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
if (!documentId || !folderId) {
|
||||||
|
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
folderId,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (document?.teamId && !team?.url) {
|
||||||
|
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentVisibility = document?.visibility;
|
||||||
|
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||||
|
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
let canAccessDocument = true;
|
||||||
|
|
||||||
|
if (team && !isRecipient && document?.userId !== user.id) {
|
||||||
|
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||||
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||||
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||||
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
||||||
|
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
|
||||||
|
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
||||||
|
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
||||||
|
.otherwise(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document || !document.documentData || (team && !canAccessDocument)) {
|
||||||
|
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (team && !canAccessDocument) {
|
||||||
|
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.folderId !== folderId) {
|
||||||
|
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todo: Get full document instead?
|
||||||
|
const [recipients, fields] = await Promise.all([
|
||||||
|
getRecipientsForDocument({
|
||||||
|
documentId,
|
||||||
|
teamId: team?.id,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
getFieldsForDocument({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const documentWithRecipients = {
|
||||||
|
...document,
|
||||||
|
recipients,
|
||||||
|
};
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
document: documentWithRecipients,
|
||||||
|
documentRootPath,
|
||||||
|
fields,
|
||||||
|
folderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentPage() {
|
||||||
|
const loaderData = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
|
const { document, documentRootPath, fields, folderId } = loaderData;
|
||||||
|
|
||||||
|
const { recipients, documentData, documentMeta } = document;
|
||||||
|
|
||||||
|
// This was a feature flag. Leave to false since it's not ready.
|
||||||
|
const isDocumentHistoryEnabled = false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
{document.status === DocumentStatus.PENDING && (
|
||||||
|
<DocumentRecipientLinkCopyDialog recipients={recipients} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath}
|
||||||
|
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
<Trans>Documents</Trans>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-between truncate">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
|
title={document.title}
|
||||||
|
>
|
||||||
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<DocumentStatusComponent
|
||||||
|
inheritColor
|
||||||
|
status={document.status}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{recipients.length > 0 && (
|
||||||
|
<div className="text-muted-foreground flex items-center">
|
||||||
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={recipients}
|
||||||
|
documentStatus={document.status}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Trans>{recipients.length} Recipient(s)</Trans>
|
||||||
|
</span>
|
||||||
|
</StackAvatarsWithTooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{document.deletedAt && (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
<Trans>Document deleted</Trans>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDocumentHistoryEnabled && (
|
||||||
|
<div className="self-end">
|
||||||
|
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Clock9 className="mr-1.5 h-4 w-4" />
|
||||||
|
<Trans>Document history</Trans>
|
||||||
|
</Button>
|
||||||
|
</DocumentHistorySheet>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||||
|
<Card
|
||||||
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
|
gradient
|
||||||
|
>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<PDFViewer document={document} key={documentData.id} documentData={documentData} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{document.status === DocumentStatus.PENDING && (
|
||||||
|
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
|
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<DocumentPageViewDropdown document={document} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 px-4 text-sm">
|
||||||
|
{match(document.status)
|
||||||
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
|
<Trans>This document has been signed by all recipients</Trans>
|
||||||
|
))
|
||||||
|
.with(DocumentStatus.REJECTED, () => (
|
||||||
|
<Trans>This document has been rejected by a recipient</Trans>
|
||||||
|
))
|
||||||
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
|
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||||
|
))
|
||||||
|
.with(DocumentStatus.PENDING, () => {
|
||||||
|
const pendingRecipients = recipients.filter(
|
||||||
|
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Plural
|
||||||
|
value={pendingRecipients.length}
|
||||||
|
one="Waiting on 1 recipient"
|
||||||
|
other="Waiting on # recipients"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.exhaustive()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t px-4 pt-4">
|
||||||
|
<DocumentPageViewButton document={document} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Document information section. */}
|
||||||
|
<DocumentPageViewInformation document={document} userId={user.id} />
|
||||||
|
|
||||||
|
{/* Recipients section. */}
|
||||||
|
<DocumentPageViewRecipients document={document} documentRootPath={documentRootPath} />
|
||||||
|
|
||||||
|
{/* Recent activity section. */}
|
||||||
|
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,155 @@
|
|||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { TeamMemberRole } from '@prisma/client';
|
||||||
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
|
import { Link, redirect } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
|
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||||
|
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
|
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||||
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
|
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/documents.$id.edit';
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { user } = await getSession(request);
|
||||||
|
|
||||||
|
let team: TGetTeamByUrlResponse | null = null;
|
||||||
|
|
||||||
|
if (params.teamUrl) {
|
||||||
|
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, folderId } = params;
|
||||||
|
|
||||||
|
const documentId = Number(id);
|
||||||
|
|
||||||
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
if (!documentId || Number.isNaN(documentId)) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!folderId) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await getDocumentWithDetailsById({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
folderId,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (document?.teamId && !team?.url) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentVisibility = document?.visibility;
|
||||||
|
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||||
|
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
let canAccessDocument = true;
|
||||||
|
|
||||||
|
if (!isRecipient && document?.userId !== user.id) {
|
||||||
|
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||||
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||||
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||||
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
||||||
|
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
|
||||||
|
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
||||||
|
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
||||||
|
.otherwise(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (team && !canAccessDocument) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.folderId !== folderId) {
|
||||||
|
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDocumentCompleted(document.status)) {
|
||||||
|
throw redirect(`${documentRootPath}/${documentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDocumentEnterprise = await isUserEnterprise({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
document: {
|
||||||
|
...document,
|
||||||
|
folder: null,
|
||||||
|
},
|
||||||
|
documentRootPath,
|
||||||
|
isDocumentEnterprise,
|
||||||
|
folderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentEditPage() {
|
||||||
|
const { document, documentRootPath, isDocumentEnterprise, folderId } =
|
||||||
|
useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
const { recipients } = document;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link
|
||||||
|
to={`${documentRootPath}/f/${folderId}`}
|
||||||
|
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
<Trans>Documents</Trans>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
|
title={document.title}
|
||||||
|
>
|
||||||
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
||||||
|
|
||||||
|
{recipients.length > 0 && (
|
||||||
|
<div className="text-muted-foreground flex items-center">
|
||||||
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={recipients}
|
||||||
|
documentStatus={document.status}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
||||||
|
</span>
|
||||||
|
</StackAvatarsWithTooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocumentEditForm
|
||||||
|
className="mt-6"
|
||||||
|
initialDocument={document}
|
||||||
|
documentRootPath={documentRootPath}
|
||||||
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,199 @@
|
|||||||
|
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 { Recipient } from '@prisma/client';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { Card } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
|
import { DocumentAuditLogDownloadButton } from '~/components/general/document/document-audit-log-download-button';
|
||||||
|
import { DocumentCertificateDownloadButton } from '~/components/general/document/document-certificate-download-button';
|
||||||
|
import {
|
||||||
|
DocumentStatus as DocumentStatusComponent,
|
||||||
|
FRIENDLY_STATUS_MAP,
|
||||||
|
} from '~/components/general/document/document-status';
|
||||||
|
import { DocumentLogsTable } from '~/components/tables/document-logs-table';
|
||||||
|
|
||||||
|
import type { Route } from './+types/documents.f.$folderId.$id.logs';
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { user } = await getSession(request);
|
||||||
|
|
||||||
|
let team: TGetTeamByUrlResponse | null = null;
|
||||||
|
|
||||||
|
if (params.teamUrl) {
|
||||||
|
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, folderId } = params;
|
||||||
|
|
||||||
|
const documentId = Number(id);
|
||||||
|
|
||||||
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
if (!documentId || Number.isNaN(documentId)) {
|
||||||
|
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!folderId) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todo: Get full document instead?
|
||||||
|
const [document, recipients] = await Promise.all([
|
||||||
|
getDocumentById({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
folderId,
|
||||||
|
}).catch(() => null),
|
||||||
|
getRecipientsForDocument({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!document || !document.documentData) {
|
||||||
|
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.folderId !== folderId) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
documentRootPath,
|
||||||
|
recipients,
|
||||||
|
folderId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { document, documentRootPath, recipients, folderId } = loaderData;
|
||||||
|
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
const documentInformation: { description: MessageDescriptor; value: string }[] = [
|
||||||
|
{
|
||||||
|
description: msg`Document title`,
|
||||||
|
value: document.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Document ID`,
|
||||||
|
value: document.id.toString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Document status`,
|
||||||
|
value: _(FRIENDLY_STATUS_MAP[document.status].label),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Created by`,
|
||||||
|
value: document.user.name
|
||||||
|
? `${document.user.name} (${document.user.email})`
|
||||||
|
: document.user.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Date created`,
|
||||||
|
value: DateTime.fromJSDate(document.createdAt)
|
||||||
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
|
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Last updated`,
|
||||||
|
value: DateTime.fromJSDate(document.updatedAt)
|
||||||
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
|
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Time zone`,
|
||||||
|
value: document.documentMeta?.timezone ?? 'N/A',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatRecipientText = (recipient: Recipient) => {
|
||||||
|
let text = recipient.email;
|
||||||
|
|
||||||
|
if (recipient.name) {
|
||||||
|
text = `${recipient.name} (${recipient.email})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[${recipient.role}] ${text}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link
|
||||||
|
to={`${documentRootPath}/f/${folderId}/${document.id}`}
|
||||||
|
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
<Trans>Document</Trans>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
|
title={document.title}
|
||||||
|
>
|
||||||
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-col justify-between sm:flex-row">
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<DocumentStatusComponent
|
||||||
|
inheritColor
|
||||||
|
status={document.status}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||||
|
<DocumentCertificateDownloadButton
|
||||||
|
className="mr-2"
|
||||||
|
documentId={document.id}
|
||||||
|
documentStatus={document.status}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentAuditLogDownloadButton documentId={document.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
||||||
|
{documentInformation.map((info, i) => (
|
||||||
|
<div className="text-foreground text-sm" key={i}>
|
||||||
|
<h3 className="font-semibold">{_(info.description)}</h3>
|
||||||
|
<p className="text-muted-foreground truncate">{info.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="text-foreground text-sm">
|
||||||
|
<h3 className="font-semibold">Recipients</h3>
|
||||||
|
<ul className="text-muted-foreground list-inside list-disc">
|
||||||
|
{recipients.map((recipient) => (
|
||||||
|
<li key={`recipient-${recipient.id}`}>
|
||||||
|
<span className="-ml-2">{formatRecipientText(recipient)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<DocumentLogsTable documentId={document.id} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,374 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
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 { trpc } from '@documenso/trpc/react';
|
||||||
|
import {
|
||||||
|
type TFindDocumentsInternalResponse,
|
||||||
|
ZFindDocumentsInternalRequestSchema,
|
||||||
|
} from '@documenso/trpc/server/document-router/schema';
|
||||||
|
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
|
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||||
|
import { CreateFolderDialog } from '~/components/dialogs/folder-create-dialog';
|
||||||
|
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||||
|
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||||
|
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-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 { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
||||||
|
import { FolderCard } from '~/components/general/folder/folder-card';
|
||||||
|
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 { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return appMetaTags('Documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||||
|
status: true,
|
||||||
|
period: true,
|
||||||
|
page: true,
|
||||||
|
perPage: true,
|
||||||
|
query: true,
|
||||||
|
}).extend({
|
||||||
|
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||||
|
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
|
||||||
|
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||||
|
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||||
|
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
||||||
|
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||||
|
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||||
|
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||||
|
|
||||||
|
const { folderId } = useParams();
|
||||||
|
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||||
|
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||||
|
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocumentsInternal.useQuery(
|
||||||
|
{
|
||||||
|
...findDocumentSearchParams,
|
||||||
|
folderId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: foldersData,
|
||||||
|
isLoading: isFoldersLoading,
|
||||||
|
refetch: refetchFolders,
|
||||||
|
} = trpc.folder.getFolders.useQuery({
|
||||||
|
parentId: folderId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refetch();
|
||||||
|
void refetchFolders();
|
||||||
|
}, [team?.url]);
|
||||||
|
|
||||||
|
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
|
params.set('status', value);
|
||||||
|
|
||||||
|
if (value === ExtendedDocumentStatus.ALL) {
|
||||||
|
params.delete('status');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.has('page')) {
|
||||||
|
params.delete('page');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatDocumentsPath(team?.url)}/f/${folderId}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.stats) {
|
||||||
|
setStats(data.stats);
|
||||||
|
}
|
||||||
|
}, [data?.stats]);
|
||||||
|
|
||||||
|
const navigateToFolder = (folderId?: string | null) => {
|
||||||
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
if (folderId) {
|
||||||
|
void navigate(`${documentsPath}/f/${folderId}`);
|
||||||
|
} else {
|
||||||
|
void navigate(documentsPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentDropZoneWrapper>
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex flex-1 items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
||||||
|
onClick={() => navigateToFolder(null)}
|
||||||
|
>
|
||||||
|
<HomeIcon className="h-4 w-4" />
|
||||||
|
<span>Home</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{foldersData?.breadcrumbs.map((folder) => (
|
||||||
|
<div key={folder.id} className="flex items-center space-x-2">
|
||||||
|
<span>/</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center space-x-2 pl-1 hover:bg-transparent"
|
||||||
|
onClick={() => navigateToFolder(folder.id)}
|
||||||
|
>
|
||||||
|
<FolderIcon className="h-4 w-4" />
|
||||||
|
<span>{folder.name}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||||
|
<DocumentUploadDropzone />
|
||||||
|
<CreateFolderDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFoldersLoading ? (
|
||||||
|
<div className="mt-6 flex justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{foldersData?.folders && foldersData.folders.some((folder) => folder.pinned) && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{foldersData.folders
|
||||||
|
.filter((folder) => folder.pinned)
|
||||||
|
.map((folder) => (
|
||||||
|
<FolderCard
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
onNavigate={navigateToFolder}
|
||||||
|
onMove={(folder) => {
|
||||||
|
setFolderToMove(folder);
|
||||||
|
setIsMovingFolder(true);
|
||||||
|
}}
|
||||||
|
onPin={(folderId) => void pinFolder({ folderId })}
|
||||||
|
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||||
|
onSettings={(folder) => {
|
||||||
|
setFolderToSettings(folder);
|
||||||
|
setIsSettingsFolderOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={(folder) => {
|
||||||
|
setFolderToDelete(folder);
|
||||||
|
setIsDeletingFolder(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{foldersData?.folders
|
||||||
|
.filter((folder) => !folder.pinned)
|
||||||
|
.map((folder) => (
|
||||||
|
<FolderCard
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
onNavigate={navigateToFolder}
|
||||||
|
onMove={(folder) => {
|
||||||
|
setFolderToMove(folder);
|
||||||
|
setIsMovingFolder(true);
|
||||||
|
}}
|
||||||
|
onPin={(folderId) => void pinFolder({ folderId })}
|
||||||
|
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||||
|
onSettings={(folder) => {
|
||||||
|
setFolderToSettings(folder);
|
||||||
|
setIsSettingsFolderOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={(folder) => {
|
||||||
|
setFolderToDelete(folder);
|
||||||
|
setIsDeletingFolder(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
{team && (
|
||||||
|
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||||
|
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||||
|
<AvatarFallback className="text-muted-foreground text-xs">
|
||||||
|
{team.name.slice(0, 1)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<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,
|
||||||
|
].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 &&
|
||||||
|
(!foldersData?.folders.length || foldersData.folders.length === 0) ? (
|
||||||
|
<DocumentsTableEmptyState
|
||||||
|
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DocumentsTable
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isLoadingError={isLoadingError}
|
||||||
|
onMoveDocument={(documentId) => {
|
||||||
|
setDocumentToMove(documentId);
|
||||||
|
setIsMovingDocument(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{documentToMove && (
|
||||||
|
<DocumentMoveToFolderDialog
|
||||||
|
documentId={documentToMove}
|
||||||
|
open={isMovingDocument}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsMovingDocument(open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setDocumentToMove(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
currentFolderId={folderId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FolderMoveDialog
|
||||||
|
foldersData={foldersData?.folders}
|
||||||
|
folder={folderToMove}
|
||||||
|
isOpen={isMovingFolder}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsMovingFolder(open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setFolderToMove(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FolderSettingsDialog
|
||||||
|
folder={folderToSettings}
|
||||||
|
isOpen={isSettingsFolderOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsSettingsFolderOpen(open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setFolderToSettings(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FolderDeleteDialog
|
||||||
|
folder={folderToDelete}
|
||||||
|
isOpen={isDeletingFolder}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsDeletingFolder(open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setFolderToDelete(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DocumentDropZoneWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,181 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { HomeIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { CreateFolderDialog } from '~/components/dialogs/folder-create-dialog';
|
||||||
|
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||||
|
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||||
|
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
|
||||||
|
import { FolderCard } from '~/components/general/folder/folder-card';
|
||||||
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return appMetaTags('Documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentsFoldersPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||||
|
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||||
|
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
||||||
|
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||||
|
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||||
|
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||||
|
|
||||||
|
const { data: foldersData, isLoading: isFoldersLoading } = trpc.folder.getFolders.useQuery({
|
||||||
|
type: FolderType.DOCUMENT,
|
||||||
|
parentId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||||
|
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||||
|
|
||||||
|
const navigateToFolder = (folderId?: string | null) => {
|
||||||
|
const documentsPath = formatDocumentsPath();
|
||||||
|
|
||||||
|
if (folderId) {
|
||||||
|
void navigate(`${documentsPath}/f/${folderId}`);
|
||||||
|
} else {
|
||||||
|
void navigate(documentsPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex flex-1 items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
||||||
|
onClick={() => navigateToFolder(null)}
|
||||||
|
>
|
||||||
|
<HomeIcon className="h-4 w-4" />
|
||||||
|
<span>Home</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-y-4 sm:flex-row sm:justify-end sm:gap-x-4">
|
||||||
|
<CreateFolderDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{isFoldersLoading ? (
|
||||||
|
<div className="mt-6 flex justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{foldersData?.folders?.some((folder) => folder.pinned) && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{foldersData.folders
|
||||||
|
.filter((folder) => folder.pinned)
|
||||||
|
.map((folder) => (
|
||||||
|
<FolderCard
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
onNavigate={navigateToFolder}
|
||||||
|
onMove={(folder) => {
|
||||||
|
setFolderToMove(folder);
|
||||||
|
setIsMovingFolder(true);
|
||||||
|
}}
|
||||||
|
onPin={(folderId) => void pinFolder({ folderId })}
|
||||||
|
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||||
|
onSettings={(folder) => {
|
||||||
|
setFolderToSettings(folder);
|
||||||
|
setIsSettingsFolderOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={(folder) => {
|
||||||
|
setFolderToDelete(folder);
|
||||||
|
setIsDeletingFolder(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-12">
|
||||||
|
<h1 className="truncate text-2xl font-semibold md:text-3xl">
|
||||||
|
<Trans>All Folders</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{foldersData?.folders
|
||||||
|
.filter((folder) => !folder.pinned)
|
||||||
|
.map((folder) => (
|
||||||
|
<FolderCard
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
onNavigate={navigateToFolder}
|
||||||
|
onMove={(folder) => {
|
||||||
|
setFolderToMove(folder);
|
||||||
|
setIsMovingFolder(true);
|
||||||
|
}}
|
||||||
|
onPin={(folderId) => void pinFolder({ folderId })}
|
||||||
|
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||||
|
onSettings={(folder) => {
|
||||||
|
setFolderToSettings(folder);
|
||||||
|
setIsSettingsFolderOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={(folder) => {
|
||||||
|
setFolderToDelete(folder);
|
||||||
|
setIsDeletingFolder(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FolderMoveDialog
|
||||||
|
foldersData={foldersData?.folders}
|
||||||
|
folder={folderToMove}
|
||||||
|
isOpen={isMovingFolder}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsMovingFolder(open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setFolderToMove(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FolderSettingsDialog
|
||||||
|
folder={folderToSettings}
|
||||||
|
isOpen={isSettingsFolderOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsSettingsFolderOpen(open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setFolderToSettings(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FolderDeleteDialog
|
||||||
|
folder={folderToDelete}
|
||||||
|
isOpen={isDeletingFolder}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsDeletingFolder(open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setFolderToDelete(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import DocumentPage, { loader } from '~/routes/_authenticated+/documents.f.$folderId.$id._index';
|
||||||
|
|
||||||
|
export { loader };
|
||||||
|
|
||||||
|
export default DocumentPage;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents.f.$folderId.$id.edit';
|
||||||
|
|
||||||
|
export { loader };
|
||||||
|
|
||||||
|
export default DocumentEditPage;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents.f.$folderId.$id.logs';
|
||||||
|
|
||||||
|
export { loader };
|
||||||
|
|
||||||
|
export default DocumentLogsPage;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents.f.$folderId._index';
|
||||||
|
|
||||||
|
export { meta };
|
||||||
|
|
||||||
|
export default DocumentsPage;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import DocumentsFoldersPage, { meta } from '~/routes/_authenticated+/documents.folders._index';
|
||||||
|
|
||||||
|
export { meta };
|
||||||
|
|
||||||
|
export default DocumentsFoldersPage;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import TemplatePage, { loader } from '~/routes/_authenticated+/templates.f.$folderId.$id._index';
|
||||||
|
|
||||||
|
export { loader };
|
||||||
|
|
||||||
|
export default TemplatePage;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user