@cavulsqa/create 2.9.3 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/bin/create.mjs +18 -25
- package/lib/templates.mjs +56 -0
- package/package.json +13 -5
- package/templates/f7-app/auto-imports.d.ts +9 -0
- package/templates/f7-app/package.json +6 -3
- package/templates/f7-app/src/env.d.ts +1 -0
- package/templates/f7-app/src/main.ts +3 -0
- package/templates/f7-app/src/plugins/bootstrapError.ts +2 -0
- package/templates/f7-app/src/plugins/recorder.plugin.ts +155 -0
- package/templates/f7-app/src/shared/recorder/capacitorSink.ts +124 -0
- package/templates/f7-app/vite.config.ts +1 -0
- package/templates/m3e-app/.claude/rules/data-fetching.md +68 -0
- package/templates/m3e-app/.claude/rules/database.md +106 -0
- package/templates/m3e-app/.claude/rules/m3e-ui.md +48 -0
- package/templates/m3e-app/.claude/rules/modules.md +43 -0
- package/templates/m3e-app/.claude/rules/native.md +60 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/SKILL.md +68 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/color.md +44 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/components.md +325 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/layout.md +42 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/motion.md +51 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/shapes-type.md +48 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/sources.md +53 -0
- package/templates/m3e-app/.claude/skills/module-architecture/SKILL.md +65 -0
- package/templates/m3e-app/.claude/skills/module-architecture/file-templates.md +178 -0
- package/templates/m3e-app/.claude/skills/reactive-data/SKILL.md +90 -0
- package/templates/m3e-app/.claude/skills/reactive-data/testing.md +55 -0
- package/templates/m3e-app/.env.example +32 -0
- package/templates/m3e-app/CLAUDE.md +93 -0
- package/templates/m3e-app/auto-imports.d.ts +851 -0
- package/templates/m3e-app/capacitor.config.ts +44 -0
- package/templates/m3e-app/components.d.ts +252 -0
- package/templates/m3e-app/index.html +16 -0
- package/templates/m3e-app/package.json +108 -0
- package/templates/m3e-app/src/App.vue +129 -0
- package/templates/m3e-app/src/app/pragmas.config.ts +35 -0
- package/templates/m3e-app/src/app/scroll.config.ts +20 -0
- package/templates/m3e-app/src/app/storage.config.ts +65 -0
- package/templates/m3e-app/src/app/tabs.ts +33 -0
- package/templates/m3e-app/src/app/theme.config.ts +51 -0
- package/templates/m3e-app/src/assets/css/app.css +18 -0
- package/templates/m3e-app/src/assets/css/base.css +53 -0
- package/templates/m3e-app/src/assets/css/layout/container-transform.css +113 -0
- package/templates/m3e-app/src/assets/css/layout/shell.css +69 -0
- package/templates/m3e-app/src/assets/css/layout/transitions.css +121 -0
- package/templates/m3e-app/src/assets/css/m3e.css +6 -0
- package/templates/m3e-app/src/assets/css/theme/framework7.css +18 -0
- package/templates/m3e-app/src/assets/css/theme/tailwind.css +365 -0
- package/templates/m3e-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
- package/templates/m3e-app/src/domains/benchmark/benchmark.suite.ts +662 -0
- package/templates/m3e-app/src/domains/sales/sales.repository.ts +458 -0
- package/templates/m3e-app/src/env.d.ts +40 -0
- package/templates/m3e-app/src/locales/ar.json +1260 -0
- package/templates/m3e-app/src/locales/en.json +1260 -0
- package/templates/m3e-app/src/locales/fr.json +1260 -0
- package/templates/m3e-app/src/main.ts +41 -0
- package/templates/m3e-app/src/modules/demo/components/DemoBenchmark.vue +119 -0
- package/templates/m3e-app/src/modules/demo/components/DemoBusLog.vue +63 -0
- package/templates/m3e-app/src/modules/demo/components/DemoCreateOrderSheet.vue +203 -0
- package/templates/m3e-app/src/modules/demo/components/DemoMetricsSheet.vue +75 -0
- package/templates/m3e-app/src/modules/demo/components/DemoOrderList.vue +75 -0
- package/templates/m3e-app/src/modules/demo/components/DemoPipelineBenchmark.vue +65 -0
- package/templates/m3e-app/src/modules/demo/components/DemoStatCards.vue +71 -0
- package/templates/m3e-app/src/modules/demo/composables/useBenchmark.ts +139 -0
- package/templates/m3e-app/src/modules/demo/composables/useOrderStatus.ts +64 -0
- package/templates/m3e-app/src/modules/demo/composables/useReactiveDemo.ts +171 -0
- package/templates/m3e-app/src/modules/demo/router/routes/demo.routes.ts +22 -0
- package/templates/m3e-app/src/modules/demo/views/DemoView.vue +210 -0
- package/templates/m3e-app/src/modules/demo/views/OrderDetailView.vue +136 -0
- package/templates/m3e-app/src/modules/demo/views/OrderSearchView.vue +74 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryBlock.vue +16 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryCarouselTile.vue +71 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryCustomerForm.vue +76 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryProofOfDelivery.vue +69 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaDay.vue +106 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaMonth.vue +40 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaVisitList.vue +45 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatAttachSheet.vue +106 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatContactPicker.vue +85 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatInviteComposer.vue +131 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatPollComposer.vue +148 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatRecentPhotos.vue +62 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsAccount.vue +79 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsTags.vue +53 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsVerification.vue +73 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/PasswordStrength.vue +37 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryButtons.vue +128 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCarousels.vue +236 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCharts.vue +84 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryFabs.vue +70 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryInputs.vue +219 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryNavigation.vue +141 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryOverlays.vue +368 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryPickers.vue +175 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryProgress.vue +118 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryScale.vue +103 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GallerySelection.vue +219 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryShapes.vue +48 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GallerySurfaces.vue +213 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryTables.vue +185 -0
- package/templates/m3e-app/src/modules/gallery/components/surfaces/SurfacesContainerTransform.vue +46 -0
- package/templates/m3e-app/src/modules/gallery/composables/chatTeam.ts +18 -0
- package/templates/m3e-app/src/modules/gallery/composables/composeKind.ts +2 -0
- package/templates/m3e-app/src/modules/gallery/composables/currentPosition.ts +35 -0
- package/templates/m3e-app/src/modules/gallery/composables/demoOrders.ts +29 -0
- package/templates/m3e-app/src/modules/gallery/composables/featuredStories.ts +73 -0
- package/templates/m3e-app/src/modules/gallery/composables/fieldFormats.ts +36 -0
- package/templates/m3e-app/src/modules/gallery/composables/openExternal.ts +11 -0
- package/templates/m3e-app/src/modules/gallery/composables/routePlan.ts +90 -0
- package/templates/m3e-app/src/modules/gallery/composables/useAgenda.ts +61 -0
- package/templates/m3e-app/src/modules/gallery/composables/useChatCustomers.ts +31 -0
- package/templates/m3e-app/src/modules/gallery/composables/useChatDemo.ts +299 -0
- package/templates/m3e-app/src/modules/gallery/composables/useGallerySections.ts +230 -0
- package/templates/m3e-app/src/modules/gallery/composables/useLocalAttachments.ts +71 -0
- package/templates/m3e-app/src/modules/gallery/composables/useLocationShare.ts +38 -0
- package/templates/m3e-app/src/modules/gallery/composables/usePhotoScenes.ts +82 -0
- package/templates/m3e-app/src/modules/gallery/composables/useTreeDemo.ts +67 -0
- package/templates/m3e-app/src/modules/gallery/composables/wilayas.ts +67 -0
- package/templates/m3e-app/src/modules/gallery/router/routes/gallery.routes.ts +52 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryAgendaView.vue +117 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryChatView.vue +292 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryContactsView.vue +77 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryFeaturedView.vue +60 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryLoginView.vue +135 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryOnboardingView.vue +116 -0
- package/templates/m3e-app/src/modules/gallery/views/GallerySectionView.vue +27 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryTabsView.vue +135 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryView.vue +50 -0
- package/templates/m3e-app/src/modules/home/components/HomeHero.vue +57 -0
- package/templates/m3e-app/src/modules/home/composables/useHomeFeatures.ts +128 -0
- package/templates/m3e-app/src/modules/home/composables/useOpenFeature.ts +22 -0
- package/templates/m3e-app/src/modules/home/router/routes/home.routes.ts +21 -0
- package/templates/m3e-app/src/modules/home/views/FeatureDetailView.vue +66 -0
- package/templates/m3e-app/src/modules/home/views/HomeView.vue +47 -0
- package/templates/m3e-app/src/modules/settings/components/RolePalette.vue +38 -0
- package/templates/m3e-app/src/modules/settings/components/SeedSwatches.vue +41 -0
- package/templates/m3e-app/src/modules/settings/components/SettingsChoice.vue +43 -0
- package/templates/m3e-app/src/modules/settings/components/StudioPreview.vue +52 -0
- package/templates/m3e-app/src/modules/settings/router/routes/settings.routes.ts +17 -0
- package/templates/m3e-app/src/modules/settings/types.ts +7 -0
- package/templates/m3e-app/src/modules/settings/views/ColorStudioView.vue +166 -0
- package/templates/m3e-app/src/modules/settings/views/SettingsView.vue +157 -0
- package/templates/m3e-app/src/plugins/bootstrapError.ts +62 -0
- package/templates/m3e-app/src/plugins/capacitor/index.ts +14 -0
- package/templates/m3e-app/src/plugins/capacitor/useAndroidBackButton.ts +36 -0
- package/templates/m3e-app/src/plugins/capacitor/useKeyboard.ts +48 -0
- package/templates/m3e-app/src/plugins/capacitor/useSplashScreen.ts +43 -0
- package/templates/m3e-app/src/plugins/capacitor/useStatusBar.ts +16 -0
- package/templates/m3e-app/src/plugins/framework7.plugin.ts +36 -0
- package/templates/m3e-app/src/plugins/i18n.plugin.ts +87 -0
- package/templates/m3e-app/src/plugins/m3e.plugin.ts +22 -0
- package/templates/m3e-app/src/plugins/seed.plugin.ts +7 -0
- package/templates/m3e-app/src/plugins/sqlite.plugin.ts +32 -0
- package/templates/m3e-app/src/router/global/global.routes.ts +12 -0
- package/templates/m3e-app/src/router/index.ts +20 -0
- package/templates/m3e-app/src/shared/components/error/404.vue +13 -0
- package/templates/m3e-app/src/shared/components/layout/EmptyState.vue +30 -0
- package/templates/m3e-app/src/shared/components/layout/SectionHeader.vue +10 -0
- package/templates/m3e-app/src/shared/components/page/AppPage.vue +104 -0
- package/templates/m3e-app/src/shared/composables/navigation/useActiveTab.ts +39 -0
- package/templates/m3e-app/src/shared/composables/navigation/useContainerTransform.ts +117 -0
- package/templates/m3e-app/src/shared/composables/navigation/useNavigationGuard.ts +48 -0
- package/templates/m3e-app/src/shared/composables/navigation/useNavigationVisibility.ts +64 -0
- package/templates/m3e-app/src/shared/composables/navigation/useViewRouter.ts +25 -0
- package/templates/m3e-app/src/shared/composables/navigation/useWindowClass.ts +22 -0
- package/templates/m3e-app/src/shared/composables/theme/useThemeSettings.ts +155 -0
- package/templates/m3e-app/src/shared/database/candidates/capacitorSqlite.ts +36 -0
- package/templates/m3e-app/src/shared/database/candidates/index.ts +4 -0
- package/templates/m3e-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
- package/templates/m3e-app/src/shared/database/candidates/types.ts +56 -0
- package/templates/m3e-app/src/shared/database/candidates/waSqlite.ts +56 -0
- package/templates/m3e-app/src/shared/database/database.ts +219 -0
- package/templates/m3e-app/src/shared/database/index.ts +3 -0
- package/templates/m3e-app/src/shared/database/migrations.ts +223 -0
- package/templates/m3e-app/src/shared/database/opfs.worker.ts +5 -0
- package/templates/m3e-app/src/shared/database/queries.ts +26 -0
- package/templates/m3e-app/src/shared/database/schema.ts +153 -0
- package/templates/m3e-app/src/shared/database/storage.ts +64 -0
- package/templates/m3e-app/src/shared/database/wa.worker.ts +5 -0
- package/templates/m3e-app/src/shared/utils/lazyRoute.ts +41 -0
- package/templates/m3e-app/src/shared/utils/resolvers/resolvers.ts +42 -0
- package/templates/m3e-app/src/shared/utils/textDirection.ts +22 -0
- package/templates/m3e-app/src/shared/utils/theme/themeSettings.ts +59 -0
- package/templates/m3e-app/src/shared/utils/tone.ts +8 -0
- package/templates/m3e-app/tests/benchmark.suite.test.ts +104 -0
- package/templates/m3e-app/tests/containerTransform.test.ts +58 -0
- package/templates/m3e-app/tests/fieldFormats.test.ts +25 -0
- package/templates/m3e-app/tests/lazyRoute.test.ts +30 -0
- package/templates/m3e-app/tests/locales.test.ts +73 -0
- package/templates/m3e-app/tests/migrations.test.ts +74 -0
- package/templates/m3e-app/tests/navigationGuard.test.ts +82 -0
- package/templates/m3e-app/tests/openDatabase.test.ts +54 -0
- package/templates/m3e-app/tests/sales.repository.test.ts +329 -0
- package/templates/m3e-app/tests/storage.test.ts +117 -0
- package/templates/m3e-app/tests/textDirection.test.ts +11 -0
- package/templates/m3e-app/tooling/linkedPackages.ts +112 -0
- package/templates/m3e-app/tsconfig.json +21 -0
- 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
|
+
}
|