@tribe-nest/forge 3.37.0 → 3.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -8,6 +8,9 @@ export {
|
|
|
8
8
|
useWorkInvite,
|
|
9
9
|
useAcceptWorkInvite,
|
|
10
10
|
useWorkProjectInvoices,
|
|
11
|
+
useWorkSharedDocuments,
|
|
12
|
+
useWorkOpenSharedDocument,
|
|
13
|
+
useUploadWorkDocument,
|
|
11
14
|
formatWorkMoney,
|
|
12
15
|
formatWorkDate,
|
|
13
16
|
type WorkProjectSummary,
|
|
@@ -21,5 +24,6 @@ export {
|
|
|
21
24
|
type WorkInviteContext,
|
|
22
25
|
type WorkInviteAcceptResult,
|
|
23
26
|
type WorkProjectInvoice,
|
|
27
|
+
type WorkSharedDocument,
|
|
24
28
|
type UseWorkTaskCommentsResult,
|
|
25
29
|
} from "./useWorkPortal";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
|
2
|
+
import axios from "axios";
|
|
2
3
|
import { useForge } from "../../../provider/ForgeProvider";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -89,6 +90,25 @@ export interface WorkTaskAttachment {
|
|
|
89
90
|
size: number | null;
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
/**
|
|
94
|
+
* A case-file document the firm has shared with the client, or one the client
|
|
95
|
+
* put there themselves. A file document describes itself but never carries an
|
|
96
|
+
* address: the bytes come only through `useWorkOpenSharedDocument`.
|
|
97
|
+
*/
|
|
98
|
+
export interface WorkSharedDocument {
|
|
99
|
+
id: string;
|
|
100
|
+
title: string;
|
|
101
|
+
docType: "page" | "file";
|
|
102
|
+
updatedAt: string;
|
|
103
|
+
sharedAt: string;
|
|
104
|
+
file: {
|
|
105
|
+
mediaId: string;
|
|
106
|
+
filename: string | null;
|
|
107
|
+
size: number;
|
|
108
|
+
type: string;
|
|
109
|
+
} | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
92
112
|
export interface WorkProjectInvoice {
|
|
93
113
|
id: string;
|
|
94
114
|
invoiceNumber: string;
|
|
@@ -110,7 +130,7 @@ export interface WorkInviteContext {
|
|
|
110
130
|
firstName: string | null;
|
|
111
131
|
/**
|
|
112
132
|
* The invited address already has an account, so the invite grants portal
|
|
113
|
-
* access but cannot set a credential
|
|
133
|
+
* access but cannot set a credential: render "sign in", not a password
|
|
114
134
|
* form (C10).
|
|
115
135
|
*/
|
|
116
136
|
requiresExistingLogin?: boolean;
|
|
@@ -124,7 +144,7 @@ export interface WorkInviteAcceptResult {
|
|
|
124
144
|
*/
|
|
125
145
|
requiresLogin?: boolean;
|
|
126
146
|
token?: string;
|
|
127
|
-
/** Rotating refresh token for the portal session (backend §7)
|
|
147
|
+
/** Rotating refresh token for the portal session (backend §7). Persist it. */
|
|
128
148
|
refreshToken?: string;
|
|
129
149
|
projectId: string;
|
|
130
150
|
account: { id: string; email: string; firstName: string | null; lastName: string | null };
|
|
@@ -142,12 +162,14 @@ const keys = {
|
|
|
142
162
|
["work-portal-task-attachments", profileId, projectId, taskId] as const,
|
|
143
163
|
projectInvoices: (profileId: string | undefined, projectId: string) =>
|
|
144
164
|
["work-portal-project-invoices", profileId, projectId] as const,
|
|
165
|
+
sharedDocuments: (profileId: string | undefined, projectId: string) =>
|
|
166
|
+
["work-portal-shared-documents", profileId, projectId] as const,
|
|
145
167
|
};
|
|
146
168
|
|
|
147
169
|
// ── Authed client's projects ─────────────────────────────────────────────────
|
|
148
170
|
|
|
149
171
|
/** The authenticated client's (non-archived) client projects with a task/done
|
|
150
|
-
* summary
|
|
172
|
+
* summary: GET /public/work/projects. The token is the identity. */
|
|
151
173
|
export function useWorkPortalProjects(): UseQueryResult<WorkProjectSummary[]> {
|
|
152
174
|
const { client, profileId } = useForge();
|
|
153
175
|
return useQuery<WorkProjectSummary[]>({
|
|
@@ -163,7 +185,7 @@ export function useWorkPortalProjects(): UseQueryResult<WorkProjectSummary[]> {
|
|
|
163
185
|
// ── Authed client's single-project report ────────────────────────────────────
|
|
164
186
|
|
|
165
187
|
/** The authenticated client's overview + billable-only time report for one of
|
|
166
|
-
* their projects
|
|
188
|
+
* their projects: GET /public/work/projects/:id/report. */
|
|
167
189
|
export function useWorkProjectReport(projectId: string): UseQueryResult<WorkProjectReportData> {
|
|
168
190
|
const { client, profileId } = useForge();
|
|
169
191
|
return useQuery<WorkProjectReportData>({
|
|
@@ -180,7 +202,7 @@ export function useWorkProjectReport(projectId: string): UseQueryResult<WorkProj
|
|
|
180
202
|
|
|
181
203
|
// ── Public tokenized report (no login) ───────────────────────────────────────
|
|
182
204
|
|
|
183
|
-
/** Login-free tokenized share of a project's time report
|
|
205
|
+
/** Login-free tokenized share of a project's time report: GET
|
|
184
206
|
* /public/work/time-report/:token. The signed token IS the authorization. */
|
|
185
207
|
export function useWorkTokenReport(token: string): UseQueryResult<WorkProjectReportData> {
|
|
186
208
|
const { client } = useForge();
|
|
@@ -208,7 +230,7 @@ export interface UseWorkTaskCommentsResult {
|
|
|
208
230
|
|
|
209
231
|
/**
|
|
210
232
|
* The authenticated client's comment thread for a single task on one of their
|
|
211
|
-
* projects
|
|
233
|
+
* projects: GET/POST /public/work/projects/:projectId/tasks/:taskId/comments.
|
|
212
234
|
* Newest first. The bearer token IS the client identity (server-enforced).
|
|
213
235
|
* Posting invalidates the thread so the new comment appears. Tier-1 primitive:
|
|
214
236
|
* returns clean data + a bound mutation, renders nothing.
|
|
@@ -259,7 +281,7 @@ export function useWorkTaskComments(projectId: string, taskId: string): UseWorkT
|
|
|
259
281
|
|
|
260
282
|
/**
|
|
261
283
|
* Read-only attachments the team has added to a task on one of the client's
|
|
262
|
-
* projects
|
|
284
|
+
* projects: GET /public/work/projects/:projectId/tasks/:taskId/attachments.
|
|
263
285
|
* Tier-1 primitive: returns clean data, renders nothing.
|
|
264
286
|
*/
|
|
265
287
|
export function useWorkTaskAttachments(projectId: string, taskId: string): UseQueryResult<WorkTaskAttachment[]> {
|
|
@@ -279,7 +301,7 @@ export function useWorkTaskAttachments(projectId: string, taskId: string): UseQu
|
|
|
279
301
|
// ── Authed client's invoices for a project ───────────────────────────────────
|
|
280
302
|
|
|
281
303
|
/**
|
|
282
|
-
* The client's (non-draft) invoices for one of their projects
|
|
304
|
+
* The client's (non-draft) invoices for one of their projects:
|
|
283
305
|
* GET /public/work/projects/:id/invoices. Each carries a `payable` flag the
|
|
284
306
|
* portal turns into a Pay button (links to /i/invoice/:id). Tier-1 primitive.
|
|
285
307
|
*/
|
|
@@ -295,11 +317,104 @@ export function useWorkProjectInvoices(projectId: string): UseQueryResult<WorkPr
|
|
|
295
317
|
});
|
|
296
318
|
}
|
|
297
319
|
|
|
320
|
+
// ── Authed client's case-file documents ──────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The documents on one of the client's projects that they may see: what the
|
|
324
|
+
* firm shared with them, and what they uploaded themselves:
|
|
325
|
+
* GET /public/work/projects/:id/documents. Tier-1 primitive.
|
|
326
|
+
*/
|
|
327
|
+
export function useWorkSharedDocuments(projectId: string): UseQueryResult<WorkSharedDocument[]> {
|
|
328
|
+
const { client, profileId } = useForge();
|
|
329
|
+
return useQuery<WorkSharedDocument[]>({
|
|
330
|
+
queryKey: keys.sharedDocuments(profileId, projectId),
|
|
331
|
+
queryFn: async () => {
|
|
332
|
+
const res = await client.get(`/public/work/projects/${projectId}/documents`, { params: { profileId } });
|
|
333
|
+
return res.data as WorkSharedDocument[];
|
|
334
|
+
},
|
|
335
|
+
enabled: !!profileId && !!client && !!projectId,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A sixty-second, single-use GET at one shared file's bytes:
|
|
341
|
+
* GET /public/work/projects/:id/documents/:documentId/content. Meant to be
|
|
342
|
+
* followed once, by the click that asked for it; never cache or render the URL.
|
|
343
|
+
*/
|
|
344
|
+
export function useWorkOpenSharedDocument(projectId: string) {
|
|
345
|
+
const { client, profileId } = useForge();
|
|
346
|
+
return useMutation<{ url: string }, unknown, string>({
|
|
347
|
+
mutationFn: async (documentId: string) => {
|
|
348
|
+
const res = await client.get(`/public/work/projects/${projectId}/documents/${documentId}/content`, {
|
|
349
|
+
params: { profileId },
|
|
350
|
+
});
|
|
351
|
+
return res.data as { url: string };
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const mediaTypeFor = (file: File): "image" | "video" | "audio" | "document" | "other" => {
|
|
357
|
+
const mime = file.type || "";
|
|
358
|
+
if (mime.startsWith("image/")) return "image";
|
|
359
|
+
if (mime.startsWith("video/")) return "video";
|
|
360
|
+
if (mime.startsWith("audio/")) return "audio";
|
|
361
|
+
if (mime.startsWith("text/") || mime.includes("pdf") || mime.includes("word") || mime.includes("document")) {
|
|
362
|
+
return "document";
|
|
363
|
+
}
|
|
364
|
+
return "other";
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Put a file into the client's matter, in three steps that must stay in this
|
|
369
|
+
* order:
|
|
370
|
+
*
|
|
371
|
+
* 1. sign a PUT into the firm's PRIVATE bucket
|
|
372
|
+
* (POST /public/work/projects/:id/documents/upload-url);
|
|
373
|
+
* 2. send the bytes straight to object storage, with the Content-Type the
|
|
374
|
+
* server SIGNED (derived from the extension allowlist, not the browser);
|
|
375
|
+
* 3. register the document (POST /public/work/projects/:id/documents), which
|
|
376
|
+
* writes the row, shares it back to the client and queues extraction.
|
|
377
|
+
*
|
|
378
|
+
* A failure at any step is a failure of the whole upload. On success the
|
|
379
|
+
* shared-documents list is refetched so the new file appears at once.
|
|
380
|
+
*/
|
|
381
|
+
export function useUploadWorkDocument(projectId: string) {
|
|
382
|
+
const { client, profileId } = useForge();
|
|
383
|
+
const queryClient = useQueryClient();
|
|
384
|
+
return useMutation<WorkSharedDocument, unknown, { file: File; title?: string }>({
|
|
385
|
+
mutationFn: async ({ file, title }) => {
|
|
386
|
+
const { data: signed } = await client.post<{
|
|
387
|
+
presignedUrl: string;
|
|
388
|
+
key: string;
|
|
389
|
+
requiredHeaders?: Record<string, string>;
|
|
390
|
+
}>(`/public/work/projects/${projectId}/documents/upload-url`, { fileName: file.name }, { params: { profileId } });
|
|
391
|
+
await axios.put(signed.presignedUrl, file, {
|
|
392
|
+
headers: signed.requiredHeaders ?? { "Content-Type": file.type || "application/octet-stream" },
|
|
393
|
+
});
|
|
394
|
+
const { data } = await client.post(
|
|
395
|
+
`/public/work/projects/${projectId}/documents`,
|
|
396
|
+
{
|
|
397
|
+
storageKey: signed.key,
|
|
398
|
+
fileName: file.name,
|
|
399
|
+
fileSize: file.size,
|
|
400
|
+
mediaType: mediaTypeFor(file),
|
|
401
|
+
...(title ? { title } : {}),
|
|
402
|
+
},
|
|
403
|
+
{ params: { profileId } },
|
|
404
|
+
);
|
|
405
|
+
return data as WorkSharedDocument;
|
|
406
|
+
},
|
|
407
|
+
onSuccess: () => {
|
|
408
|
+
void queryClient.invalidateQueries({ queryKey: keys.sharedDocuments(profileId, projectId) });
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
298
413
|
// ── Client invite accept (public, no auth) ───────────────────────────────────
|
|
299
414
|
|
|
300
415
|
/**
|
|
301
416
|
* Fetch the branded context for an invite link (GET /public/work/invite).
|
|
302
|
-
* Public
|
|
417
|
+
* Public: no bearer token needed; the link's token IS the credential.
|
|
303
418
|
* `null`/errored when the token or project is missing/invalid/expired.
|
|
304
419
|
*/
|
|
305
420
|
export function useWorkInvite(
|
|
@@ -321,7 +436,7 @@ export function useWorkInvite(
|
|
|
321
436
|
/**
|
|
322
437
|
* Accept an invite (POST /public/work/invite/accept): set a password and
|
|
323
438
|
* receive a fresh portal session token. The caller persists the token +
|
|
324
|
-
* navigates. Public
|
|
439
|
+
* navigates. Public: no bearer token needed.
|
|
325
440
|
*/
|
|
326
441
|
export function useAcceptWorkInvite() {
|
|
327
442
|
const { client } = useForge();
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { FileText, Paperclip, Upload } from "lucide-react";
|
|
3
|
+
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
4
|
+
import {
|
|
5
|
+
formatWorkDate,
|
|
6
|
+
useUploadWorkDocument,
|
|
7
|
+
useWorkOpenSharedDocument,
|
|
8
|
+
useWorkSharedDocuments,
|
|
9
|
+
type WorkSharedDocument,
|
|
10
|
+
} from "../../headless/work/useWorkPortal";
|
|
11
|
+
|
|
12
|
+
export interface WorkProjectDocumentsProps {
|
|
13
|
+
/** The client project id. */
|
|
14
|
+
projectId: string;
|
|
15
|
+
/** Hide the upload control. The list still renders. Defaults to `true`. */
|
|
16
|
+
allowUpload?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const humanSize = (size: number | null | undefined): string => {
|
|
20
|
+
if (!size || size <= 0) return "";
|
|
21
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
22
|
+
let n = size;
|
|
23
|
+
let i = 0;
|
|
24
|
+
while (n >= 1024 && i < units.length - 1) {
|
|
25
|
+
n /= 1024;
|
|
26
|
+
i += 1;
|
|
27
|
+
}
|
|
28
|
+
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const errorMessage = (error: unknown): string =>
|
|
32
|
+
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
|
33
|
+
"The upload didn't go through. Please try again.";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The documents on a client's project: what the firm shared with them, and
|
|
37
|
+
* what they sent in themselves, with an upload control for the latter.
|
|
38
|
+
*
|
|
39
|
+
* A file opens through a short-lived signed read fetched on the click, never
|
|
40
|
+
* from an address held in the page. Built on `useWorkSharedDocuments`,
|
|
41
|
+
* `useWorkOpenSharedDocument` and `useUploadWorkDocument`; theme tokens only.
|
|
42
|
+
*
|
|
43
|
+
* Unlike the invoices block this renders even when the list is empty, because
|
|
44
|
+
* the empty state is where the upload control lives.
|
|
45
|
+
*/
|
|
46
|
+
export function WorkProjectDocuments({ projectId, allowUpload = true }: WorkProjectDocumentsProps) {
|
|
47
|
+
const t = useThemeTokens();
|
|
48
|
+
const { data: documents, isLoading } = useWorkSharedDocuments(projectId);
|
|
49
|
+
const open = useWorkOpenSharedDocument(projectId);
|
|
50
|
+
const upload = useUploadWorkDocument(projectId);
|
|
51
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
52
|
+
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
const [opening, setOpening] = useState<string | null>(null);
|
|
54
|
+
|
|
55
|
+
if (isLoading) return null;
|
|
56
|
+
|
|
57
|
+
const onFiles = async (files: FileList | null) => {
|
|
58
|
+
if (!files || files.length === 0) return;
|
|
59
|
+
setError(null);
|
|
60
|
+
// One at a time, in order: each is its own signed PUT and its own row, and
|
|
61
|
+
// a failure names the file that failed rather than the whole batch.
|
|
62
|
+
for (const file of Array.from(files)) {
|
|
63
|
+
try {
|
|
64
|
+
await upload.mutateAsync({ file });
|
|
65
|
+
} catch (err) {
|
|
66
|
+
setError(`${file.name}: ${errorMessage(err)}`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const openDocument = async (doc: WorkSharedDocument) => {
|
|
73
|
+
if (!doc.file) return;
|
|
74
|
+
setOpening(doc.id);
|
|
75
|
+
try {
|
|
76
|
+
const { url } = await open.mutateAsync(doc.id);
|
|
77
|
+
window.open(url, "_blank", "noopener");
|
|
78
|
+
} catch (err) {
|
|
79
|
+
setError(errorMessage(err));
|
|
80
|
+
} finally {
|
|
81
|
+
setOpening(null);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const list = documents ?? [];
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
data-testid="work-project-documents"
|
|
90
|
+
style={{
|
|
91
|
+
border: `1px solid ${t.border}`,
|
|
92
|
+
borderRadius: t.cornerRadius,
|
|
93
|
+
background: t.surface,
|
|
94
|
+
padding: 20,
|
|
95
|
+
color: t.text,
|
|
96
|
+
fontFamily: t.fontFamily,
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
<div
|
|
100
|
+
style={{
|
|
101
|
+
display: "flex",
|
|
102
|
+
alignItems: "center",
|
|
103
|
+
justifyContent: "space-between",
|
|
104
|
+
gap: 12,
|
|
105
|
+
flexWrap: "wrap",
|
|
106
|
+
marginBottom: 14,
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
<h2 style={{ fontSize: 16, fontWeight: 800, margin: 0 }}>Documents</h2>
|
|
110
|
+
{allowUpload && (
|
|
111
|
+
<>
|
|
112
|
+
<input
|
|
113
|
+
ref={fileInputRef}
|
|
114
|
+
type="file"
|
|
115
|
+
multiple
|
|
116
|
+
hidden
|
|
117
|
+
data-testid="work-document-file-input"
|
|
118
|
+
onChange={(e) => {
|
|
119
|
+
void onFiles(e.target.files);
|
|
120
|
+
e.target.value = "";
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
data-testid="work-document-upload"
|
|
126
|
+
onClick={() => fileInputRef.current?.click()}
|
|
127
|
+
disabled={upload.isPending}
|
|
128
|
+
style={{
|
|
129
|
+
display: "inline-flex",
|
|
130
|
+
alignItems: "center",
|
|
131
|
+
gap: 8,
|
|
132
|
+
padding: "8px 16px",
|
|
133
|
+
borderRadius: t.cornerRadius,
|
|
134
|
+
border: "none",
|
|
135
|
+
background: t.primary,
|
|
136
|
+
color: t.textPrimary,
|
|
137
|
+
fontWeight: 700,
|
|
138
|
+
fontSize: 13,
|
|
139
|
+
cursor: upload.isPending ? "wait" : "pointer",
|
|
140
|
+
opacity: upload.isPending ? 0.7 : 1,
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<Upload size={14} />
|
|
144
|
+
{upload.isPending ? "Uploading…" : "Upload a document"}
|
|
145
|
+
</button>
|
|
146
|
+
</>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{error && (
|
|
151
|
+
<p
|
|
152
|
+
data-testid="work-document-error"
|
|
153
|
+
role="alert"
|
|
154
|
+
style={{ margin: "0 0 12px", fontSize: 13, color: "#ef4444" }}
|
|
155
|
+
>
|
|
156
|
+
{error}
|
|
157
|
+
</p>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{list.length === 0 ? (
|
|
161
|
+
<p data-testid="work-project-documents-empty" style={{ margin: 0, fontSize: 13, color: t.muted }}>
|
|
162
|
+
{allowUpload
|
|
163
|
+
? "Nothing here yet. Upload a document to add it to this project."
|
|
164
|
+
: "Nothing has been shared with you yet."}
|
|
165
|
+
</p>
|
|
166
|
+
) : (
|
|
167
|
+
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
|
|
168
|
+
{list.map((doc) => (
|
|
169
|
+
<li
|
|
170
|
+
key={doc.id}
|
|
171
|
+
data-testid={`work-document-${doc.id}`}
|
|
172
|
+
style={{
|
|
173
|
+
display: "flex",
|
|
174
|
+
alignItems: "center",
|
|
175
|
+
justifyContent: "space-between",
|
|
176
|
+
gap: 12,
|
|
177
|
+
flexWrap: "wrap",
|
|
178
|
+
padding: "10px 14px",
|
|
179
|
+
border: `1px solid ${t.border}`,
|
|
180
|
+
borderRadius: t.cornerRadius,
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
|
184
|
+
{doc.file ? (
|
|
185
|
+
<Paperclip size={14} style={{ color: t.muted, flexShrink: 0 }} />
|
|
186
|
+
) : (
|
|
187
|
+
<FileText size={14} style={{ color: t.muted, flexShrink: 0 }} />
|
|
188
|
+
)}
|
|
189
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
|
190
|
+
<span style={{ fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
191
|
+
{doc.title}
|
|
192
|
+
</span>
|
|
193
|
+
<span style={{ fontSize: 12, color: t.muted }}>
|
|
194
|
+
{formatWorkDate(doc.sharedAt)}
|
|
195
|
+
{doc.file?.size ? ` · ${humanSize(doc.file.size)}` : ""}
|
|
196
|
+
</span>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
{doc.file && (
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
data-testid={`work-document-open-${doc.id}`}
|
|
203
|
+
onClick={() => void openDocument(doc)}
|
|
204
|
+
disabled={opening === doc.id}
|
|
205
|
+
style={{
|
|
206
|
+
background: "transparent",
|
|
207
|
+
border: "none",
|
|
208
|
+
padding: 0,
|
|
209
|
+
color: t.primary,
|
|
210
|
+
fontSize: 13,
|
|
211
|
+
textDecoration: "underline",
|
|
212
|
+
cursor: opening === doc.id ? "wait" : "pointer",
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
{opening === doc.id ? "Opening…" : "Open"}
|
|
216
|
+
</button>
|
|
217
|
+
)}
|
|
218
|
+
</li>
|
|
219
|
+
))}
|
|
220
|
+
</ul>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -3,6 +3,7 @@ import { Loading } from "../Loading";
|
|
|
3
3
|
import { useWorkProjectReport } from "../../headless/work/useWorkPortal";
|
|
4
4
|
import { WorkReportBody } from "./WorkReportBody";
|
|
5
5
|
import { WorkProjectInvoices } from "./WorkProjectInvoices";
|
|
6
|
+
import { WorkProjectDocuments } from "./WorkProjectDocuments";
|
|
6
7
|
|
|
7
8
|
export interface WorkProjectReportProps {
|
|
8
9
|
/** The client project id (from the route). */
|
|
@@ -17,7 +18,7 @@ export interface WorkProjectReportProps {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* The authenticated client's read-only project report
|
|
21
|
+
* The authenticated client's read-only project report: milestones, task list
|
|
21
22
|
* and the billable-only time report. Each task links to its detail/comments
|
|
22
23
|
* page. Built on `useWorkProjectReport`; theme tokens only.
|
|
23
24
|
*/
|
|
@@ -55,11 +56,12 @@ export function WorkProjectReport({ projectId, taskHref, onOpenTask }: WorkProje
|
|
|
55
56
|
? undefined
|
|
56
57
|
: (taskHref ?? ((taskId: string) => `/i/work/projects/${projectId}/tasks/${taskId}`));
|
|
57
58
|
|
|
58
|
-
// The invoices
|
|
59
|
-
// share, which renders WorkReportBody directly).
|
|
59
|
+
// The documents and invoices sections are authed-only (not part of the
|
|
60
|
+
// login-free token share, which renders WorkReportBody directly).
|
|
60
61
|
return (
|
|
61
62
|
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
|
62
63
|
<WorkReportBody report={report} taskHref={resolvedTaskHref} onOpenTask={onOpenTask} />
|
|
64
|
+
<WorkProjectDocuments projectId={projectId} />
|
|
63
65
|
<WorkProjectInvoices projectId={projectId} />
|
|
64
66
|
</div>
|
|
65
67
|
);
|
|
@@ -8,3 +8,4 @@ export { WorkTaskAttachments, type WorkTaskAttachmentsProps } from "./WorkTaskAt
|
|
|
8
8
|
export { WorkTaskDetail, type WorkTaskDetailProps } from "./WorkTaskDetail";
|
|
9
9
|
export { WorkInviteAccept, type WorkInviteAcceptProps } from "./WorkInviteAccept";
|
|
10
10
|
export { WorkProjectInvoices, type WorkProjectInvoicesProps } from "./WorkProjectInvoices";
|
|
11
|
+
export { WorkProjectDocuments, type WorkProjectDocumentsProps } from "./WorkProjectDocuments";
|