@tribe-nest/forge 3.60.0 → 3.61.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.
Files changed (207) hide show
  1. package/package.json +2 -1
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +17 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/types/models.ts +11 -0
  37. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  38. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  39. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  40. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  41. package/src/ui/analytics/useMetaEvents.ts +78 -0
  42. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  43. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  44. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  45. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  46. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  47. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  48. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  49. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  50. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  51. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  52. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  53. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  54. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  55. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  56. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  57. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  58. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  59. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  60. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  61. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  62. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  63. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  64. package/src/ui/headless/communities/index.ts +41 -0
  65. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  66. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  67. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  68. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  69. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  70. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  71. package/src/ui/headless/community/communityPoll.ts +297 -0
  72. package/src/ui/headless/community/countries.ts +123 -0
  73. package/src/ui/headless/community/index.ts +81 -1
  74. package/src/ui/headless/community/linkPreview.ts +71 -0
  75. package/src/ui/headless/community/richLexical.ts +27 -2
  76. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  77. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  78. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  79. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  80. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  81. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  82. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  83. package/src/ui/headless/downloads/index.ts +8 -0
  84. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  85. package/src/ui/headless/index.ts +13 -0
  86. package/src/ui/headless/memberHome/index.ts +10 -0
  87. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  88. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  89. package/src/ui/headless/portal/index.ts +57 -0
  90. package/src/ui/headless/portal/usePortal.ts +274 -0
  91. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  92. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  93. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  94. package/src/ui/headless/siteApp/appBar.ts +102 -0
  95. package/src/ui/headless/siteApp/appHome.ts +344 -0
  96. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  97. package/src/ui/headless/siteApp/index.ts +58 -0
  98. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  99. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  100. package/src/ui/headless/work/index.ts +2 -0
  101. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  102. package/src/ui/index.ts +11 -0
  103. package/src/ui/styled/AccountDashboard.tsx +712 -352
  104. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  105. package/src/ui/styled/ChatRoom.tsx +126 -16
  106. package/src/ui/styled/Checkout.tsx +8 -0
  107. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  108. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  109. package/src/ui/styled/CoachingDetail.tsx +2 -0
  110. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  111. package/src/ui/styled/CourseDetail.tsx +2 -0
  112. package/src/ui/styled/DownloadPage.tsx +263 -0
  113. package/src/ui/styled/EventConfirmation.tsx +10 -0
  114. package/src/ui/styled/EventDetail.tsx +10 -0
  115. package/src/ui/styled/FilmLibrary.tsx +22 -9
  116. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  117. package/src/ui/styled/ProductDetail.tsx +12 -0
  118. package/src/ui/styled/PwaRegistration.tsx +5 -1
  119. package/src/ui/styled/SignupForm.tsx +18 -3
  120. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  121. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  122. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  123. package/src/ui/styled/app/AppBar.tsx +298 -0
  124. package/src/ui/styled/app/AppHome.tsx +1139 -0
  125. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  126. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  127. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  128. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  129. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  130. package/src/ui/styled/app/index.ts +7 -0
  131. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  132. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  133. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  134. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  135. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  136. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  137. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  138. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  139. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  140. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  141. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  142. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  143. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  144. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  145. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  146. package/src/ui/styled/communities/index.ts +9 -0
  147. package/src/ui/styled/communities/shared.tsx +39 -11
  148. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  149. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  150. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  151. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  152. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  153. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  154. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  155. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  156. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  157. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  158. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  159. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  160. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  161. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  162. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  163. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  164. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  165. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  166. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  167. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  168. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  169. package/src/ui/styled/community/index.ts +37 -2
  170. package/src/ui/styled/forge-utilities.css +234 -0
  171. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  172. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  173. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  174. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  175. package/src/ui/styled/members/index.ts +8 -0
  176. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  177. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  178. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  179. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  180. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  181. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  182. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  183. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  184. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  185. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  186. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  187. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  188. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  189. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  190. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  191. package/src/ui/styled/portal/index.ts +13 -0
  192. package/src/ui/styled/portal/portalNav.ts +260 -0
  193. package/src/ui/styled/portal/shared.tsx +206 -0
  194. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  195. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  196. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  197. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  198. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  199. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  200. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  201. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  202. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  203. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  204. package/src/ui/styled/work/index.ts +1 -1
  205. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  206. package/src/utils/landing.ts +19 -6
  207. package/src/utils/metaEvents.ts +302 -0
@@ -1,13 +1,16 @@
1
- import { CheckCircle2, ChevronRight, Circle, Clock, Flag } from "lucide-react";
1
+ import type { CSSProperties, MouseEvent, ReactNode } from "react";
2
+ import { CheckCircle2, ChevronRight, Circle } from "lucide-react";
3
+ import { useForgeT, type ForgeT } from "../../../i18n";
2
4
  import { useThemeTokens, type ResolvedThemeTokens } from "../../theme/ForgeThemeProvider";
3
- import {
4
- formatWorkDate,
5
- formatWorkMoney,
6
- type WorkProjectReportData,
7
- } from "../../headless/work/useWorkPortal";
5
+ import { formatWorkMoney, type WorkProjectReportData } from "../../headless/work/useWorkPortal";
6
+ import { PortalBlock, PortalHeading, PortalList, PortalRow, usePortal } from "../portal/shared";
7
+
8
+ export type WorkReportSection = "header" | "milestones" | "tasks" | "time";
9
+
10
+ const ALL_SECTIONS: WorkReportSection[] = ["header", "milestones", "tasks", "time"];
8
11
 
9
12
  /**
10
- * Shared read-only renderer for a project's report — used by both the authed
13
+ * Shared read-only renderer for a project's report, used by both the authed
11
14
  * `WorkProjectReport` and the public tokenized `WorkTokenReport`. Shows
12
15
  * milestones, a simple task list, and the billable-only time report
13
16
  * (hours + amount per task + total). Theme tokens only.
@@ -18,275 +21,245 @@ export interface WorkReportBodyProps {
18
21
  * report only), each task row becomes a link. The public token report omits
19
22
  * this, so tasks stay read-only there. */
20
23
  taskHref?: (taskId: string) => string;
21
- /** Router-driven open handler. When provided, task rows render as buttons
22
- * calling this instead of anchors. Takes precedence over `taskHref`. */
24
+ /** Router-driven open handler. Without `taskHref`, task rows render as
25
+ * buttons calling this. With both, rows are real links whose plain left
26
+ * click calls this instead of reloading the page. */
23
27
  onOpenTask?: (taskId: string) => void;
28
+ /** Which parts to render, in this fixed order. Defaults to all four, which
29
+ * is what the public token report shows. */
30
+ sections?: WorkReportSection[];
31
+ }
32
+
33
+ /** Project and milestone status in words. Literal keys, so the translation spec can see each one. */
34
+ export function workStatusLabel(tr: ForgeT, status: string): string {
35
+ const labels: Record<string, string> = {
36
+ active: tr("forge.work_report_body.status_active"),
37
+ on_hold: tr("forge.work_report_body.status_on_hold"),
38
+ completed: tr("forge.work_report_body.status_completed"),
39
+ archived: tr("forge.work_report_body.status_archived"),
40
+ planning: tr("forge.work_report_body.status_planning"),
41
+ pending: tr("forge.work_report_body.status_pending"),
42
+ in_progress: tr("forge.work_report_body.status_in_progress"),
43
+ };
44
+ return labels[status] ?? status.replace(/_/g, " ");
24
45
  }
25
46
 
26
- export function WorkReportBody({ report, taskHref, onOpenTask }: WorkReportBodyProps) {
47
+ const isComplete = (status: string) => ["done", "completed", "complete"].includes(status?.toLowerCase());
48
+
49
+ export function WorkReportBody({ report, taskHref, onOpenTask, sections = ALL_SECTIONS }: WorkReportBodyProps) {
27
50
  const t = useThemeTokens();
51
+ const tr = useForgeT();
52
+ const { formatDate } = usePortal();
28
53
  const { project, milestones, tasks, timeReport } = report;
29
54
  const taskOpenable = !!taskHref || !!onOpenTask;
55
+ const show = (section: WorkReportSection) => sections.includes(section);
30
56
 
31
57
  return (
32
- <div
33
- data-testid="work-project-report"
34
- style={{ display: "flex", flexDirection: "column", gap: 24, color: t.text, fontFamily: t.fontFamily }}
35
- >
36
- {/* Header */}
37
- <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
38
- <h1 data-testid="work-project-name" style={{ fontSize: 22, fontWeight: 800, margin: 0, color: t.text }}>
39
- {project.name}
40
- </h1>
41
- <StatusPill status={project.status} t={t} />
42
- </div>
43
-
44
- {/* Milestones */}
45
- <Panel title="Milestones" icon={<Flag size={16} />} t={t}>
46
- {milestones.length === 0 ? (
47
- <Muted t={t}>No milestones yet.</Muted>
48
- ) : (
49
- <ul style={listStyle}>
50
- {milestones.map((m) => (
51
- <li key={m.id} style={rowStyle}>
52
- <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
53
- <StatusDot status={m.status} t={t} />
54
- <span style={{ color: t.text }}>{m.name}</span>
55
- </span>
56
- <span style={{ fontSize: 13, color: t.muted }}>{formatWorkDate(m.dueDate)}</span>
57
- </li>
58
- ))}
59
- </ul>
60
- )}
61
- </Panel>
58
+ <div data-testid="work-project-report" style={{ color: t.text, fontFamily: t.fontFamily }}>
59
+ {show("header") && (
60
+ <PortalHeading
61
+ title={<span data-testid="work-project-name">{project.name}</span>}
62
+ actions={
63
+ <span data-testid="work-project-status" style={{ fontSize: 14, color: t.muted }}>
64
+ {workStatusLabel(tr, project.status)}
65
+ </span>
66
+ }
67
+ />
68
+ )}
62
69
 
63
- {/* Task list */}
64
- <Panel title="Tasks" icon={<CheckCircle2 size={16} />} t={t}>
65
- {tasks.length === 0 ? (
66
- <Muted t={t}>No tasks yet.</Muted>
67
- ) : (
68
- <ul style={listStyle}>
69
- {tasks.map((task) => {
70
- const inner = (
71
- <>
72
- <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
73
- {task.done ? (
74
- <CheckCircle2 size={16} style={{ color: t.primary }} />
75
- ) : (
76
- <Circle size={16} style={{ color: t.muted }} />
77
- )}
78
- <span
79
- style={{
80
- color: task.done ? t.muted : t.text,
81
- textDecoration: task.done ? "line-through" : "none",
82
- }}
83
- >
84
- {task.title}
70
+ {show("milestones") && (
71
+ <PortalBlock title={tr("forge.work_report_body.milestones")}>
72
+ {milestones.length === 0 ? (
73
+ <Muted t={t}>{tr("forge.work_report_body.milestones_empty")}</Muted>
74
+ ) : (
75
+ <PortalList>
76
+ {milestones.map((m) => {
77
+ const complete = isComplete(m.status);
78
+ return (
79
+ <PortalRow key={m.id} testId="work-milestone">
80
+ <span className="fg:min-w-0 fg:flex-1" style={{ fontWeight: 600, color: complete ? t.muted : t.text }}>
81
+ {m.name}
85
82
  </span>
86
- </span>
87
- <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
88
- <span style={{ fontSize: 13, color: t.muted }}>{formatWorkDate(task.dueDate)}</span>
89
- {taskOpenable && <ChevronRight size={16} style={{ color: t.muted, flexShrink: 0 }} />}
90
- </span>
91
- </>
92
- );
83
+ <span className="fg:flex fg:flex-wrap fg:gap-x-4" style={{ fontSize: 13, color: t.muted }}>
84
+ {m.dueDate ? <span>{tr("forge.work_report_body.due_on", { date: formatDate(m.dueDate) })}</span> : null}
85
+ <span>{workStatusLabel(tr, m.status)}</span>
86
+ </span>
87
+ </PortalRow>
88
+ );
89
+ })}
90
+ </PortalList>
91
+ )}
92
+ </PortalBlock>
93
+ )}
93
94
 
94
- if (!taskOpenable) {
95
- return (
96
- <li key={task.id} style={rowStyle}>
97
- {inner}
98
- </li>
95
+ {show("tasks") && (
96
+ <PortalBlock title={tr("forge.work_report_body.tasks")}>
97
+ {tasks.length === 0 ? (
98
+ <Muted t={t}>{tr("forge.work_report_body.tasks_empty")}</Muted>
99
+ ) : (
100
+ <PortalList>
101
+ {tasks.map((task) => {
102
+ const inner = (
103
+ <>
104
+ <span className="fg:flex fg:min-w-0 fg:flex-1 fg:items-center fg:gap-3">
105
+ {task.done ? (
106
+ <CheckCircle2 size={18} aria-hidden style={{ color: t.text, flexShrink: 0 }} />
107
+ ) : (
108
+ <Circle size={18} aria-hidden style={{ color: t.muted, flexShrink: 0 }} />
109
+ )}
110
+ <span className="fg:sr-only">
111
+ {task.done ? tr("forge.work_report_body.task_done") : tr("forge.work_report_body.task_open")}
112
+ </span>
113
+ <span
114
+ style={{
115
+ color: task.done ? t.muted : t.text,
116
+ textDecoration: task.done ? "line-through" : "none",
117
+ }}
118
+ >
119
+ {task.title}
120
+ </span>
121
+ </span>
122
+ <span className="fg:flex fg:items-center fg:gap-3" style={{ fontSize: 13, color: t.muted }}>
123
+ {task.dueDate ? (
124
+ <span>{tr("forge.work_report_body.due_on", { date: formatDate(task.dueDate) })}</span>
125
+ ) : null}
126
+ {taskOpenable && <ChevronRight size={16} aria-hidden style={{ flexShrink: 0 }} />}
127
+ </span>
128
+ </>
99
129
  );
100
- }
101
130
 
102
- const linkStyle: React.CSSProperties = {
103
- ...rowStyle,
104
- width: "100%",
105
- textAlign: "left",
106
- cursor: "pointer",
107
- background: "transparent",
108
- border: "none",
109
- padding: "4px 0",
110
- color: t.text,
111
- fontFamily: t.fontFamily,
112
- fontSize: "inherit",
113
- textDecoration: "none",
114
- };
131
+ if (!taskOpenable) {
132
+ return (
133
+ <PortalRow key={task.id} testId="work-task-row">
134
+ {inner}
135
+ </PortalRow>
136
+ );
137
+ }
138
+
139
+ const linkStyle: CSSProperties = {
140
+ width: "100%",
141
+ textAlign: "left",
142
+ cursor: "pointer",
143
+ background: "transparent",
144
+ border: "none",
145
+ color: t.text,
146
+ fontFamily: t.fontFamily,
147
+ fontSize: "inherit",
148
+ textDecoration: "none",
149
+ };
150
+ const linkClass = "fg:flex fg:flex-wrap fg:items-center fg:gap-x-4 fg:gap-y-2 fg:px-0 fg:py-4";
115
151
 
116
- if (onOpenTask) {
117
152
  return (
118
- <li key={task.id} style={{ listStyle: "none" }}>
119
- <button type="button" data-testid="work-task-link" onClick={() => onOpenTask(task.id)} style={linkStyle}>
120
- {inner}
121
- </button>
153
+ <li key={task.id} style={{ listStyle: "none", borderBottom: `1px solid ${t.border}` }}>
154
+ {taskHref ? (
155
+ <a
156
+ data-testid="work-task-link"
157
+ href={taskHref(task.id)}
158
+ className={linkClass}
159
+ style={linkStyle}
160
+ onClick={
161
+ onOpenTask
162
+ ? (event: MouseEvent<HTMLAnchorElement>) => {
163
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
164
+ return;
165
+ }
166
+ event.preventDefault();
167
+ onOpenTask(task.id);
168
+ }
169
+ : undefined
170
+ }
171
+ >
172
+ {inner}
173
+ </a>
174
+ ) : (
175
+ <button
176
+ type="button"
177
+ data-testid="work-task-link"
178
+ onClick={() => onOpenTask!(task.id)}
179
+ className={linkClass}
180
+ style={linkStyle}
181
+ >
182
+ {inner}
183
+ </button>
184
+ )}
122
185
  </li>
123
186
  );
124
- }
125
-
126
- return (
127
- <li key={task.id} style={{ listStyle: "none" }}>
128
- <a data-testid="work-task-link" href={taskHref!(task.id)} style={linkStyle}>
129
- {inner}
130
- </a>
131
- </li>
132
- );
133
- })}
134
- </ul>
135
- )}
136
- </Panel>
187
+ })}
188
+ </PortalList>
189
+ )}
190
+ </PortalBlock>
191
+ )}
137
192
 
138
- {/* Billable time report */}
139
- <Panel title="Billable time" icon={<Clock size={16} />} t={t}>
140
- {timeReport.byTask.length === 0 ? (
141
- <Muted t={t}>No billable time logged yet.</Muted>
142
- ) : (
143
- <div data-testid="work-time-report" style={{ width: "100%", overflowX: "auto" }}>
144
- <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
145
- <thead>
146
- <tr>
147
- <Th align="left" t={t}>
148
- Task
149
- </Th>
150
- <Th align="right" t={t}>
151
- Hours
152
- </Th>
153
- <Th align="right" t={t}>
154
- Amount
155
- </Th>
156
- </tr>
157
- </thead>
158
- <tbody>
159
- {timeReport.byTask.map((row) => (
160
- <tr key={row.taskId ?? "unassigned"}>
161
- <Td align="left" t={t}>
162
- {row.taskTitle}
193
+ {show("time") && (
194
+ <PortalBlock title={tr("forge.work_report_body.billable_time")}>
195
+ {timeReport.byTask.length === 0 ? (
196
+ <Muted t={t}>{tr("forge.work_report_body.billable_time_empty")}</Muted>
197
+ ) : (
198
+ <div data-testid="work-time-report" style={{ width: "100%", overflowX: "auto" }}>
199
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 15 }}>
200
+ <thead>
201
+ <tr>
202
+ <Th align="left" t={t}>
203
+ {tr("forge.work_report_body.column_task")}
204
+ </Th>
205
+ <Th align="right" t={t}>
206
+ {tr("forge.work_report_body.column_hours")}
207
+ </Th>
208
+ <Th align="right" t={t}>
209
+ {tr("forge.work_report_body.column_amount")}
210
+ </Th>
211
+ </tr>
212
+ </thead>
213
+ <tbody>
214
+ {timeReport.byTask.map((row) => (
215
+ <tr key={row.taskId ?? "unassigned"}>
216
+ <Td align="left" t={t}>
217
+ {row.taskTitle}
218
+ </Td>
219
+ <Td align="right" t={t}>
220
+ {row.hours.toLocaleString()}
221
+ </Td>
222
+ <Td align="right" t={t}>
223
+ {formatWorkMoney(row.amount, timeReport.currency)}
224
+ </Td>
225
+ </tr>
226
+ ))}
227
+ </tbody>
228
+ <tfoot>
229
+ <tr data-testid="work-time-total">
230
+ <Td align="left" bold t={t}>
231
+ {tr("forge.work_report_body.total")}
163
232
  </Td>
164
- <Td align="right" t={t}>
165
- {row.hours.toLocaleString()}
233
+ <Td align="right" bold t={t}>
234
+ {timeReport.totalHours.toLocaleString()}
166
235
  </Td>
167
- <Td align="right" t={t}>
168
- {formatWorkMoney(row.amount, timeReport.currency)}
236
+ <Td align="right" bold t={t} testId="work-total-amount">
237
+ {formatWorkMoney(timeReport.totalAmount, timeReport.currency)}
169
238
  </Td>
170
239
  </tr>
171
- ))}
172
- </tbody>
173
- <tfoot>
174
- <tr data-testid="work-time-total">
175
- <Td align="left" bold t={t}>
176
- Total
177
- </Td>
178
- <Td align="right" bold t={t}>
179
- {timeReport.totalHours.toLocaleString()}
180
- </Td>
181
- <Td align="right" bold t={t} testId="work-total-amount">
182
- {formatWorkMoney(timeReport.totalAmount, timeReport.currency)}
183
- </Td>
184
- </tr>
185
- </tfoot>
186
- </table>
187
- </div>
188
- )}
189
- </Panel>
240
+ </tfoot>
241
+ </table>
242
+ </div>
243
+ )}
244
+ </PortalBlock>
245
+ )}
190
246
  </div>
191
247
  );
192
248
  }
193
249
 
194
- const listStyle: React.CSSProperties = {
195
- margin: 0,
196
- padding: 0,
197
- listStyle: "none",
198
- display: "flex",
199
- flexDirection: "column",
200
- gap: 8,
201
- };
202
-
203
- const rowStyle: React.CSSProperties = {
204
- display: "flex",
205
- alignItems: "center",
206
- justifyContent: "space-between",
207
- gap: 12,
208
- flexWrap: "wrap",
209
- };
210
-
211
- function Panel({
212
- title,
213
- icon,
214
- t,
215
- children,
216
- }: {
217
- title: string;
218
- icon?: React.ReactNode;
219
- t: ResolvedThemeTokens;
220
- children: React.ReactNode;
221
- }) {
222
- return (
223
- <div style={{ padding: 20, border: `1px solid ${t.border}`, borderRadius: t.cornerRadius, background: t.surface }}>
224
- <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
225
- {icon && <span style={{ color: t.primary, display: "inline-flex" }}>{icon}</span>}
226
- <h2
227
- style={{
228
- fontSize: 13,
229
- fontWeight: 600,
230
- textTransform: "uppercase",
231
- letterSpacing: "0.05em",
232
- margin: 0,
233
- color: t.text,
234
- }}
235
- >
236
- {title}
237
- </h2>
238
- </div>
239
- {children}
240
- </div>
241
- );
242
- }
243
-
244
- function Muted({ t, children }: { t: ResolvedThemeTokens; children: React.ReactNode }) {
250
+ function Muted({ t, children }: { t: ResolvedThemeTokens; children: ReactNode }) {
245
251
  return <p style={{ margin: 0, fontSize: 14, color: t.muted }}>{children}</p>;
246
252
  }
247
253
 
248
- function StatusPill({ status, t }: { status: string; t: ResolvedThemeTokens }) {
249
- return (
250
- <span
251
- style={{
252
- fontSize: 12,
253
- fontWeight: 600,
254
- padding: "4px 10px",
255
- borderRadius: 999,
256
- textTransform: "capitalize",
257
- background: t.border,
258
- color: t.primary,
259
- }}
260
- >
261
- {status.replace(/_/g, " ")}
262
- </span>
263
- );
264
- }
265
-
266
- function StatusDot({ status, t }: { status: string; t: ResolvedThemeTokens }) {
267
- const done = ["done", "completed", "complete"].includes(status?.toLowerCase());
268
- return (
269
- <span
270
- style={{
271
- display: "inline-block",
272
- width: 8,
273
- height: 8,
274
- borderRadius: "50%",
275
- background: done ? t.primary : t.muted,
276
- }}
277
- />
278
- );
279
- }
280
-
281
- function Th({ align, t, children }: { align: "left" | "right"; t: ResolvedThemeTokens; children: React.ReactNode }) {
254
+ function Th({ align, t, children }: { align: "left" | "right"; t: ResolvedThemeTokens; children: ReactNode }) {
282
255
  return (
283
256
  <th
257
+ scope="col"
284
258
  style={{
285
- padding: "8px",
286
- fontWeight: 600,
287
- fontSize: 12,
288
- textTransform: "uppercase",
289
- letterSpacing: "0.05em",
259
+ padding: "0 0 10px",
260
+ paddingLeft: align === "right" ? 16 : 0,
261
+ fontWeight: 500,
262
+ fontSize: 13,
290
263
  textAlign: align,
291
264
  color: t.muted,
292
265
  borderBottom: `1px solid ${t.border}`,
@@ -308,17 +281,19 @@ function Td({
308
281
  bold?: boolean;
309
282
  t: ResolvedThemeTokens;
310
283
  testId?: string;
311
- children: React.ReactNode;
284
+ children: ReactNode;
312
285
  }) {
313
286
  return (
314
287
  <td
315
288
  data-testid={testId}
316
289
  style={{
317
- padding: "8px",
290
+ padding: "12px 0",
291
+ paddingLeft: align === "right" ? 16 : 0,
318
292
  textAlign: align,
319
293
  color: t.text,
320
- fontWeight: bold ? 700 : 400,
321
- borderTop: `1px solid ${t.border}`,
294
+ fontWeight: bold ? 600 : 400,
295
+ fontVariantNumeric: "tabular-nums",
296
+ borderBottom: bold ? "none" : `1px solid ${t.border}`,
322
297
  }}
323
298
  >
324
299
  {children}
@@ -1,6 +1,8 @@
1
1
  import { Paperclip } from "lucide-react";
2
+ import { useForgeT } from "../../../i18n";
2
3
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
4
  import { useWorkTaskAttachments } from "../../headless/work/useWorkPortal";
5
+ import { PortalBlock, PortalList, PortalRow } from "../portal/shared";
4
6
 
5
7
  export interface WorkTaskAttachmentsProps {
6
8
  /** The client project id. */
@@ -27,41 +29,33 @@ const humanSize = (size: number | null): string => {
27
29
  */
28
30
  export function WorkTaskAttachments({ projectId, taskId }: WorkTaskAttachmentsProps) {
29
31
  const t = useThemeTokens();
32
+ const tr = useForgeT();
30
33
  const { data: attachments, isLoading } = useWorkTaskAttachments(projectId, taskId);
31
34
 
32
35
  if (isLoading) return null;
33
36
  if (!attachments || attachments.length === 0) return null;
34
37
 
35
38
  return (
36
- <div data-testid="work-task-attachments" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
37
- <span style={{ fontSize: 13, fontWeight: 700, color: t.text }}>Attachments</span>
38
- <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
39
- {attachments.map((att) => (
40
- <li key={att.id} data-testid={`work-task-attachment-${att.id}`}>
41
- <a
42
- href={att.url ?? "#"}
43
- target="_blank"
44
- rel="noreferrer"
45
- style={{
46
- display: "inline-flex",
47
- alignItems: "center",
48
- gap: 8,
49
- fontSize: 13,
50
- color: t.primary,
51
- textDecoration: "none",
52
- padding: "8px 12px",
53
- border: `1px solid ${t.border}`,
54
- borderRadius: t.cornerRadius,
55
- background: t.surface,
56
- }}
57
- >
58
- <Paperclip size={14} />
59
- <span>{att.name || att.filename || "Attachment"}</span>
60
- {att.size ? <span style={{ color: t.muted }}>({humanSize(att.size)})</span> : null}
61
- </a>
62
- </li>
63
- ))}
64
- </ul>
39
+ <div data-testid="work-task-attachments" style={{ color: t.text, fontFamily: t.fontFamily }}>
40
+ <PortalBlock title={tr("forge.work_task_attachments.title")}>
41
+ <PortalList>
42
+ {attachments.map((att) => (
43
+ <PortalRow key={att.id} testId={`work-task-attachment-${att.id}`}>
44
+ <Paperclip size={16} aria-hidden style={{ color: t.muted, flexShrink: 0 }} />
45
+ <a
46
+ href={att.url ?? "#"}
47
+ target="_blank"
48
+ rel="noreferrer"
49
+ className="fg:min-w-0 fg:flex-1"
50
+ style={{ color: t.text, fontWeight: 600, fontSize: 15, textDecoration: "underline", textUnderlineOffset: 3 }}
51
+ >
52
+ {att.name || att.filename || tr("forge.work_task_attachments.fallback_name")}
53
+ </a>
54
+ {att.size ? <span style={{ fontSize: 13, color: t.muted }}>{humanSize(att.size)}</span> : null}
55
+ </PortalRow>
56
+ ))}
57
+ </PortalList>
58
+ </PortalBlock>
65
59
  </div>
66
60
  );
67
61
  }