@tulip-systems/create-app 0.12.3 → 0.13.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 (190) hide show
  1. package/dist/index.mjs +2866 -2949
  2. package/dist/index.mjs.map +1 -1
  3. package/dist/templates/basic/package.json +37 -38
  4. package/dist/templates/basic/src/app/layout.tsx +20 -10
  5. package/dist/templates/code/package.json +81 -71
  6. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-black-italic.otf +0 -0
  7. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-black.otf +0 -0
  8. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-bold-italic.otf +0 -0
  9. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-bold.otf +0 -0
  10. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-extra-light-italic.otf +0 -0
  11. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-extra-light.otf +0 -0
  12. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-light-italic.otf +0 -0
  13. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-light.otf +0 -0
  14. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-medium-italic.otf +0 -0
  15. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-medium.otf +0 -0
  16. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-regular-italic.otf +0 -0
  17. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-regular.otf +0 -0
  18. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-thin-italic.otf +0 -0
  19. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-thin.otf +0 -0
  20. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-ultra-thin-italic.otf +0 -0
  21. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-ultra-thin.otf +0 -0
  22. package/dist/templates/code/public/images/preview/hero-home.webp +0 -0
  23. package/dist/templates/code/public/sw.js +35 -0
  24. package/dist/templates/code/src/app/admin/contacts/_components/grid.tsx +1 -1
  25. package/dist/templates/code/src/app/admin/customers/_config/columns-data.tsx +1 -1
  26. package/dist/templates/code/src/app/admin/employees/_config/columns-data.tsx +1 -1
  27. package/dist/templates/code/src/app/admin/history/[id]/layout.tsx +33 -0
  28. package/dist/templates/code/src/app/admin/history/[id]/loading.tsx +11 -0
  29. package/dist/templates/code/src/app/admin/history/[id]/page.tsx +30 -0
  30. package/dist/templates/code/src/app/admin/history/_components/diff.client.tsx +377 -0
  31. package/dist/templates/code/src/app/admin/history/_components/field-comparison.tsx +153 -0
  32. package/dist/templates/code/src/app/admin/history/_components/tables.tsx +47 -0
  33. package/dist/templates/code/src/app/admin/history/_components/toolbars.tsx +54 -0
  34. package/dist/templates/code/src/app/admin/history/_config/columns-data.tsx +61 -0
  35. package/dist/templates/code/src/app/admin/history/_config/filters.ts +46 -0
  36. package/dist/templates/code/src/app/admin/history/_config/sorting.ts +30 -0
  37. package/dist/templates/code/src/app/admin/history/_lib/helpers.ts +98 -0
  38. package/dist/templates/code/src/app/admin/history/_lib/router.ts +100 -0
  39. package/dist/templates/code/src/app/admin/history/_lib/schema.ts +45 -0
  40. package/dist/templates/code/src/app/admin/history/_lib/service.server.ts +30 -0
  41. package/dist/templates/code/src/app/admin/history/_lib/validators.ts +118 -0
  42. package/dist/templates/code/src/app/admin/initiatives/_config/columns-data.tsx +1 -1
  43. package/dist/templates/code/src/app/admin/notifications/_components/command-toggle-push.tsx +115 -0
  44. package/dist/templates/code/src/app/admin/notifications/_components/tables.tsx +37 -0
  45. package/dist/templates/code/src/app/admin/notifications/_components/toolbars.tsx +21 -0
  46. package/dist/templates/code/src/app/admin/notifications/_config/columns-data.tsx +64 -0
  47. package/dist/templates/code/src/app/admin/notifications/_config/commands.tsx +40 -0
  48. package/dist/templates/code/src/app/admin/notifications/_lib/listeners.server.ts +69 -0
  49. package/dist/templates/code/src/app/admin/notifications/_lib/push.server.ts +80 -0
  50. package/dist/templates/code/src/app/admin/notifications/_lib/router.ts +82 -0
  51. package/dist/templates/code/src/app/admin/notifications/_lib/schema.ts +49 -0
  52. package/dist/templates/code/src/app/admin/notifications/_lib/validators.ts +19 -0
  53. package/dist/templates/code/src/app/admin/notifications/layout.tsx +17 -0
  54. package/dist/templates/code/src/app/admin/notifications/loading.tsx +9 -0
  55. package/dist/templates/code/src/app/admin/notifications/page.tsx +28 -0
  56. package/dist/templates/code/src/app/admin/projects/[id]/(default)/page.tsx +1 -1
  57. package/dist/templates/code/src/app/admin/projects/[id]/timeline/page.tsx +1 -1
  58. package/dist/templates/code/src/app/admin/projects/_config/columns-data.tsx +1 -1
  59. package/dist/templates/code/src/app/admin/projects/_lib/events.ts +64 -0
  60. package/dist/templates/code/src/app/admin/projects/_lib/listeners.server.ts +82 -0
  61. package/dist/templates/code/src/app/admin/projects/_lib/router.ts +145 -20
  62. package/dist/templates/code/src/app/admin/projects/timeline/page.tsx +1 -1
  63. package/dist/templates/code/src/app/admin/prospects/_config/columns-data.tsx +1 -1
  64. package/dist/templates/code/src/app/admin/queue/_components/create-command.tsx +97 -0
  65. package/dist/templates/code/src/app/admin/queue/_components/tables.tsx +37 -0
  66. package/dist/templates/code/src/app/admin/queue/_components/toolbars.tsx +16 -0
  67. package/dist/templates/code/src/app/admin/queue/_config/columns-data.tsx +44 -0
  68. package/dist/templates/code/src/app/admin/queue/_config/commands.tsx +39 -0
  69. package/dist/templates/code/src/app/admin/queue/_lib/queue.ts +27 -0
  70. package/dist/templates/code/src/app/admin/queue/_lib/router.ts +33 -0
  71. package/dist/templates/code/src/app/admin/queue/_lib/schema.ts +12 -0
  72. package/dist/templates/code/src/app/admin/queue/_lib/validators.ts +15 -0
  73. package/dist/templates/code/src/app/admin/quotations/(list)/layout.tsx +24 -0
  74. package/dist/templates/code/src/app/admin/quotations/(list)/page.tsx +23 -0
  75. package/dist/templates/code/src/app/admin/quotations/[id]/(dashboard)/layout.tsx +40 -0
  76. package/dist/templates/code/src/app/admin/quotations/[id]/(dashboard)/page.tsx +56 -0
  77. package/dist/templates/code/src/app/admin/quotations/[id]/preview/page.tsx +17 -0
  78. package/dist/templates/code/src/app/admin/quotations/_blocks/commands.tsx +24 -0
  79. package/dist/templates/code/src/app/admin/quotations/_blocks/hero/config.ts +14 -0
  80. package/dist/templates/code/src/app/admin/quotations/_blocks/hero/create.tsx +152 -0
  81. package/dist/templates/code/src/app/admin/quotations/_blocks/hero/inline.tsx +90 -0
  82. package/dist/templates/code/src/app/admin/quotations/_blocks/hero/preview.tsx +90 -0
  83. package/dist/templates/code/src/app/admin/quotations/_blocks/registry.ts +13 -0
  84. package/dist/templates/code/src/app/admin/quotations/_blocks/render.tsx +190 -0
  85. package/dist/templates/code/src/app/admin/quotations/_blocks/styles.ts +24 -0
  86. package/dist/templates/code/src/app/admin/quotations/_blocks/summary/config.ts +13 -0
  87. package/dist/templates/code/src/app/admin/quotations/_blocks/summary/create.tsx +175 -0
  88. package/dist/templates/code/src/app/admin/quotations/_blocks/summary/inline.tsx +93 -0
  89. package/dist/templates/code/src/app/admin/quotations/_blocks/summary/preview.tsx +25 -0
  90. package/dist/templates/code/src/app/admin/quotations/_blocks/totals/config.ts +18 -0
  91. package/dist/templates/code/src/app/admin/quotations/_blocks/totals/create.tsx +177 -0
  92. package/dist/templates/code/src/app/admin/quotations/_blocks/totals/inline.tsx +96 -0
  93. package/dist/templates/code/src/app/admin/quotations/_blocks/totals/preview.tsx +32 -0
  94. package/dist/templates/code/src/app/admin/quotations/_components/create-command.tsx +97 -0
  95. package/dist/templates/code/src/app/admin/quotations/_components/tables.tsx +68 -0
  96. package/dist/templates/code/src/app/admin/quotations/_components/toolbars.tsx +18 -0
  97. package/dist/templates/code/src/app/admin/quotations/_config/columns-data.tsx +35 -0
  98. package/dist/templates/code/src/app/admin/quotations/_config/commands.tsx +71 -0
  99. package/dist/templates/code/src/app/admin/quotations/_config/filters.ts +22 -0
  100. package/dist/templates/code/src/app/admin/quotations/_config/sorting.ts +23 -0
  101. package/dist/templates/code/src/app/admin/quotations/_lib/router.ts +260 -0
  102. package/dist/templates/code/src/app/admin/quotations/_lib/schema.ts +42 -0
  103. package/dist/templates/code/src/app/admin/quotations/_lib/validators.ts +20 -0
  104. package/dist/templates/code/src/app/admin/settings/history/layout.tsx +6 -0
  105. package/dist/templates/code/src/app/admin/settings/history/loading.tsx +5 -0
  106. package/dist/templates/code/src/app/admin/settings/history/page.tsx +13 -0
  107. package/dist/templates/code/src/app/admin/settings/layout.tsx +11 -0
  108. package/dist/templates/code/src/app/admin/settings/queue/loading.tsx +9 -0
  109. package/dist/templates/code/src/app/admin/settings/queue/page.tsx +21 -0
  110. package/dist/templates/code/src/app/admin/tasks/_components/domains-tabs-grid.tsx +1 -1
  111. package/dist/templates/code/src/app/admin/tasks/_config/columns-data.tsx +1 -1
  112. package/dist/templates/code/src/app/admin/tasks/_config/columns-inline.tsx +1 -1
  113. package/dist/templates/code/src/app/admin/tasks/_lib/events.ts +40 -0
  114. package/dist/templates/code/src/app/admin/tasks/_lib/listeners.server.ts +46 -0
  115. package/dist/templates/code/src/app/admin/tasks/_lib/router.ts +222 -90
  116. package/dist/templates/code/src/app/admin/time/_config/columns-data.tsx +1 -1
  117. package/dist/templates/code/src/app/admin/time/_config/columns-inline.tsx +1 -1
  118. package/dist/templates/code/src/app/api/[transport]/route.ts +31 -21
  119. package/dist/templates/code/src/app/api/queue/dispatch/route.ts +4 -0
  120. package/dist/templates/code/src/app/auth/forget-password/page.tsx +1 -1
  121. package/dist/templates/code/src/app/auth/layout.tsx +1 -1
  122. package/dist/templates/code/src/app/auth/reset-password/page.tsx +1 -1
  123. package/dist/templates/code/src/app/layout.tsx +25 -13
  124. package/dist/templates/code/src/env.ts +12 -0
  125. package/dist/templates/code/src/lib/config/paths.tsx +16 -0
  126. package/dist/templates/code/src/lib/utils/fonts.ts +102 -0
  127. package/dist/templates/code/src/server/auth/constants.ts +1 -1
  128. package/dist/templates/code/src/server/auth/init.ts +12 -5
  129. package/dist/templates/code/src/server/auth/permissions.ts +7 -0
  130. package/dist/templates/code/src/server/auth/schema.ts +100 -1
  131. package/dist/templates/code/src/server/db/schema.ts +4 -0
  132. package/dist/templates/code/src/server/events/handlers.ts +17 -0
  133. package/dist/templates/code/src/server/events/init.ts +18 -0
  134. package/dist/templates/code/src/server/events/listeners.ts +17 -0
  135. package/dist/templates/code/src/server/queue/handlers.ts +5 -0
  136. package/dist/templates/code/src/server/queue/init.ts +16 -0
  137. package/dist/templates/code/src/server/router/router.ts +23 -0
  138. package/dist/templates/spark/package.json +64 -65
  139. package/dist/templates/spark/src/app/admin/assignments/[id]/(default)/@dashboard/page.tsx +1 -1
  140. package/dist/templates/spark/src/app/admin/assignments/_config/columns-data.tsx +1 -1
  141. package/dist/templates/spark/src/app/admin/companies/_config/columns-data.tsx +1 -1
  142. package/dist/templates/spark/src/app/admin/contacts/_components/grid.tsx +1 -1
  143. package/dist/templates/spark/src/app/admin/customers/_config/columns-data.tsx +1 -1
  144. package/dist/templates/spark/src/app/admin/drive/_config/columns-data.tsx +1 -1
  145. package/dist/templates/spark/src/app/admin/emails/_components/send-email.tsx +1 -1
  146. package/dist/templates/spark/src/app/admin/emails/_config/columns-data.tsx +1 -1
  147. package/dist/templates/spark/src/app/admin/employees/_config/columns-data.tsx +1 -1
  148. package/dist/templates/spark/src/app/admin/executions/[id]/@dashboard/page.tsx +1 -1
  149. package/dist/templates/spark/src/app/admin/executions/_config/columns-data.tsx +1 -1
  150. package/dist/templates/spark/src/app/admin/products/[id]/(preview)/page.tsx +1 -1
  151. package/dist/templates/spark/src/app/admin/products/_config/columns-data.tsx +1 -1
  152. package/dist/templates/spark/src/app/admin/projects/[id]/(dashboard)/page.tsx +1 -1
  153. package/dist/templates/spark/src/app/admin/projects/[id]/messages/page.tsx +1 -1
  154. package/dist/templates/spark/src/app/admin/projects/[id]/statistics/page.tsx +1 -1
  155. package/dist/templates/spark/src/app/admin/projects/_config/columns-data.tsx +1 -1
  156. package/dist/templates/spark/src/app/admin/purchase-order-items/_config/columns-data.tsx +1 -1
  157. package/dist/templates/spark/src/app/admin/purchase-order-items/_config/columns-inline.tsx +1 -1
  158. package/dist/templates/spark/src/app/admin/purchase-order-items/_config/commands.tsx +1 -1
  159. package/dist/templates/spark/src/app/admin/purchase-orders/[id]/(default)/@dashboard/page.tsx +1 -1
  160. package/dist/templates/spark/src/app/admin/purchase-orders/_config/columns-data.tsx +1 -1
  161. package/dist/templates/spark/src/app/admin/quotation-items/_config/commands.tsx +1 -1
  162. package/dist/templates/spark/src/app/admin/quotations/[id]/(default)/@dashboard/page.tsx +1 -1
  163. package/dist/templates/spark/src/app/admin/quotations/_config/columns-data.tsx +1 -1
  164. package/dist/templates/spark/src/app/admin/run-tasks/_config/columns-data.tsx +1 -1
  165. package/dist/templates/spark/src/app/admin/runs/[id]/@dashboard/page.tsx +1 -1
  166. package/dist/templates/spark/src/app/admin/runs/_config/columns-data.tsx +1 -1
  167. package/dist/templates/spark/src/app/admin/sales-invoices/[id]/@dashboard/page.tsx +1 -1
  168. package/dist/templates/spark/src/app/admin/sales-invoices/_config/columns-data.tsx +1 -1
  169. package/dist/templates/spark/src/app/admin/sales-order-executions/_config/columns-data.tsx +1 -1
  170. package/dist/templates/spark/src/app/admin/sales-order-executions/_config/columns-inline.tsx +1 -1
  171. package/dist/templates/spark/src/app/admin/sales-order-executions/_config/commands.tsx +1 -1
  172. package/dist/templates/spark/src/app/admin/sales-order-items/_config/commands.tsx +1 -1
  173. package/dist/templates/spark/src/app/admin/sales-orders/[id]/(default)/@dashboard/page.tsx +1 -1
  174. package/dist/templates/spark/src/app/admin/sales-orders/[id]/(default)/layout.tsx +1 -1
  175. package/dist/templates/spark/src/app/admin/sales-orders/_config/columns-data.tsx +1 -1
  176. package/dist/templates/spark/src/app/admin/schedule/_components/schedule-card.tsx +1 -1
  177. package/dist/templates/spark/src/app/admin/schedule/_components/schedule-month-view.tsx +1 -1
  178. package/dist/templates/spark/src/app/admin/suppliers/_config/columns-data.tsx +1 -1
  179. package/dist/templates/spark/src/app/admin/tasks/_components/subproject-selector.tsx +1 -1
  180. package/dist/templates/spark/src/app/admin/tasks/_config/columns-data.tsx +1 -1
  181. package/dist/templates/spark/src/app/admin/tasks/_config/columns-inline.tsx +1 -1
  182. package/dist/templates/spark/src/app/admin/tasks/_config/commands.tsx +1 -1
  183. package/dist/templates/spark/src/app/admin/technical-sheets/[id]/page.tsx +1 -1
  184. package/dist/templates/spark/src/app/admin/technical-sheets/_config/columns-data.tsx +1 -1
  185. package/dist/templates/spark/src/app/admin/technical-sheets/_config/commands.tsx +1 -1
  186. package/dist/templates/spark/src/app/admin/templates/_config/columns-data.tsx +1 -1
  187. package/dist/templates/spark/src/app/auth/layout.tsx +1 -1
  188. package/dist/templates/spark/src/app/layout.tsx +25 -10
  189. package/dist/templates/spark/src/server/auth/init.ts +1 -0
  190. package/package.json +6 -6
@@ -0,0 +1,377 @@
1
+ "use client";
2
+
3
+ import { convertEditorContentToPlainText, type EditorJSONContent } from "@tulip-systems/editor";
4
+ import { Button } from "@tulip-systems/ui/button";
5
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@tulip-systems/ui/collapsible";
6
+ import {
7
+ Empty,
8
+ EmptyDescription,
9
+ EmptyHeader,
10
+ EmptyMedia,
11
+ EmptyTitle,
12
+ } from "@tulip-systems/ui/empty";
13
+ import { Switch } from "@tulip-systems/ui/switch";
14
+ import { diffLines, diffWordsWithSpace } from "diff";
15
+ import equal from "fast-deep-equal";
16
+ import { ChevronDownIcon, FileDiffIcon } from "lucide-react";
17
+ import { useState } from "react";
18
+ import { applyHistoryChanges } from "../_lib/helpers";
19
+ import { type HistoryDiff, historyDiffInputSchema, type JSONValue } from "../_lib/validators";
20
+ import {
21
+ FieldComparison,
22
+ FieldComparisonAdded,
23
+ FieldComparisonCell,
24
+ FieldComparisonHeader,
25
+ FieldComparisonHeading,
26
+ FieldComparisonLabel,
27
+ FieldComparisonRemoved,
28
+ FieldComparisonRow,
29
+ } from "./field-comparison";
30
+
31
+ /**
32
+ * Convert stored field names to labels without module-specific mappings.
33
+ */
34
+ function fieldLabel(key: string): string {
35
+ return key
36
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
37
+ .replaceAll("_", " ")
38
+ .replaceAll("-", " ")
39
+ .replace(/\bid\b/gi, "ID")
40
+ .replace(/^./, (letter) => letter.toUpperCase());
41
+ }
42
+
43
+ /**
44
+ * Identify nested objects and editor documents without resolving related records.
45
+ */
46
+ function isObject(value: JSONValue | undefined): value is Record<string, JSONValue> {
47
+ return value !== null && typeof value === "object" && !Array.isArray(value);
48
+ }
49
+
50
+ /**
51
+ * Convert recognized editor documents to readable text.
52
+ */
53
+ function editorText(value: JSONValue | undefined): string | undefined {
54
+ if (!isObject(value) || value.type !== "doc" || !Array.isArray(value.content)) return undefined;
55
+ return convertEditorContentToPlainText(value as EditorJSONContent);
56
+ }
57
+
58
+ /**
59
+ * Render values as content, preserving empty and absent states and grouping array items.
60
+ */
61
+ function StoredValue({ value }: { value: JSONValue | undefined }) {
62
+ if (value === undefined) return <span className="text-muted-foreground italic">Not present</span>;
63
+ if (value === null) return <span className="text-muted-foreground italic">No value (null)</span>;
64
+ if (value === "") return <span className="text-muted-foreground italic">Empty text</span>;
65
+ if (Array.isArray(value)) {
66
+ if (!value.length) return <span className="text-muted-foreground italic">Empty list</span>;
67
+ return (
68
+ <ol className="list-inside list-decimal space-y-3">
69
+ {value.map((item, index) => (
70
+ <li key={`${index}:${JSON.stringify(item)}`}>
71
+ <StoredValue value={item} />
72
+ </li>
73
+ ))}
74
+ </ol>
75
+ );
76
+ }
77
+ if (isObject(value)) {
78
+ if (!Object.keys(value).length)
79
+ return <span className="text-muted-foreground italic">Empty object</span>;
80
+ return (
81
+ <dl className="space-y-3">
82
+ {Object.entries(value).map(([key, item]) => (
83
+ <div key={key}>
84
+ <dt className="mb-1 text-muted-foreground text-xs">{fieldLabel(key)}</dt>
85
+ <dd>
86
+ <StoredValue value={item} />
87
+ </dd>
88
+ </div>
89
+ ))}
90
+ </dl>
91
+ );
92
+ }
93
+ return <span className="whitespace-pre-wrap break-words">{String(value)}</span>;
94
+ }
95
+
96
+ /**
97
+ * Present formatted fields with a local switch for unchanged values.
98
+ */
99
+ function FormattedComparison({
100
+ before,
101
+ after,
102
+ }: {
103
+ before: JSONValue | undefined;
104
+ after: JSONValue | undefined;
105
+ }) {
106
+ const [showUnchanged, setShowUnchanged] = useState(false);
107
+
108
+ return (
109
+ <div className="flex flex-col gap-3">
110
+ <FieldComparison>
111
+ <FieldComparisonHeader>
112
+ <FieldComparisonHeading>Before</FieldComparisonHeading>
113
+ <FieldComparisonHeading>After</FieldComparisonHeading>
114
+ </FieldComparisonHeader>
115
+ {!showUnchanged && equal(before, after) ? (
116
+ <p className="px-4 py-3 text-muted-foreground">No fields changed.</p>
117
+ ) : (
118
+ <FormattedFields before={before} after={after} showUnchanged={showUnchanged} />
119
+ )}
120
+ </FieldComparison>
121
+
122
+ <label
123
+ htmlFor="history-show-unchanged"
124
+ className="flex items-center gap-2 self-end text-muted-foreground text-sm"
125
+ >
126
+ <Switch
127
+ id="history-show-unchanged"
128
+ checked={showUnchanged}
129
+ onCheckedChange={setShowUnchanged}
130
+ />
131
+ Show unchanged fields
132
+ </label>
133
+ </div>
134
+ );
135
+ }
136
+
137
+ /**
138
+ * Compare fields in compact rows, using readable paths for nested values.
139
+ */
140
+ function FormattedFields({
141
+ label,
142
+ before,
143
+ after,
144
+ showUnchanged,
145
+ }: {
146
+ label?: string;
147
+ before: JSONValue | undefined;
148
+ after: JSONValue | undefined;
149
+ showUnchanged: boolean;
150
+ }) {
151
+ const changed = !equal(before, after);
152
+ if (!changed && !showUnchanged) return null;
153
+ const beforeText = editorText(before);
154
+ const afterText = editorText(after);
155
+ const left = beforeText ?? before;
156
+ const right = afterText ?? after;
157
+
158
+ if ((isObject(left) || left === undefined) && (isObject(right) || right === undefined)) {
159
+ const keys = [...new Set([...Object.keys(left ?? {}), ...Object.keys(right ?? {})])].toSorted();
160
+ if (keys.length) {
161
+ return (
162
+ <>
163
+ {keys.map((key) => (
164
+ <FormattedFields
165
+ key={key}
166
+ label={label ? `${label} › ${fieldLabel(key)}` : fieldLabel(key)}
167
+ before={Object.hasOwn(left ?? {}, key) ? left?.[key] : undefined}
168
+ after={Object.hasOwn(right ?? {}, key) ? right?.[key] : undefined}
169
+ showUnchanged={showUnchanged}
170
+ />
171
+ ))}
172
+ </>
173
+ );
174
+ }
175
+ }
176
+
177
+ const words =
178
+ typeof left === "string" && typeof right === "string" ? diffWordsWithSpace(left, right) : null;
179
+
180
+ return (
181
+ <FieldComparisonRow aria-label={label ?? "Value"}>
182
+ <FieldComparisonCell>
183
+ <FieldComparisonLabel className="flex flex-wrap items-center gap-2">
184
+ {label ?? "Value"}
185
+ {!changed && <span className="font-normal">Unchanged</span>}
186
+ </FieldComparisonLabel>
187
+ {words && left !== "" ? (
188
+ <span className="whitespace-pre-wrap break-words">
189
+ {words.map((part, index) => {
190
+ if (part.added) return null;
191
+ if (!part.removed) return part.value;
192
+ return (
193
+ <FieldComparisonRemoved key={`${index}:${part.value}`}>
194
+ {part.value}
195
+ </FieldComparisonRemoved>
196
+ );
197
+ })}
198
+ </span>
199
+ ) : (
200
+ <div
201
+ className={changed && after === undefined ? "text-destructive line-through" : undefined}
202
+ >
203
+ <StoredValue value={left} />
204
+ </div>
205
+ )}
206
+ </FieldComparisonCell>
207
+ <FieldComparisonCell>
208
+ <FieldComparisonLabel className="flex flex-wrap items-center gap-2">
209
+ {label ?? "Value"}
210
+ {!changed && <span className="font-normal">Unchanged</span>}
211
+ </FieldComparisonLabel>
212
+ {words && right !== "" ? (
213
+ <span className="whitespace-pre-wrap break-words">
214
+ {words.map((part, index) => {
215
+ if (part.removed) return null;
216
+ if (!part.added) return part.value;
217
+ return (
218
+ <FieldComparisonAdded key={`${index}:${part.value}`}>
219
+ {part.value}
220
+ </FieldComparisonAdded>
221
+ );
222
+ })}
223
+ </span>
224
+ ) : (
225
+ <div className={changed && before === undefined ? "text-primary" : undefined}>
226
+ <StoredValue value={right} />
227
+ </div>
228
+ )}
229
+ </FieldComparisonCell>
230
+ {changed && beforeText !== undefined && beforeText === afterText && (
231
+ <p className="col-span-2 px-4 pb-3 text-muted-foreground text-xs">
232
+ Formatting or structure changed. The text is unchanged.
233
+ </p>
234
+ )}
235
+ </FieldComparisonRow>
236
+ );
237
+ }
238
+
239
+ /**
240
+ * Show complete before/after JSON snapshots with changed lines highlighted.
241
+ */
242
+ function TechnicalComparison({ before, after }: { before: JSONValue; after: JSONValue }) {
243
+ const parts = diffLines(
244
+ `${JSON.stringify(before, null, 2)}\n`,
245
+ `${JSON.stringify(after, null, 2)}\n`,
246
+ );
247
+ return (
248
+ <FieldComparison>
249
+ <FieldComparisonHeader>
250
+ <FieldComparisonHeading>Before snapshot</FieldComparisonHeading>
251
+ <FieldComparisonHeading>After snapshot</FieldComparisonHeading>
252
+ </FieldComparisonHeader>
253
+ <div className="py-2">
254
+ {parts.map((part, index) => {
255
+ if (part.added && parts[index - 1]?.removed) return null;
256
+ const addition = part.removed ? parts[index + 1] : part;
257
+
258
+ return (
259
+ <FieldComparisonRow key={`${index}:${part.value}`} className="not-last:border-b-0">
260
+ <FieldComparisonCell className="py-0 font-mono text-xs leading-6">
261
+ <pre className="whitespace-pre-wrap break-all">
262
+ {part.removed && (
263
+ <FieldComparisonRemoved className="block rounded-none no-underline">
264
+ {part.value}
265
+ </FieldComparisonRemoved>
266
+ )}
267
+ {!part.removed && !part.added && part.value}
268
+ </pre>
269
+ </FieldComparisonCell>
270
+ <FieldComparisonCell className="py-0 font-mono text-xs leading-6">
271
+ <pre className="whitespace-pre-wrap break-all">
272
+ {addition?.added && (
273
+ <FieldComparisonAdded className="block rounded-none">
274
+ {addition.value}
275
+ </FieldComparisonAdded>
276
+ )}
277
+ {!part.removed && !part.added && part.value}
278
+ </pre>
279
+ </FieldComparisonCell>
280
+ </FieldComparisonRow>
281
+ );
282
+ })}
283
+ </div>
284
+ </FieldComparison>
285
+ );
286
+ }
287
+
288
+ /**
289
+ * Reconstruct the final resource once, then display field comparisons independently of patch operations.
290
+ */
291
+ export function HistoryDiffComparison({ diff }: { diff: HistoryDiff | null }) {
292
+ if (!diff) {
293
+ return (
294
+ <Empty className="min-h-[80vh] border py-12">
295
+ <EmptyHeader>
296
+ <EmptyMedia variant="icon">
297
+ <FileDiffIcon aria-hidden="true" />
298
+ </EmptyMedia>
299
+ <EmptyTitle>No diff recorded</EmptyTitle>
300
+ <EmptyDescription>
301
+ Detailed changes were not recorded for this history entry.
302
+ </EmptyDescription>
303
+ </EmptyHeader>
304
+ </Empty>
305
+ );
306
+ }
307
+
308
+ const parsed = historyDiffInputSchema.safeParse(diff);
309
+ if (!parsed.success) {
310
+ return (
311
+ <Empty className="min-h-[80vh] border py-12">
312
+ <EmptyHeader>
313
+ <EmptyMedia variant="icon">
314
+ <FileDiffIcon aria-hidden="true" />
315
+ </EmptyMedia>
316
+ <EmptyTitle>Diff unavailable</EmptyTitle>
317
+ <EmptyDescription>
318
+ This history entry uses a diff format that cannot be displayed.
319
+ </EmptyDescription>
320
+ </EmptyHeader>
321
+ </Empty>
322
+ );
323
+ }
324
+ const after = applyHistoryChanges(parsed.data.snapshot, parsed.data.changes);
325
+
326
+ return (
327
+ <div className="flex flex-col gap-3">
328
+ <Collapsible defaultOpen>
329
+ <CollapsibleTrigger asChild>
330
+ <Button variant="outline" className="group w-full">
331
+ Changes
332
+ <ChevronDownIcon className="ml-auto group-data-[state=open]:rotate-180" />
333
+ </Button>
334
+ </CollapsibleTrigger>
335
+
336
+ <CollapsibleContent className="pt-3 pb-8">
337
+ <FormattedComparison
338
+ before={
339
+ parsed.data.changes[0]?.op === "add" && parsed.data.changes[0].path === ""
340
+ ? undefined
341
+ : parsed.data.snapshot
342
+ }
343
+ after={after}
344
+ />
345
+ </CollapsibleContent>
346
+ </Collapsible>
347
+
348
+ <Collapsible>
349
+ <CollapsibleTrigger asChild>
350
+ <Button variant="outline" className="group w-full">
351
+ Technical changes
352
+ <ChevronDownIcon className="ml-auto group-data-[state=open]:rotate-180" />
353
+ </Button>
354
+ </CollapsibleTrigger>
355
+
356
+ <CollapsibleContent className="pt-3 pb-6">
357
+ <TechnicalComparison before={parsed.data.snapshot} after={after} />
358
+ </CollapsibleContent>
359
+ </Collapsible>
360
+
361
+ <Collapsible>
362
+ <CollapsibleTrigger asChild>
363
+ <Button variant="outline" className="group w-full">
364
+ Payload
365
+ <ChevronDownIcon className="ml-auto group-data-[state=open]:rotate-180" />
366
+ </Button>
367
+ </CollapsibleTrigger>
368
+
369
+ <CollapsibleContent className="pt-3 pb-6">
370
+ <pre className="max-h-96 overflow-auto rounded-md border bg-muted/30 p-4 text-xs leading-6">
371
+ {JSON.stringify(parsed.data.changes, null, 2)}
372
+ </pre>
373
+ </CollapsibleContent>
374
+ </Collapsible>
375
+ </div>
376
+ );
377
+ }
@@ -0,0 +1,153 @@
1
+ import { cn } from "@tulip-systems/ui/cn";
2
+ import type { ComponentProps } from "react";
3
+
4
+ /**
5
+ * Contain a two-column comparison in a bordered card that scrolls on narrow screens.
6
+ *
7
+ * @example
8
+ * <FieldComparison>
9
+ * <FieldComparisonHeader>
10
+ * <FieldComparisonHeading>Before</FieldComparisonHeading>
11
+ * <FieldComparisonHeading>After</FieldComparisonHeading>
12
+ * </FieldComparisonHeader>
13
+ * <FieldComparisonRow>
14
+ * <FieldComparisonCell>
15
+ * <FieldComparisonLabel>Status</FieldComparisonLabel>
16
+ * <FieldComparisonRemoved>Planned</FieldComparisonRemoved>
17
+ * </FieldComparisonCell>
18
+ * <FieldComparisonCell>
19
+ * <FieldComparisonLabel>Status</FieldComparisonLabel>
20
+ * <FieldComparisonAdded>Active</FieldComparisonAdded>
21
+ * </FieldComparisonCell>
22
+ * </FieldComparisonRow>
23
+ * </FieldComparison>
24
+ */
25
+ function FieldComparison({ className, children, ...props }: ComponentProps<"div">) {
26
+ return (
27
+ <div className={cn("overflow-x-auto rounded-md border border-border", className)} {...props}>
28
+ <div className="min-w-[36rem]">{children}</div>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Align two column headings above the comparison rows.
35
+ *
36
+ * @example
37
+ * <FieldComparisonHeader>
38
+ * <FieldComparisonHeading>Before</FieldComparisonHeading>
39
+ * <FieldComparisonHeading>After</FieldComparisonHeading>
40
+ * </FieldComparisonHeader>
41
+ */
42
+ function FieldComparisonHeader({ className, ...props }: ComponentProps<"div">) {
43
+ return (
44
+ <div
45
+ className={cn("grid grid-cols-2 border-border border-b bg-muted/50", className)}
46
+ {...props}
47
+ />
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Label a column, matching the content spacing and center divider below it.
53
+ *
54
+ * @example
55
+ * <FieldComparisonHeading>Before snapshot</FieldComparisonHeading>
56
+ */
57
+ function FieldComparisonHeading({ className, ...props }: ComponentProps<"div">) {
58
+ return (
59
+ <div
60
+ className={cn(
61
+ "min-w-0 border-border px-4 py-2 font-medium text-muted-foreground text-xs even:border-l",
62
+ className,
63
+ )}
64
+ {...props}
65
+ />
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Align a pair of cells with equal widths and a subtle separator between rows.
71
+ *
72
+ * @example
73
+ * <FieldComparisonRow>
74
+ * <FieldComparisonCell>Planned</FieldComparisonCell>
75
+ * <FieldComparisonCell>Active</FieldComparisonCell>
76
+ * </FieldComparisonRow>
77
+ */
78
+ function FieldComparisonRow({ className, ...props }: ComponentProps<"div">) {
79
+ return (
80
+ <div className={cn("grid grid-cols-2 border-border not-last:border-b", className)} {...props} />
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Hold arbitrary comparison content with consistent padding and a center divider.
86
+ *
87
+ * @example
88
+ * <FieldComparisonCell>
89
+ * <FieldComparisonLabel>Title</FieldComparisonLabel>
90
+ * Swimwise
91
+ * </FieldComparisonCell>
92
+ */
93
+ function FieldComparisonCell({ className, ...props }: ComponentProps<"div">) {
94
+ return (
95
+ <div
96
+ className={cn(
97
+ "min-w-0 break-words border-border px-4 py-2.5 text-sm leading-relaxed even:border-l",
98
+ className,
99
+ )}
100
+ {...props}
101
+ />
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Display a muted field label above a cell's value.
107
+ *
108
+ * @example
109
+ * <FieldComparisonLabel>Settings › Visibility</FieldComparisonLabel>
110
+ */
111
+ function FieldComparisonLabel({ className, ...props }: ComponentProps<"div">) {
112
+ return (
113
+ <div className={cn("mb-1 font-medium text-muted-foreground text-xs", className)} {...props} />
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Mark removed content with semantic deletion markup and Tulip's destructive color.
119
+ *
120
+ * @example
121
+ * <FieldComparisonRemoved>Planned</FieldComparisonRemoved>
122
+ */
123
+ function FieldComparisonRemoved({ className, ...props }: ComponentProps<"del">) {
124
+ return (
125
+ <del className={cn("rounded-sm bg-destructive/10 text-destructive", className)} {...props} />
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Mark added content with semantic insertion markup and Tulip's primary color.
131
+ *
132
+ * @example
133
+ * <FieldComparisonAdded>Active</FieldComparisonAdded>
134
+ */
135
+ function FieldComparisonAdded({ className, ...props }: ComponentProps<"ins">) {
136
+ return (
137
+ <ins
138
+ className={cn("rounded-sm bg-primary/10 text-primary no-underline", className)}
139
+ {...props}
140
+ />
141
+ );
142
+ }
143
+
144
+ export {
145
+ FieldComparison,
146
+ FieldComparisonAdded,
147
+ FieldComparisonCell,
148
+ FieldComparisonHeader,
149
+ FieldComparisonHeading,
150
+ FieldComparisonLabel,
151
+ FieldComparisonRemoved,
152
+ FieldComparisonRow,
153
+ };
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
4
+ import type { TableColumnDef } from "@tulip-systems/data-tables";
5
+ import {
6
+ createTableConfig,
7
+ TableConfigProvider,
8
+ usePaginationStrategy,
9
+ } from "@tulip-systems/data-tables/client";
10
+ import { offsetPagination } from "@tulip-systems/query";
11
+ import { useFilters, usePagination, useSorting } from "@tulip-systems/query/client";
12
+ import { type PropsWithChildren, useDeferredValue, useMemo } from "react";
13
+ import { orpc } from "@/server/router/client";
14
+ import type { HistoryColumn } from "../_config/columns-data";
15
+ import { historyFilters } from "../_config/filters";
16
+ import { historyTableSorts } from "../_config/sorting";
17
+
18
+ export function HistoryPaginationTableProvider({
19
+ columns,
20
+ children,
21
+ }: PropsWithChildren<{ columns: TableColumnDef<HistoryColumn>[] }>) {
22
+ const filtering = useFilters(historyFilters);
23
+ const sorting = useSorting(historyTableSorts);
24
+ const pagination = usePagination(offsetPagination);
25
+ const input = useDeferredValue({
26
+ pagination: pagination.input,
27
+ filters: filtering.input,
28
+ sorts: sorting.input,
29
+ });
30
+
31
+ const response = useSuspenseQuery(orpc.history.list.queryOptions({ input }));
32
+ const queryData = useMemo(() => response.data.data ?? [], [response.data.data]);
33
+ const { total } = response.data.pagination;
34
+
35
+ const strategy = usePaginationStrategy({ pagination, total });
36
+
37
+ const config = createTableConfig<HistoryColumn>({
38
+ queryData,
39
+ getRowId: (row) => row.id,
40
+ columns,
41
+ strategy,
42
+ filtering,
43
+ sorting,
44
+ });
45
+
46
+ return <TableConfigProvider config={config}>{children}</TableConfigProvider>;
47
+ }
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
4
+ import { TableToolbar, TableToolbarList } from "@tulip-systems/data-tables";
5
+ import { resolveFilterDescriptors } from "@tulip-systems/query";
6
+ import { FilterCombobox, FilterSearch } from "@tulip-systems/query/client";
7
+ import { Suspense } from "react";
8
+ import { orpc } from "@/server/router/client";
9
+ import { historyFilters } from "../_config/filters";
10
+
11
+ const descriptors = resolveFilterDescriptors(historyFilters);
12
+
13
+ export function HistoryTableToolbar() {
14
+ return (
15
+ <TableToolbar>
16
+ <TableToolbarList>
17
+ <FilterSearch
18
+ placeholder="Search resources, actions or actors…"
19
+ filter={descriptors.search}
20
+ />
21
+
22
+ <Suspense>
23
+ <HistoryResourceFilter />
24
+ <HistoryActionFilter />
25
+ <HistoryActorFilter />
26
+ </Suspense>
27
+ </TableToolbarList>
28
+ </TableToolbar>
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Available resource types are supplied by recorded entries.
34
+ */
35
+ function HistoryResourceFilter() {
36
+ const { data } = useSuspenseQuery(orpc.history.filterResourceTypes.queryOptions());
37
+ return <FilterCombobox title="Resource" filter={descriptors.resourceType} options={data} />;
38
+ }
39
+
40
+ /**
41
+ * Action keys remain open so modules can introduce additional actions.
42
+ */
43
+ function HistoryActionFilter() {
44
+ const { data } = useSuspenseQuery(orpc.history.filterActions.queryOptions());
45
+ return <FilterCombobox title="Action" filter={descriptors.action} options={data} />;
46
+ }
47
+
48
+ /**
49
+ * Account IDs identify actors even when their recorded names change.
50
+ */
51
+ function HistoryActorFilter() {
52
+ const { data } = useSuspenseQuery(orpc.history.filterActors.queryOptions());
53
+ return <FilterCombobox title="Who" filter={descriptors.actorId} options={data} />;
54
+ }
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { formatDateTime } from "@tulip-systems/core";
4
+ import { type TableColumnDef, TableColumnHeader } from "@tulip-systems/data-tables";
5
+ import { TableTextCell } from "@tulip-systems/data-tables/client";
6
+ import Link from "next/link";
7
+ import type { HistoryEntry } from "../_lib/validators";
8
+
9
+ export type HistoryColumn = HistoryEntry;
10
+
11
+ export const historyColumns: TableColumnDef<HistoryColumn>[] = [
12
+ {
13
+ id: "summary",
14
+ // Render stored resource facts verbatim; modules own labels and context order.
15
+ accessorFn: (entry) =>
16
+ [entry.resourceType, entry.resourceLabel, entry.action].filter(Boolean).join(" · "),
17
+ enableSorting: false,
18
+ header: ({ column }) => <TableColumnHeader column={column} title="What happened" />,
19
+ cell: ({ getValue, row }) => (
20
+ <TableTextCell className="whitespace-nowrap">
21
+ <Link className="hover:underline" href={`/admin/history/${row.original.id}`}>
22
+ {getValue<string>()}
23
+
24
+ {row.original.context.length > 0 && (
25
+ <span className="text-muted-foreground">
26
+ {" — "}
27
+ {row.original.context.map((item) => `${item.type}: ${item.label}`).join(" › ")}
28
+ </span>
29
+ )}
30
+ </Link>
31
+ </TableTextCell>
32
+ ),
33
+ },
34
+ {
35
+ accessorKey: "actorName",
36
+ header: ({ column }) => <TableColumnHeader column={column} title="Who" />,
37
+ cell: ({ row }) => <TableTextCell>{row.getValue("actorName")}</TableTextCell>,
38
+ },
39
+ {
40
+ id: "action",
41
+ accessorFn: (entry) => entry.action,
42
+ header: ({ column }) => <TableColumnHeader column={column} title="Action" />,
43
+ cell: ({ row }) => <TableTextCell>{row.getValue("action")}</TableTextCell>,
44
+ },
45
+ {
46
+ accessorKey: "resourceLabel",
47
+ header: ({ column }) => <TableColumnHeader column={column} title="Resource title" />,
48
+ cell: ({ row }) => (
49
+ <TableTextCell className="whitespace-nowrap">{row.getValue("resourceLabel")}</TableTextCell>
50
+ ),
51
+ },
52
+ {
53
+ accessorKey: "occurredAt",
54
+ header: ({ column }) => <TableColumnHeader column={column} title="When" />,
55
+ cell: ({ row }) => (
56
+ <TableTextCell className="whitespace-nowrap">
57
+ {formatDateTime(row.original.occurredAt)}
58
+ </TableTextCell>
59
+ ),
60
+ },
61
+ ];