@cavulsqa/create 2.9.3 → 2.10.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 (199) hide show
  1. package/README.md +12 -1
  2. package/bin/create.mjs +18 -25
  3. package/lib/templates.mjs +56 -0
  4. package/package.json +13 -5
  5. package/templates/f7-app/auto-imports.d.ts +9 -0
  6. package/templates/f7-app/package.json +6 -3
  7. package/templates/f7-app/src/env.d.ts +1 -0
  8. package/templates/f7-app/src/main.ts +3 -0
  9. package/templates/f7-app/src/plugins/bootstrapError.ts +2 -0
  10. package/templates/f7-app/src/plugins/recorder.plugin.ts +155 -0
  11. package/templates/f7-app/src/shared/recorder/capacitorSink.ts +124 -0
  12. package/templates/f7-app/vite.config.ts +1 -0
  13. package/templates/m3e-app/.claude/rules/data-fetching.md +68 -0
  14. package/templates/m3e-app/.claude/rules/database.md +106 -0
  15. package/templates/m3e-app/.claude/rules/m3e-ui.md +48 -0
  16. package/templates/m3e-app/.claude/rules/modules.md +43 -0
  17. package/templates/m3e-app/.claude/rules/native.md +60 -0
  18. package/templates/m3e-app/.claude/skills/m3-expressive/SKILL.md +68 -0
  19. package/templates/m3e-app/.claude/skills/m3-expressive/color.md +44 -0
  20. package/templates/m3e-app/.claude/skills/m3-expressive/components.md +325 -0
  21. package/templates/m3e-app/.claude/skills/m3-expressive/layout.md +42 -0
  22. package/templates/m3e-app/.claude/skills/m3-expressive/motion.md +51 -0
  23. package/templates/m3e-app/.claude/skills/m3-expressive/shapes-type.md +48 -0
  24. package/templates/m3e-app/.claude/skills/m3-expressive/sources.md +53 -0
  25. package/templates/m3e-app/.claude/skills/module-architecture/SKILL.md +65 -0
  26. package/templates/m3e-app/.claude/skills/module-architecture/file-templates.md +178 -0
  27. package/templates/m3e-app/.claude/skills/reactive-data/SKILL.md +90 -0
  28. package/templates/m3e-app/.claude/skills/reactive-data/testing.md +55 -0
  29. package/templates/m3e-app/.env.example +32 -0
  30. package/templates/m3e-app/CLAUDE.md +93 -0
  31. package/templates/m3e-app/auto-imports.d.ts +851 -0
  32. package/templates/m3e-app/capacitor.config.ts +44 -0
  33. package/templates/m3e-app/components.d.ts +252 -0
  34. package/templates/m3e-app/index.html +16 -0
  35. package/templates/m3e-app/package.json +107 -0
  36. package/templates/m3e-app/src/App.vue +129 -0
  37. package/templates/m3e-app/src/app/pragmas.config.ts +35 -0
  38. package/templates/m3e-app/src/app/scroll.config.ts +20 -0
  39. package/templates/m3e-app/src/app/storage.config.ts +65 -0
  40. package/templates/m3e-app/src/app/tabs.ts +33 -0
  41. package/templates/m3e-app/src/app/theme.config.ts +51 -0
  42. package/templates/m3e-app/src/assets/css/app.css +18 -0
  43. package/templates/m3e-app/src/assets/css/base.css +53 -0
  44. package/templates/m3e-app/src/assets/css/layout/container-transform.css +113 -0
  45. package/templates/m3e-app/src/assets/css/layout/shell.css +69 -0
  46. package/templates/m3e-app/src/assets/css/layout/transitions.css +121 -0
  47. package/templates/m3e-app/src/assets/css/m3e.css +6 -0
  48. package/templates/m3e-app/src/assets/css/theme/framework7.css +18 -0
  49. package/templates/m3e-app/src/assets/css/theme/tailwind.css +365 -0
  50. package/templates/m3e-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
  51. package/templates/m3e-app/src/domains/benchmark/benchmark.suite.ts +662 -0
  52. package/templates/m3e-app/src/domains/sales/sales.repository.ts +458 -0
  53. package/templates/m3e-app/src/env.d.ts +40 -0
  54. package/templates/m3e-app/src/locales/ar.json +1260 -0
  55. package/templates/m3e-app/src/locales/en.json +1260 -0
  56. package/templates/m3e-app/src/locales/fr.json +1260 -0
  57. package/templates/m3e-app/src/main.ts +41 -0
  58. package/templates/m3e-app/src/modules/demo/components/DemoBenchmark.vue +119 -0
  59. package/templates/m3e-app/src/modules/demo/components/DemoBusLog.vue +63 -0
  60. package/templates/m3e-app/src/modules/demo/components/DemoCreateOrderSheet.vue +203 -0
  61. package/templates/m3e-app/src/modules/demo/components/DemoMetricsSheet.vue +75 -0
  62. package/templates/m3e-app/src/modules/demo/components/DemoOrderList.vue +75 -0
  63. package/templates/m3e-app/src/modules/demo/components/DemoPipelineBenchmark.vue +65 -0
  64. package/templates/m3e-app/src/modules/demo/components/DemoStatCards.vue +71 -0
  65. package/templates/m3e-app/src/modules/demo/composables/useBenchmark.ts +139 -0
  66. package/templates/m3e-app/src/modules/demo/composables/useOrderStatus.ts +64 -0
  67. package/templates/m3e-app/src/modules/demo/composables/useReactiveDemo.ts +171 -0
  68. package/templates/m3e-app/src/modules/demo/router/routes/demo.routes.ts +22 -0
  69. package/templates/m3e-app/src/modules/demo/views/DemoView.vue +210 -0
  70. package/templates/m3e-app/src/modules/demo/views/OrderDetailView.vue +136 -0
  71. package/templates/m3e-app/src/modules/demo/views/OrderSearchView.vue +74 -0
  72. package/templates/m3e-app/src/modules/gallery/components/GalleryBlock.vue +16 -0
  73. package/templates/m3e-app/src/modules/gallery/components/GalleryCarouselTile.vue +71 -0
  74. package/templates/m3e-app/src/modules/gallery/components/GalleryCustomerForm.vue +76 -0
  75. package/templates/m3e-app/src/modules/gallery/components/GalleryProofOfDelivery.vue +69 -0
  76. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaDay.vue +106 -0
  77. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaMonth.vue +40 -0
  78. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaVisitList.vue +45 -0
  79. package/templates/m3e-app/src/modules/gallery/components/chat/ChatAttachSheet.vue +106 -0
  80. package/templates/m3e-app/src/modules/gallery/components/chat/ChatContactPicker.vue +85 -0
  81. package/templates/m3e-app/src/modules/gallery/components/chat/ChatInviteComposer.vue +131 -0
  82. package/templates/m3e-app/src/modules/gallery/components/chat/ChatPollComposer.vue +148 -0
  83. package/templates/m3e-app/src/modules/gallery/components/chat/ChatRecentPhotos.vue +62 -0
  84. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsAccount.vue +79 -0
  85. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsTags.vue +53 -0
  86. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsVerification.vue +73 -0
  87. package/templates/m3e-app/src/modules/gallery/components/inputs/PasswordStrength.vue +37 -0
  88. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryButtons.vue +128 -0
  89. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCarousels.vue +236 -0
  90. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCharts.vue +84 -0
  91. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryFabs.vue +70 -0
  92. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryInputs.vue +219 -0
  93. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryNavigation.vue +141 -0
  94. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryOverlays.vue +368 -0
  95. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryPickers.vue +175 -0
  96. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryProgress.vue +118 -0
  97. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryScale.vue +103 -0
  98. package/templates/m3e-app/src/modules/gallery/components/sections/GallerySelection.vue +219 -0
  99. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryShapes.vue +48 -0
  100. package/templates/m3e-app/src/modules/gallery/components/sections/GallerySurfaces.vue +213 -0
  101. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryTables.vue +185 -0
  102. package/templates/m3e-app/src/modules/gallery/components/surfaces/SurfacesContainerTransform.vue +46 -0
  103. package/templates/m3e-app/src/modules/gallery/composables/chatTeam.ts +18 -0
  104. package/templates/m3e-app/src/modules/gallery/composables/composeKind.ts +2 -0
  105. package/templates/m3e-app/src/modules/gallery/composables/currentPosition.ts +35 -0
  106. package/templates/m3e-app/src/modules/gallery/composables/demoOrders.ts +29 -0
  107. package/templates/m3e-app/src/modules/gallery/composables/featuredStories.ts +73 -0
  108. package/templates/m3e-app/src/modules/gallery/composables/fieldFormats.ts +36 -0
  109. package/templates/m3e-app/src/modules/gallery/composables/openExternal.ts +11 -0
  110. package/templates/m3e-app/src/modules/gallery/composables/routePlan.ts +90 -0
  111. package/templates/m3e-app/src/modules/gallery/composables/useAgenda.ts +61 -0
  112. package/templates/m3e-app/src/modules/gallery/composables/useChatCustomers.ts +31 -0
  113. package/templates/m3e-app/src/modules/gallery/composables/useChatDemo.ts +299 -0
  114. package/templates/m3e-app/src/modules/gallery/composables/useGallerySections.ts +230 -0
  115. package/templates/m3e-app/src/modules/gallery/composables/useLocalAttachments.ts +71 -0
  116. package/templates/m3e-app/src/modules/gallery/composables/useLocationShare.ts +38 -0
  117. package/templates/m3e-app/src/modules/gallery/composables/usePhotoScenes.ts +82 -0
  118. package/templates/m3e-app/src/modules/gallery/composables/useTreeDemo.ts +67 -0
  119. package/templates/m3e-app/src/modules/gallery/composables/wilayas.ts +67 -0
  120. package/templates/m3e-app/src/modules/gallery/router/routes/gallery.routes.ts +52 -0
  121. package/templates/m3e-app/src/modules/gallery/views/GalleryAgendaView.vue +117 -0
  122. package/templates/m3e-app/src/modules/gallery/views/GalleryChatView.vue +292 -0
  123. package/templates/m3e-app/src/modules/gallery/views/GalleryContactsView.vue +77 -0
  124. package/templates/m3e-app/src/modules/gallery/views/GalleryFeaturedView.vue +60 -0
  125. package/templates/m3e-app/src/modules/gallery/views/GalleryLoginView.vue +135 -0
  126. package/templates/m3e-app/src/modules/gallery/views/GalleryOnboardingView.vue +116 -0
  127. package/templates/m3e-app/src/modules/gallery/views/GallerySectionView.vue +27 -0
  128. package/templates/m3e-app/src/modules/gallery/views/GalleryTabsView.vue +135 -0
  129. package/templates/m3e-app/src/modules/gallery/views/GalleryView.vue +50 -0
  130. package/templates/m3e-app/src/modules/home/components/HomeHero.vue +57 -0
  131. package/templates/m3e-app/src/modules/home/composables/useHomeFeatures.ts +128 -0
  132. package/templates/m3e-app/src/modules/home/composables/useOpenFeature.ts +22 -0
  133. package/templates/m3e-app/src/modules/home/router/routes/home.routes.ts +21 -0
  134. package/templates/m3e-app/src/modules/home/views/FeatureDetailView.vue +66 -0
  135. package/templates/m3e-app/src/modules/home/views/HomeView.vue +47 -0
  136. package/templates/m3e-app/src/modules/settings/components/RolePalette.vue +38 -0
  137. package/templates/m3e-app/src/modules/settings/components/SeedSwatches.vue +41 -0
  138. package/templates/m3e-app/src/modules/settings/components/SettingsChoice.vue +43 -0
  139. package/templates/m3e-app/src/modules/settings/components/StudioPreview.vue +52 -0
  140. package/templates/m3e-app/src/modules/settings/router/routes/settings.routes.ts +17 -0
  141. package/templates/m3e-app/src/modules/settings/types.ts +7 -0
  142. package/templates/m3e-app/src/modules/settings/views/ColorStudioView.vue +166 -0
  143. package/templates/m3e-app/src/modules/settings/views/SettingsView.vue +157 -0
  144. package/templates/m3e-app/src/plugins/bootstrapError.ts +62 -0
  145. package/templates/m3e-app/src/plugins/capacitor/index.ts +14 -0
  146. package/templates/m3e-app/src/plugins/capacitor/useAndroidBackButton.ts +36 -0
  147. package/templates/m3e-app/src/plugins/capacitor/useKeyboard.ts +48 -0
  148. package/templates/m3e-app/src/plugins/capacitor/useSplashScreen.ts +43 -0
  149. package/templates/m3e-app/src/plugins/capacitor/useStatusBar.ts +16 -0
  150. package/templates/m3e-app/src/plugins/framework7.plugin.ts +36 -0
  151. package/templates/m3e-app/src/plugins/i18n.plugin.ts +87 -0
  152. package/templates/m3e-app/src/plugins/m3e.plugin.ts +22 -0
  153. package/templates/m3e-app/src/plugins/seed.plugin.ts +7 -0
  154. package/templates/m3e-app/src/plugins/sqlite.plugin.ts +32 -0
  155. package/templates/m3e-app/src/router/global/global.routes.ts +12 -0
  156. package/templates/m3e-app/src/router/index.ts +20 -0
  157. package/templates/m3e-app/src/shared/components/error/404.vue +13 -0
  158. package/templates/m3e-app/src/shared/components/layout/EmptyState.vue +30 -0
  159. package/templates/m3e-app/src/shared/components/layout/SectionHeader.vue +10 -0
  160. package/templates/m3e-app/src/shared/components/page/AppPage.vue +104 -0
  161. package/templates/m3e-app/src/shared/composables/navigation/useActiveTab.ts +39 -0
  162. package/templates/m3e-app/src/shared/composables/navigation/useContainerTransform.ts +117 -0
  163. package/templates/m3e-app/src/shared/composables/navigation/useNavigationGuard.ts +48 -0
  164. package/templates/m3e-app/src/shared/composables/navigation/useNavigationVisibility.ts +64 -0
  165. package/templates/m3e-app/src/shared/composables/navigation/useViewRouter.ts +25 -0
  166. package/templates/m3e-app/src/shared/composables/navigation/useWindowClass.ts +22 -0
  167. package/templates/m3e-app/src/shared/composables/theme/useThemeSettings.ts +155 -0
  168. package/templates/m3e-app/src/shared/database/candidates/capacitorSqlite.ts +36 -0
  169. package/templates/m3e-app/src/shared/database/candidates/index.ts +4 -0
  170. package/templates/m3e-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
  171. package/templates/m3e-app/src/shared/database/candidates/types.ts +56 -0
  172. package/templates/m3e-app/src/shared/database/candidates/waSqlite.ts +56 -0
  173. package/templates/m3e-app/src/shared/database/database.ts +219 -0
  174. package/templates/m3e-app/src/shared/database/index.ts +3 -0
  175. package/templates/m3e-app/src/shared/database/migrations.ts +223 -0
  176. package/templates/m3e-app/src/shared/database/opfs.worker.ts +5 -0
  177. package/templates/m3e-app/src/shared/database/queries.ts +26 -0
  178. package/templates/m3e-app/src/shared/database/schema.ts +153 -0
  179. package/templates/m3e-app/src/shared/database/storage.ts +64 -0
  180. package/templates/m3e-app/src/shared/database/wa.worker.ts +5 -0
  181. package/templates/m3e-app/src/shared/utils/lazyRoute.ts +41 -0
  182. package/templates/m3e-app/src/shared/utils/resolvers/resolvers.ts +42 -0
  183. package/templates/m3e-app/src/shared/utils/textDirection.ts +22 -0
  184. package/templates/m3e-app/src/shared/utils/theme/themeSettings.ts +59 -0
  185. package/templates/m3e-app/src/shared/utils/tone.ts +8 -0
  186. package/templates/m3e-app/tests/benchmark.suite.test.ts +104 -0
  187. package/templates/m3e-app/tests/containerTransform.test.ts +58 -0
  188. package/templates/m3e-app/tests/fieldFormats.test.ts +25 -0
  189. package/templates/m3e-app/tests/lazyRoute.test.ts +30 -0
  190. package/templates/m3e-app/tests/locales.test.ts +73 -0
  191. package/templates/m3e-app/tests/migrations.test.ts +74 -0
  192. package/templates/m3e-app/tests/navigationGuard.test.ts +82 -0
  193. package/templates/m3e-app/tests/openDatabase.test.ts +54 -0
  194. package/templates/m3e-app/tests/sales.repository.test.ts +329 -0
  195. package/templates/m3e-app/tests/storage.test.ts +117 -0
  196. package/templates/m3e-app/tests/textDirection.test.ts +11 -0
  197. package/templates/m3e-app/tooling/linkedPackages.ts +112 -0
  198. package/templates/m3e-app/tsconfig.json +21 -0
  199. package/templates/m3e-app/vite.config.ts +144 -0
@@ -0,0 +1,458 @@
1
+ import { sql, type Kysely } from "kysely";
2
+ import { nowISO } from "@cavulsqa/mobile-db";
3
+ import type { Database } from "@/shared/database/schema";
4
+
5
+ export interface DashboardStats {
6
+ customers: number;
7
+ products: number;
8
+ orders: number;
9
+ draft: number;
10
+ confirmed: number;
11
+ delivered: number;
12
+ revenueCents: number;
13
+ /** Only confirmed and delivered orders count as money you will actually see. */
14
+ committedCents: number;
15
+ }
16
+
17
+ export interface OrderRow {
18
+ id: number;
19
+ reference: string;
20
+ status: string;
21
+ createdAt: string;
22
+ customerName: string;
23
+ city: string;
24
+ lines: number;
25
+ totalCents: number;
26
+ }
27
+
28
+ const CITIES = ["Algiers", "Oran", "Constantine", "Annaba", "Blida", "Sétif"];
29
+ const TAGS = ["wholesale", "retail", "priority", "new"];
30
+ const PRODUCTS: Array<[string, number]> = [
31
+ ["Espresso 1kg", 189000],
32
+ ["Green tea 500g", 74000],
33
+ ["Ceramic mug", 32000],
34
+ ["Filter papers", 9500],
35
+ ["Cold brew bottle", 45000],
36
+ ];
37
+
38
+ /** Reads go straight to the database; only writes need the reactive wrapper. */
39
+ export function loadDashboardStats(db: Kysely<Database>): Promise<DashboardStats> {
40
+ return db
41
+ .selectNoFrom((eb) => [
42
+ eb
43
+ .selectFrom("customer")
44
+ .select((e) => e.fn.countAll<number>().as("n"))
45
+ .as("customers"),
46
+ eb
47
+ .selectFrom("product")
48
+ .select((e) => e.fn.countAll<number>().as("n"))
49
+ .as("products"),
50
+ eb
51
+ .selectFrom("sales_order")
52
+ .select((e) => e.fn.countAll<number>().as("n"))
53
+ .as("orders"),
54
+ eb
55
+ .selectFrom("order_line")
56
+ .select(sql<number>`coalesce(sum(quantity * unit_price_cents), 0)`.as("n"))
57
+ .as("revenue"),
58
+ eb
59
+ .selectFrom("sales_order")
60
+ .select((e) => e.fn.countAll<number>().as("n"))
61
+ .where("status", "=", "draft")
62
+ .as("draft"),
63
+ eb
64
+ .selectFrom("sales_order")
65
+ .select((e) => e.fn.countAll<number>().as("n"))
66
+ .where("status", "=", "confirmed")
67
+ .as("confirmed"),
68
+ eb
69
+ .selectFrom("sales_order")
70
+ .select((e) => e.fn.countAll<number>().as("n"))
71
+ .where("status", "=", "delivered")
72
+ .as("delivered"),
73
+ eb
74
+ .selectFrom("order_line")
75
+ .innerJoin("sales_order", "sales_order.id", "order_line.order_id")
76
+ .select(
77
+ sql<number>`coalesce(sum(order_line.quantity * order_line.unit_price_cents), 0)`.as("n"),
78
+ )
79
+ .where("sales_order.status", "in", ["confirmed", "delivered"])
80
+ .as("committed"),
81
+ ])
82
+ .executeTakeFirstOrThrow()
83
+ .then((row) => ({
84
+ customers: Number(row.customers ?? 0),
85
+ products: Number(row.products ?? 0),
86
+ orders: Number(row.orders ?? 0),
87
+ draft: Number(row.draft ?? 0),
88
+ confirmed: Number(row.confirmed ?? 0),
89
+ delivered: Number(row.delivered ?? 0),
90
+ revenueCents: Number(row.revenue ?? 0),
91
+ committedCents: Number(row.committed ?? 0),
92
+ }));
93
+ }
94
+
95
+ /**
96
+ * The join is the point: this reads four tables, so a write to any of them has to refresh it. The
97
+ * search term filters on the customer and the reference together.
98
+ */
99
+ export function searchOrders(db: Kysely<Database>, term: string): Promise<OrderRow[]> {
100
+ const like = `%${term.trim()}%`;
101
+
102
+ let query = db
103
+ .selectFrom("sales_order")
104
+ .innerJoin("customer", "customer.id", "sales_order.customer_id")
105
+ .leftJoin("order_line", "order_line.order_id", "sales_order.id")
106
+ .select(({ fn }) => [
107
+ "sales_order.id as id",
108
+ "sales_order.reference as reference",
109
+ "sales_order.status as status",
110
+ "sales_order.created_at as createdAt",
111
+ "customer.name as customerName",
112
+ "customer.city as city",
113
+ fn.count<number>("order_line.id").as("lines"),
114
+ sql<number>`coalesce(sum(order_line.quantity * order_line.unit_price_cents), 0)`.as(
115
+ "totalCents",
116
+ ),
117
+ ])
118
+ .groupBy(["sales_order.id", "customer.name", "customer.city"])
119
+ .orderBy("sales_order.id", "desc")
120
+ .limit(40);
121
+
122
+ if (term.trim()) {
123
+ query = query.where((eb) =>
124
+ eb.or([eb("customer.name", "like", like), eb("sales_order.reference", "like", like)]),
125
+ );
126
+ }
127
+
128
+ return query.execute().then((rows) =>
129
+ rows.map((row) => ({
130
+ ...row,
131
+ lines: Number(row.lines),
132
+ totalCents: Number(row.totalCents),
133
+ })),
134
+ );
135
+ }
136
+
137
+ export function listCustomers(
138
+ db: Kysely<Database>,
139
+ ): Promise<Array<{ id: number; name: string; city: string }>> {
140
+ return db.selectFrom("customer").select(["id", "name", "city"]).orderBy("name").execute();
141
+ }
142
+
143
+ const REFERENCE_CUSTOMERS = 6;
144
+
145
+ /**
146
+ * The catalogue and a few customers - what the app needs before anyone can write an order at all.
147
+ * Run at startup, because a first launch that makes you press "seed" before the New order sheet has
148
+ * anything to pick is not a first launch anyone should have.
149
+ *
150
+ * Idempotent by table rather than by row: these are fixtures, so if they are there this has run.
151
+ */
152
+ export async function ensureReferenceData(db: Kysely<Database>): Promise<void> {
153
+ const at = nowISO();
154
+
155
+ const existing = await db.selectFrom("product").select("name").execute();
156
+ const known = new Set(existing.map((product) => product.name));
157
+
158
+ for (const [name, price] of PRODUCTS) {
159
+ if (known.has(name)) continue;
160
+ await db.insertInto("product").values({ created_at: at, name, price_cents: price }).execute();
161
+ }
162
+
163
+ // `tag.label` is unique, and a plain insert threw "UNIQUE constraint failed" on the second run.
164
+ for (const label of TAGS) {
165
+ await db
166
+ .insertInto("tag")
167
+ .values({ label })
168
+ .onConflict((oc) => oc.column("label").doNothing())
169
+ .execute();
170
+ }
171
+
172
+ const customers = await db.selectFrom("customer").select("id").execute();
173
+ if (customers.length) return;
174
+
175
+ const tags = await db.selectFrom("tag").select("id").execute();
176
+
177
+ for (let index = 0; index < REFERENCE_CUSTOMERS; index++) {
178
+ const customerId = await insertCustomer(db, at);
179
+ const tag = tags[index % tags.length];
180
+ if (tag) {
181
+ await db
182
+ .insertInto("customer_tag")
183
+ .values({ customer_id: customerId, tag_id: tag.id })
184
+ .execute();
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The name is numbered from how many customers exist right now, so it stays unique across seeds -
191
+ * counted per insert rather than once per batch, because adding a loop index to a count that the
192
+ * loop is itself growing produces collisions.
193
+ *
194
+ * `insertId` rather than `.returning("id")` for the same reason as `saveOrder`: it is the one way
195
+ * that works on both sides of a transaction boundary.
196
+ */
197
+ async function insertCustomer(db: Kysely<Database>, at: string): Promise<number> {
198
+ const existing = (await db.selectFrom("customer").select("id").execute()).length;
199
+
200
+ const inserted = await db
201
+ .insertInto("customer")
202
+ .values({
203
+ created_at: at,
204
+ name: `Customer ${String(existing + 1)}`,
205
+ city: CITIES[existing % CITIES.length] ?? "Algiers",
206
+ })
207
+ .executeTakeFirstOrThrow();
208
+
209
+ const id = Number(inserted.insertId ?? 0);
210
+ if (!id) throw new Error("the customer was written but the database reported no id for it");
211
+ return id;
212
+ }
213
+
214
+ /**
215
+ * Demo orders on top of the reference data. Every write goes through `rdb`, so each announces the
216
+ * tables it touched.
217
+ *
218
+ * This is a button people press more than once, so it adds to the data rather than assuming an
219
+ * empty database.
220
+ */
221
+ export async function seedSampleData(db: Kysely<Database>, customers = 3): Promise<void> {
222
+ await ensureReferenceData(db);
223
+
224
+ const at = nowISO();
225
+ const products = await db.selectFrom("product").select(["id", "price_cents"]).execute();
226
+ const tags = await db.selectFrom("tag").select("id").execute();
227
+
228
+ for (let index = 0; index < customers; index++) {
229
+ const customerId = await insertCustomer(db, at);
230
+
231
+ const tag = tags[index % tags.length];
232
+ if (tag) {
233
+ await db
234
+ .insertInto("customer_tag")
235
+ .values({ customer_id: customerId, tag_id: tag.id })
236
+ .execute();
237
+ }
238
+
239
+ for (let orderIndex = 0; orderIndex < 2; orderIndex++) {
240
+ await createOrder(db, customerId, products.slice(0, 2 + ((index + orderIndex) % 3)));
241
+ }
242
+ }
243
+ }
244
+
245
+ export async function createOrder(
246
+ db: Kysely<Database>,
247
+ customerId: number,
248
+ lines: Array<{ id: number; price_cents: number }>,
249
+ ): Promise<void> {
250
+ const at = nowISO();
251
+ const reference = `SO-${String(Date.now() % 1000000).padStart(6, "0")}`;
252
+
253
+ const inserted = await db
254
+ .insertInto("sales_order")
255
+ .values({ created_at: at, customer_id: customerId, reference, status: "draft" })
256
+ .executeTakeFirstOrThrow();
257
+
258
+ const orderId = Number(inserted.insertId ?? 0);
259
+ if (!orderId) throw new Error("the order was written but the database reported no id for it");
260
+
261
+ for (const [index, line] of lines.entries()) {
262
+ await db
263
+ .insertInto("order_line")
264
+ .values({
265
+ order_id: orderId,
266
+ product_id: line.id,
267
+ quantity: index + 1,
268
+ unit_price_cents: line.price_cents,
269
+ })
270
+ .execute();
271
+ }
272
+ }
273
+
274
+ export async function advanceOrderStatus(db: Kysely<Database>, orderId: number): Promise<void> {
275
+ const next = { draft: "confirmed", confirmed: "delivered", delivered: "draft" } as const;
276
+ const current = await db
277
+ .selectFrom("sales_order")
278
+ .select("status")
279
+ .where("id", "=", orderId)
280
+ .executeTakeFirst();
281
+ if (!current) return;
282
+
283
+ await db
284
+ .updateTable("sales_order")
285
+ .set({ status: next[current.status] })
286
+ .where("id", "=", orderId)
287
+ .execute();
288
+ }
289
+
290
+ /** Order lines go with the order through the cascade, so only the parents are deleted here. */
291
+ export async function clearAll(db: Kysely<Database>): Promise<void> {
292
+ await db.deleteFrom("customer").execute();
293
+ await db.deleteFrom("sales_order").execute();
294
+ await db.deleteFrom("order_line").execute();
295
+ await db.deleteFrom("customer_tag").execute();
296
+ await db.deleteFrom("product").execute();
297
+ await db.deleteFrom("tag").execute();
298
+ }
299
+
300
+ export interface ProductRow {
301
+ id: number;
302
+ name: string;
303
+ price_cents: number;
304
+ }
305
+
306
+ export function listProducts(db: Kysely<Database>): Promise<ProductRow[]> {
307
+ return db.selectFrom("product").select(["id", "name", "price_cents"]).orderBy("name").execute();
308
+ }
309
+
310
+ /**
311
+ * Sequential rather than random, because a reference a person reads should count up. Derived from
312
+ * the highest existing number so it survives deletions without reusing a reference.
313
+ */
314
+ export async function nextOrderReference(db: Kysely<Database>): Promise<string> {
315
+ const row = await db
316
+ .selectFrom("sales_order")
317
+ .select(sql<number>`coalesce(max(cast(substr(reference, 4) as integer)), 0)`.as("highest"))
318
+ .executeTakeFirst();
319
+
320
+ return `SO-${String(Number(row?.highest ?? 0) + 1).padStart(4, "0")}`;
321
+ }
322
+
323
+ export interface DraftLine {
324
+ productId: number;
325
+ quantity: number;
326
+ unitPriceCents: number;
327
+ }
328
+
329
+ /** The whole order in one transaction: a half-written order is worse than no order. */
330
+ export async function saveOrder(
331
+ db: Kysely<Database>,
332
+ input: { customerId: number; reference: string; lines: DraftLine[] },
333
+ ): Promise<void> {
334
+ await db.transaction().execute(async (trx) => {
335
+ /**
336
+ * `insertId`, not `.returning("id")`: the SQLite plugin runs a statement issued inside an open
337
+ * transaction through `query()`, which executes it but drops its RETURNING rows - so the insert
338
+ * succeeded and kysely still threw "no result". `insertId` comes from `last_insert_rowid()`.
339
+ */
340
+ const inserted = await trx
341
+ .insertInto("sales_order")
342
+ .values({
343
+ created_at: nowISO(),
344
+ customer_id: input.customerId,
345
+ reference: input.reference,
346
+ status: "draft",
347
+ })
348
+ .executeTakeFirstOrThrow();
349
+
350
+ const orderId = Number(inserted.insertId ?? 0);
351
+ if (!orderId) throw new Error("the order was written but the database reported no id for it");
352
+
353
+ for (const line of input.lines) {
354
+ await trx
355
+ .insertInto("order_line")
356
+ .values({
357
+ order_id: orderId,
358
+ product_id: line.productId,
359
+ quantity: line.quantity,
360
+ unit_price_cents: line.unitPriceCents,
361
+ })
362
+ .execute();
363
+ }
364
+ });
365
+ }
366
+
367
+ export async function deleteOrder(db: Kysely<Database>, orderId: number): Promise<void> {
368
+ // The cascade only fires with foreign keys on, which the plugin does not guarantee per connection.
369
+ await db.deleteFrom("order_line").where("order_id", "=", orderId).execute();
370
+ await db.deleteFrom("sales_order").where("id", "=", orderId).execute();
371
+ }
372
+
373
+ export async function setOrderStatus(
374
+ db: Kysely<Database>,
375
+ orderId: number,
376
+ status: "draft" | "confirmed" | "delivered",
377
+ ): Promise<void> {
378
+ await db.updateTable("sales_order").set({ status }).where("id", "=", orderId).execute();
379
+ }
380
+
381
+ export interface OrderLineRow {
382
+ id: number;
383
+ productName: string;
384
+ quantity: number;
385
+ unitPriceCents: number;
386
+ lineTotalCents: number;
387
+ }
388
+
389
+ export interface OrderDetail {
390
+ id: number;
391
+ reference: string;
392
+ status: "draft" | "confirmed" | "delivered";
393
+ createdAt: string;
394
+ customerName: string;
395
+ city: string;
396
+ tags: string[];
397
+ lines: OrderLineRow[];
398
+ totalCents: number;
399
+ }
400
+
401
+ /**
402
+ * The detail read touches five of the six tables - order, customer, line, product and the tag
403
+ * junction - which is what makes it a fair demonstration: any write in that set refreshes it.
404
+ */
405
+ export async function loadOrderDetail(
406
+ db: Kysely<Database>,
407
+ orderId: number,
408
+ ): Promise<OrderDetail | null> {
409
+ const header = await db
410
+ .selectFrom("sales_order")
411
+ .innerJoin("customer", "customer.id", "sales_order.customer_id")
412
+ .select([
413
+ "sales_order.id as id",
414
+ "sales_order.reference as reference",
415
+ "sales_order.status as status",
416
+ "sales_order.created_at as createdAt",
417
+ "customer.id as customerId",
418
+ "customer.name as customerName",
419
+ "customer.city as city",
420
+ ])
421
+ .where("sales_order.id", "=", orderId)
422
+ .executeTakeFirst();
423
+
424
+ if (!header) return null;
425
+
426
+ const [lines, tags] = await Promise.all([
427
+ db
428
+ .selectFrom("order_line")
429
+ .innerJoin("product", "product.id", "order_line.product_id")
430
+ .select([
431
+ "order_line.id as id",
432
+ "product.name as productName",
433
+ "order_line.quantity as quantity",
434
+ "order_line.unit_price_cents as unitPriceCents",
435
+ sql<number>`order_line.quantity * order_line.unit_price_cents`.as("lineTotalCents"),
436
+ ])
437
+ .where("order_line.order_id", "=", orderId)
438
+ .execute(),
439
+ db
440
+ .selectFrom("customer_tag")
441
+ .innerJoin("tag", "tag.id", "customer_tag.tag_id")
442
+ .select("tag.label as label")
443
+ .where("customer_tag.customer_id", "=", header.customerId)
444
+ .execute(),
445
+ ]);
446
+
447
+ return {
448
+ id: header.id,
449
+ reference: header.reference,
450
+ status: header.status,
451
+ createdAt: header.createdAt,
452
+ customerName: header.customerName,
453
+ city: header.city,
454
+ tags: tags.map((row) => row.label),
455
+ lines: lines.map((line) => ({ ...line, lineTotalCents: Number(line.lineTotalCents) })),
456
+ totalCents: lines.reduce((sum, line) => sum + Number(line.lineTotalCents), 0),
457
+ };
458
+ }
@@ -0,0 +1,40 @@
1
+ /// <reference types="vite/client" />
2
+ /// <reference types="unplugin-icons/types/vue" />
3
+
4
+ /**
5
+ * For the checkers that do not parse single-file components. `vue-tsc` resolves the real SFC and
6
+ * ignores this; it exists so `vp check`'s type-aware pass can follow a `.vue` import instead of
7
+ * reporting every one as a missing module.
8
+ *
9
+ * Safe here because every `.vue` import in this template is a lazily loaded route component, whose
10
+ * props are never checked at the import site.
11
+ */
12
+ declare module "*.vue" {
13
+ import type { DefineComponent } from "vue";
14
+
15
+ const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
16
+ export default component;
17
+ }
18
+
19
+ /** Injected by vite `define`; see vite.config.ts. */
20
+ declare const __APP_NAME__: string;
21
+ declare const __APP_VERSION__: string;
22
+
23
+ /**
24
+ * The environment this app reads. Declared, so a typo in a variable name is a type error rather than
25
+ * a silent `undefined` that falls back to the default and looks like the setting was ignored.
26
+ *
27
+ * The engine is `string`, not `StorageId`, because a `.env` is unchecked text: a union here would
28
+ * claim a guarantee nothing enforces. `storage.config.ts` narrows it with `isStorageId` and reports
29
+ * a value it does not recognise.
30
+ *
31
+ * See `.env.example` for what each value means.
32
+ */
33
+ interface ImportMetaEnv {
34
+ readonly VITE_STORAGE_ENGINE?: string;
35
+ readonly VITE_PRAGMA_PROFILE?: "safe" | "fast";
36
+ }
37
+
38
+ interface ImportMeta {
39
+ readonly env: ImportMetaEnv;
40
+ }