@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
@@ -13,6 +13,7 @@ import {
13
13
  desc,
14
14
  eq,
15
15
  exists,
16
+ getTableColumns,
16
17
  gte,
17
18
  ilike,
18
19
  inArray,
@@ -22,9 +23,12 @@ import {
22
23
  or,
23
24
  sql,
24
25
  } from "drizzle-orm";
26
+ import { after } from "next/server";
25
27
  import z from "zod";
28
+ import type { JSONValue } from "@/app/admin/history/_lib/validators";
26
29
  import { getTableData } from "@/app/lib/utils/table-data.server";
27
30
  import { customers, initiatives } from "@/server/db/schema";
31
+ import { events } from "@/server/events/init";
28
32
  import { permissionMiddleware } from "@/server/router/middleware";
29
33
  import { protectedProcedure } from "@/server/router/procedures";
30
34
  import type { ProjectColumn } from "../_config/columns-data";
@@ -34,6 +38,12 @@ import {
34
38
  projectTableSortsParser,
35
39
  projectTableSortsSchema,
36
40
  } from "../_config/sorting";
41
+ import {
42
+ projectArchivedEvent,
43
+ projectCreatedEvent,
44
+ projectRestoredEvent,
45
+ projectUpdatedEvent,
46
+ } from "./events";
37
47
  import { ensureProjectDriveFolder } from "./helpers.server";
38
48
  import { projects } from "./schema";
39
49
  import {
@@ -215,50 +225,135 @@ export const projectsRouter = {
215
225
  .use(permissionMiddleware({ project: ["create"] }))
216
226
  .input(createProjectSchema)
217
227
  .handler(async ({ context, input }) => {
218
- const [result] = await context.db.insert(projects).values(input).returning();
219
-
220
- if (!result) {
221
- throw new ORPCError("NOT_FOUND", {
222
- message: "Project kon niet worden aangemaakt",
228
+ const [created] = await context.db
229
+ .insert(projects)
230
+ .values(input)
231
+ .returning({
232
+ ...getTableColumns(projects),
233
+ after: sql<Record<string, JSONValue>>`to_jsonb(NEW)`,
223
234
  });
224
- }
235
+
236
+ if (!created) throw new ORPCError("INTERNAL_SERVER_ERROR");
237
+ const { after: snapshot, ...result } = created;
238
+
239
+ after(async () => {
240
+ await events.publish(
241
+ projectCreatedEvent,
242
+ {
243
+ actor: { id: context.user.id, name: context.user.name },
244
+ projectId: result.id,
245
+ key: result.key,
246
+ title: result.title,
247
+ status: result.status,
248
+ after: snapshot,
249
+ },
250
+ { occurredAt: new Date().toISOString() },
251
+ );
252
+ });
225
253
 
226
254
  return result;
227
255
  }),
228
256
  update: protectedProcedure
229
257
  .use(permissionMiddleware({ project: ["update"] }))
230
258
  .input(z.object({ id: z.string(), data: updateProjectSchema.partial() }))
231
- .output(z.void())
232
259
  .handler(async ({ context, input }) => {
233
260
  const [result] = await context.db
234
261
  .update(projects)
235
262
  .set(input.data)
236
263
  .where(eq(projects.id, input.id))
237
- .returning();
238
-
239
- if (!result) {
240
- throw new ORPCError("NOT_FOUND", {
241
- message: "Project kon niet worden gewijzigd",
264
+ .returning({
265
+ ...getTableColumns(projects),
266
+ before: sql<Record<string, JSONValue>>`to_jsonb(OLD)`,
267
+ after: sql<Record<string, JSONValue>>`to_jsonb(NEW)`,
242
268
  });
243
- }
269
+
270
+ if (!result) return;
271
+ const { before: previous, after: next, ...project } = result;
272
+ after(async () => {
273
+ await events.publish(
274
+ projectUpdatedEvent,
275
+ {
276
+ actor: { id: context.user.id, name: context.user.name },
277
+ projectId: project.id,
278
+ key: project.key,
279
+ title: project.title,
280
+ before: previous,
281
+ after: next,
282
+ },
283
+ { occurredAt: new Date().toISOString() },
284
+ );
285
+ });
286
+ return project;
244
287
  }),
245
288
  archive: protectedProcedure
246
289
  .use(permissionMiddleware({ project: ["archive"] }))
247
290
  .input(bulkActionSchema)
248
291
  .handler(async ({ context, input }) => {
249
- await context.db
292
+ const updated = await context.db
250
293
  .update(projects)
251
294
  .set({ deletedAt: new Date() })
252
- .where(inArray(projects.id, input.ids));
295
+ .where(and(inArray(projects.id, input.ids), isNull(projects.deletedAt)))
296
+ .returning({
297
+ id: projects.id,
298
+ key: projects.key,
299
+ title: projects.title,
300
+ before: sql<Record<string, JSONValue>>`to_jsonb(OLD)`,
301
+ after: sql<Record<string, JSONValue>>`to_jsonb(NEW)`,
302
+ });
303
+
304
+ after(async () => {
305
+ await Promise.all(
306
+ updated.map((project) =>
307
+ events.publish(
308
+ projectArchivedEvent,
309
+ {
310
+ actor: { id: context.user.id, name: context.user.name },
311
+ projectId: project.id,
312
+ key: project.key,
313
+ title: project.title,
314
+ before: project.before,
315
+ after: project.after,
316
+ },
317
+ { occurredAt: new Date().toISOString() },
318
+ ),
319
+ ),
320
+ );
321
+ });
253
322
  }),
254
323
  restore: protectedProcedure
255
324
  .use(permissionMiddleware({ project: ["archive"] }))
256
325
  .input(bulkActionSchema)
257
326
  .handler(async ({ context, input }) => {
258
- await context.db
327
+ const updated = await context.db
259
328
  .update(projects)
260
329
  .set({ deletedAt: null })
261
- .where(inArray(projects.id, input.ids));
330
+ .where(and(inArray(projects.id, input.ids), isNotNull(projects.deletedAt)))
331
+ .returning({
332
+ id: projects.id,
333
+ key: projects.key,
334
+ title: projects.title,
335
+ before: sql<Record<string, JSONValue>>`to_jsonb(OLD)`,
336
+ after: sql<Record<string, JSONValue>>`to_jsonb(NEW)`,
337
+ });
338
+
339
+ after(async () => {
340
+ await Promise.all(
341
+ updated.map((project) =>
342
+ events.publish(
343
+ projectRestoredEvent,
344
+ {
345
+ actor: { id: context.user.id, name: context.user.name },
346
+ projectId: project.id,
347
+ key: project.key,
348
+ title: project.title,
349
+ before: project.before,
350
+ after: project.after,
351
+ },
352
+ { occurredAt: new Date().toISOString() },
353
+ ),
354
+ ),
355
+ );
356
+ });
262
357
  }),
263
358
  delete: protectedProcedure
264
359
  .use(permissionMiddleware({ project: ["delete"] }))
@@ -270,10 +365,40 @@ export const projectsRouter = {
270
365
  .use(permissionMiddleware({ project: ["update"] }))
271
366
  .input(bulkActionSchema.extend({ status: projectStatusSchema }))
272
367
  .handler(async ({ context, input }) => {
273
- await context.db
368
+ const updated = await context.db
274
369
  .update(projects)
275
370
  .set({ status: input.status })
276
- .where(inArray(projects.id, input.ids))
277
- .returning();
371
+ .where(
372
+ and(
373
+ inArray(projects.id, input.ids),
374
+ sql`${projects.status} IS DISTINCT FROM ${input.status}`,
375
+ ),
376
+ )
377
+ .returning({
378
+ id: projects.id,
379
+ key: projects.key,
380
+ title: projects.title,
381
+ before: sql<Record<string, JSONValue>>`to_jsonb(OLD)`,
382
+ after: sql<Record<string, JSONValue>>`to_jsonb(NEW)`,
383
+ });
384
+
385
+ after(async () => {
386
+ await Promise.all(
387
+ updated.map((project) =>
388
+ events.publish(
389
+ projectUpdatedEvent,
390
+ {
391
+ actor: { id: context.user.id, name: context.user.name },
392
+ projectId: project.id,
393
+ key: project.key,
394
+ title: project.title,
395
+ before: project.before,
396
+ after: project.after,
397
+ },
398
+ { occurredAt: new Date().toISOString() },
399
+ ),
400
+ ),
401
+ );
402
+ });
278
403
  }),
279
404
  };
@@ -2,11 +2,11 @@
2
2
 
3
3
  import { Temporal } from "@js-temporal/polyfill";
4
4
  import { useSuspenseQuery } from "@tanstack/react-query";
5
+ import { Link } from "@tulip-systems/core/navigation";
5
6
  import { Button } from "@tulip-systems/ui/button";
6
7
  import { Select, SelectContent, SelectItem, SelectTrigger } from "@tulip-systems/ui/select";
7
8
  import { findStatus } from "@tulip-systems/ui/status";
8
9
  import { MinusIcon, PlusIcon } from "lucide-react";
9
- import Link from "next/link";
10
10
  import { parseAsStringEnum, useQueryState } from "nuqs";
11
11
  import { useState } from "react";
12
12
  import {
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
 
3
+ import { Link } from "@tulip-systems/core/navigation";
3
4
  import { TableCell, type TableColumnDef, TableColumnHeader } from "@tulip-systems/data-tables";
4
5
  import {
5
6
  createTableSelectCell,
6
7
  TableStatusCell,
7
8
  TableTextCell,
8
9
  } from "@tulip-systems/data-tables/client";
9
- import Link from "next/link";
10
10
  import type { Customer } from "../../customers/_lib/validators";
11
11
  import type { Prospect } from "../_lib/validators";
12
12
  import { ProspectStatusField } from "./status";
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
4
+ import type { DefaultError, MutationOptions } from "@tanstack/react-query";
5
+ import {
6
+ CommandFormDialog,
7
+ CommandFormDialogCancel,
8
+ CommandFormDialogContent,
9
+ CommandFormDialogFields,
10
+ CommandFormDialogFooter,
11
+ CommandFormDialogHeader,
12
+ CommandFormDialogSubmit,
13
+ CommandFormDialogTitle,
14
+ CommandFormDialogTrigger,
15
+ } from "@tulip-systems/commands/client";
16
+ import { Input } from "@tulip-systems/ui/input";
17
+ import {
18
+ Form,
19
+ FormControl,
20
+ FormField,
21
+ FormItem,
22
+ FormLabel,
23
+ FormMessage,
24
+ } from "@tulip-systems/ui/react-hook-form";
25
+ import type React from "react";
26
+ import { useForm } from "react-hook-form";
27
+ import {
28
+ type EnqueueQueueTaskInput,
29
+ type EnqueueQueueTaskSchema,
30
+ enqueueQueueTaskSchema,
31
+ } from "../_lib/validators";
32
+
33
+ export type EnqueueQueueTaskCommandProps<
34
+ TData = unknown,
35
+ TError = DefaultError,
36
+ TVariables = undefined,
37
+ TOnMutateResult = unknown,
38
+ > = {
39
+ variables: (values: EnqueueQueueTaskSchema) => TVariables;
40
+ mutation: MutationOptions<TData, TError, TVariables, TOnMutateResult>;
41
+ children?: React.ReactNode;
42
+ };
43
+
44
+ export function EnqueueQueueTaskCommand<
45
+ TData = unknown,
46
+ TError = DefaultError,
47
+ TVariables = undefined,
48
+ TOnMutateResult = unknown,
49
+ >({
50
+ variables,
51
+ mutation,
52
+ children,
53
+ }: EnqueueQueueTaskCommandProps<TData, TError, TVariables, TOnMutateResult>) {
54
+ const form = useForm<EnqueueQueueTaskInput>({
55
+ resolver: standardSchemaResolver(enqueueQueueTaskSchema),
56
+ defaultValues: {
57
+ message: "",
58
+ },
59
+ });
60
+
61
+ return (
62
+ <CommandFormDialog>
63
+ {children}
64
+
65
+ <Form {...form}>
66
+ <CommandFormDialogContent variables={variables} mutation={mutation}>
67
+ <CommandFormDialogHeader>
68
+ <CommandFormDialogTitle>Enqueue task</CommandFormDialogTitle>
69
+ </CommandFormDialogHeader>
70
+
71
+ <CommandFormDialogFields>
72
+ <FormField
73
+ control={form.control}
74
+ name="message"
75
+ render={({ field }) => (
76
+ <FormItem>
77
+ <FormLabel>Message</FormLabel>
78
+ <FormControl>
79
+ <Input {...field} placeholder="Enter your message" />
80
+ </FormControl>
81
+ <FormMessage />
82
+ </FormItem>
83
+ )}
84
+ />
85
+ </CommandFormDialogFields>
86
+
87
+ <CommandFormDialogFooter>
88
+ <CommandFormDialogCancel>Cancel</CommandFormDialogCancel>
89
+ <CommandFormDialogSubmit>Enqueue</CommandFormDialogSubmit>
90
+ </CommandFormDialogFooter>
91
+ </CommandFormDialogContent>
92
+ </Form>
93
+ </CommandFormDialog>
94
+ );
95
+ }
96
+
97
+ export const EnqueueQueueTaskCommandTrigger = CommandFormDialogTrigger;
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
4
+ import type { CommandFor } from "@tulip-systems/commands";
5
+ import type { TableColumnDef } from "@tulip-systems/data-tables";
6
+ import {
7
+ createTableConfig,
8
+ TableConfigProvider,
9
+ useLocalStrategy,
10
+ } from "@tulip-systems/data-tables/client";
11
+ import type { PropsWithChildren } from "react";
12
+ import { orpc } from "@/server/router/client";
13
+ import type { QueueExecution } from "../_lib/validators";
14
+
15
+ type QueueExecutionsTableProviderProps = PropsWithChildren<{
16
+ columns: TableColumnDef<QueueExecution>[];
17
+ commands?: CommandFor<QueueExecution[]>[];
18
+ }>;
19
+
20
+ export function QueueExecutionsTableProvider({
21
+ columns,
22
+ commands,
23
+ children,
24
+ }: QueueExecutionsTableProviderProps) {
25
+ const response = useSuspenseQuery(orpc.queue.listExecutions.queryOptions());
26
+ const strategy = useLocalStrategy({ total: response.data.length });
27
+
28
+ const config = createTableConfig<QueueExecution>({
29
+ queryData: response.data,
30
+ getRowId: (row) => row.id,
31
+ columns,
32
+ strategy,
33
+ commands,
34
+ });
35
+
36
+ return <TableConfigProvider config={config}>{children}</TableConfigProvider>;
37
+ }
@@ -0,0 +1,16 @@
1
+ "use client";
2
+
3
+ import { TableToolbar, TableToolbarList } from "@tulip-systems/data-tables";
4
+ import type { PropsWithChildren } from "react";
5
+
6
+ export function QueueExecutionsTableToolbar({ children }: PropsWithChildren) {
7
+ return (
8
+ <TableToolbar>
9
+ <TableToolbarList>
10
+ <h1 className="font-semibold text-lg tracking-tight">Queue executions</h1>
11
+ </TableToolbarList>
12
+
13
+ <TableToolbarList>{children}</TableToolbarList>
14
+ </TableToolbar>
15
+ );
16
+ }
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ import { formatDateTime } from "@tulip-systems/core";
4
+ import { TableCell, type TableColumnDef, TableColumnHeader } from "@tulip-systems/data-tables";
5
+ import { createTableSelectCell } from "@tulip-systems/data-tables/client";
6
+ import type { QueueExecution } from "../_lib/validators";
7
+
8
+ export const queueExecutionColumns: TableColumnDef<QueueExecution>[] = [
9
+ createTableSelectCell(),
10
+ {
11
+ accessorKey: "message",
12
+ header: ({ column }) => <TableColumnHeader column={column} title="Message" />,
13
+ cell: ({ row }) => (
14
+ <TableCell className="max-w-[360px] truncate">{row.original.message}</TableCell>
15
+ ),
16
+ enableSorting: false,
17
+ },
18
+ {
19
+ accessorKey: "task",
20
+ header: ({ column }) => <TableColumnHeader column={column} title="Task" />,
21
+ cell: ({ row }) => <TableCell>{row.original.task}</TableCell>,
22
+ enableSorting: false,
23
+ },
24
+ {
25
+ accessorKey: "jobId",
26
+ header: ({ column }) => <TableColumnHeader column={column} title="Job" />,
27
+ cell: ({ row }) => (
28
+ <TableCell className="max-w-[280px] truncate">{row.original.jobId}</TableCell>
29
+ ),
30
+ enableSorting: false,
31
+ },
32
+ {
33
+ accessorKey: "enqueuedAt",
34
+ header: ({ column }) => <TableColumnHeader column={column} title="Enqueued" />,
35
+ cell: ({ row }) => <TableCell>{formatDateTime(row.original.enqueuedAt)}</TableCell>,
36
+ enableSorting: false,
37
+ },
38
+ {
39
+ accessorKey: "executedAt",
40
+ header: ({ column }) => <TableColumnHeader column={column} title="Executed" />,
41
+ cell: ({ row }) => <TableCell>{formatDateTime(row.original.executedAt)}</TableCell>,
42
+ enableSorting: false,
43
+ },
44
+ ];
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { commandBuilder } from "@tulip-systems/commands";
4
+ import { CommandLabel, DeleteCommand } from "@tulip-systems/commands/client";
5
+ import { toast } from "@tulip-systems/ui/sonner";
6
+ import { ListPlusIcon } from "lucide-react";
7
+ import { orpc } from "@/server/router/client";
8
+ import {
9
+ EnqueueQueueTaskCommand,
10
+ EnqueueQueueTaskCommandTrigger,
11
+ } from "../_components/create-command";
12
+ import type { QueueExecution } from "../_lib/validators";
13
+
14
+ export const queueEnqueueCommand = commandBuilder.render(() => (
15
+ <EnqueueQueueTaskCommand
16
+ variables={(values) => values}
17
+ mutation={orpc.queue.enqueueTask.mutationOptions({
18
+ meta: { invalidates: [orpc.queue.listExecutions.key()] },
19
+ onSuccess: () => toast.success("Task enqueued"),
20
+ onError: () => toast.error("Could not enqueue task"),
21
+ })}
22
+ >
23
+ <EnqueueQueueTaskCommandTrigger label="Enqueue task" hotkey="mod+i">
24
+ <ListPlusIcon className="size-4" />
25
+ <CommandLabel />
26
+ </EnqueueQueueTaskCommandTrigger>
27
+ </EnqueueQueueTaskCommand>
28
+ ));
29
+
30
+ export const queueDeleteCommand = commandBuilder.$type<QueueExecution[]>().render(({ data }) => (
31
+ <DeleteCommand
32
+ variables={{ ids: data.map((item) => item.id) }}
33
+ mutation={orpc.queue.deleteExecutions.mutationOptions({
34
+ meta: { invalidates: [orpc.queue.listExecutions.key()] },
35
+ onSuccess: () => toast.success("Queue executions deleted"),
36
+ onError: () => toast.error("Could not delete queue executions"),
37
+ })}
38
+ />
39
+ ));
@@ -0,0 +1,27 @@
1
+ import "server-cli-only";
2
+
3
+ import { defineTask } from "@tulip-systems/queue";
4
+ import { z } from "zod";
5
+ import { db } from "@/server/db/init";
6
+ import { queueExecutions } from "./schema";
7
+
8
+ export const queueLogTask = defineTask("queue.log", {
9
+ schema: z.object({
10
+ message: z.string().min(1),
11
+ requestedAt: z.string().min(1),
12
+ }),
13
+ });
14
+
15
+ export const queueLogTaskHandler = queueLogTask.handle(
16
+ "record-queue-execution",
17
+ async ({ payload, job }) => {
18
+ await db.insert(queueExecutions).values({
19
+ jobId: job.id,
20
+ task: job.task,
21
+ message: payload.message,
22
+ enqueuedAt: new Date(job.enqueuedAt),
23
+ requestedAt: new Date(payload.requestedAt),
24
+ executedAt: new Date(),
25
+ });
26
+ },
27
+ );
@@ -0,0 +1,33 @@
1
+ import "server-cli-only";
2
+
3
+ import { desc, inArray } from "drizzle-orm";
4
+ import { queue } from "@/server/queue/init";
5
+ import { protectedProcedure } from "@/server/router/procedures";
6
+ import { queueLogTask } from "./queue";
7
+ import { queueExecutions } from "./schema";
8
+ import { deleteQueueExecutionsSchema, enqueueQueueTaskSchema } from "./validators";
9
+
10
+ export const queueRouter = {
11
+ listExecutions: protectedProcedure.handler(async ({ context }) =>
12
+ context.db.query.queueExecutions.findMany({
13
+ orderBy: [desc(queueExecutions.executedAt)],
14
+ limit: 100,
15
+ }),
16
+ ),
17
+ enqueueTask: protectedProcedure.input(enqueueQueueTaskSchema).handler(async ({ input }) => {
18
+ const result = await queue.enqueue(queueLogTask, {
19
+ message: input.message,
20
+ requestedAt: new Date().toISOString(),
21
+ });
22
+
23
+ return {
24
+ adapter: queue.adapter.key,
25
+ enqueued: result,
26
+ };
27
+ }),
28
+ deleteExecutions: protectedProcedure
29
+ .input(deleteQueueExecutionsSchema)
30
+ .handler(async ({ context, input }) => {
31
+ await context.db.delete(queueExecutions).where(inArray(queueExecutions.id, input.ids));
32
+ }),
33
+ };
@@ -0,0 +1,12 @@
1
+ import { pgTable } from "drizzle-orm/pg-core";
2
+ import { baseColumns } from "@/server/db/helpers";
3
+
4
+ export const queueExecutions = pgTable("queue_executions", (t) => ({
5
+ ...baseColumns,
6
+ jobId: t.varchar().notNull(),
7
+ task: t.varchar().notNull(),
8
+ message: t.text().notNull(),
9
+ enqueuedAt: t.timestamp().notNull(),
10
+ requestedAt: t.timestamp().notNull(),
11
+ executedAt: t.timestamp().notNull(),
12
+ }));
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ import type { queueExecutions } from "./schema";
3
+
4
+ export type QueueExecution = typeof queueExecutions.$inferSelect;
5
+
6
+ export const enqueueQueueTaskSchema = z.object({
7
+ message: z.string().trim().min(1),
8
+ });
9
+
10
+ export const deleteQueueExecutionsSchema = z.object({
11
+ ids: z.array(z.string()),
12
+ });
13
+
14
+ export type EnqueueQueueTaskInput = z.input<typeof enqueueQueueTaskSchema>;
15
+ export type EnqueueQueueTaskSchema = z.infer<typeof enqueueQueueTaskSchema>;
@@ -0,0 +1,24 @@
1
+ import { Header, Topbar, TopbarTitle, TopbarTools } from "@tulip-systems/app/components/client";
2
+ import { InlineCommandMenu, InlineCommandMenuLoading } from "@tulip-systems/commands/client";
3
+ import { type PropsWithChildren, Suspense } from "react";
4
+ import { quotationCreateCommand } from "../_config/commands";
5
+
6
+ export default function QuotationsLayout({ children }: PropsWithChildren) {
7
+ return (
8
+ <>
9
+ <Header>
10
+ <Topbar>
11
+ <TopbarTitle breadcrumbs={[{ label: "Offertes" }]} />
12
+
13
+ <TopbarTools>
14
+ <Suspense fallback={<InlineCommandMenuLoading />}>
15
+ <InlineCommandMenu data={{}} commands={[quotationCreateCommand]} />
16
+ </Suspense>
17
+ </TopbarTools>
18
+ </Topbar>
19
+ </Header>
20
+
21
+ {children}
22
+ </>
23
+ );
24
+ }
@@ -0,0 +1,23 @@
1
+ import { Allowed } from "@tulip-systems/auth/permissions/client";
2
+ import { TableLayout } from "@tulip-systems/data-tables";
3
+ import { DataTable } from "@tulip-systems/data-tables/client";
4
+ import { QuotationPaginationTableProvider } from "../_components/tables";
5
+ import { QuotationTableToolbar } from "../_components/toolbars";
6
+ import { quotationColumns } from "../_config/columns-data";
7
+ import { quotationDeleteCommand } from "../_config/commands";
8
+
9
+ export default function QuotationsPage() {
10
+ return (
11
+ <Allowed permission={{ quotation: ["view"] }}>
12
+ <TableLayout className="p-content">
13
+ <QuotationPaginationTableProvider
14
+ columns={quotationColumns}
15
+ commands={[quotationDeleteCommand]}
16
+ >
17
+ <QuotationTableToolbar />
18
+ <DataTable />
19
+ </QuotationPaginationTableProvider>
20
+ </TableLayout>
21
+ </Allowed>
22
+ );
23
+ }
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
4
+ import { Header, Topbar, TopbarTitle, TopbarTools } from "@tulip-systems/app/components/client";
5
+ import { InlineCommandMenu, InlineCommandMenuLoading } from "@tulip-systems/commands/client";
6
+ import { notFound } from "next/navigation";
7
+ import { Suspense, use } from "react";
8
+ import { orpc } from "@/server/router/client";
9
+ import { quotationDeleteCommand, quotationPreviewCommand } from "../../_config/commands";
10
+
11
+ export default function QuotationsLayout(props: LayoutProps<"/admin/quotations/[id]">) {
12
+ const params = use(props.params);
13
+ const id = params.id?.toString();
14
+
15
+ const { data } = useSuspenseQuery(orpc.quotations.getById.queryOptions({ input: { id } }));
16
+ if (!data) notFound();
17
+
18
+ return (
19
+ <>
20
+ <Header>
21
+ <Topbar>
22
+ <TopbarTitle
23
+ breadcrumbs={[{ label: "Offertes", href: "/admin/quotations" }, { label: data.title }]}
24
+ />
25
+
26
+ <TopbarTools>
27
+ <Suspense fallback={<InlineCommandMenuLoading />}>
28
+ <InlineCommandMenu
29
+ data={data}
30
+ commands={[quotationPreviewCommand, quotationDeleteCommand]}
31
+ />
32
+ </Suspense>
33
+ </TopbarTools>
34
+ </Topbar>
35
+ </Header>
36
+
37
+ {props.children}
38
+ </>
39
+ );
40
+ }