@cavulsqa/create 0.1.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.
- package/README.md +63 -0
- package/bin/create.mjs +142 -0
- package/lib/scaffold.mjs +124 -0
- package/package.json +60 -0
- package/templates/f7-app/.claude/rules/data-fetching.md +67 -0
- package/templates/f7-app/.claude/rules/database.md +65 -0
- package/templates/f7-app/.claude/rules/framework7-ui.md +79 -0
- package/templates/f7-app/.claude/rules/modules.md +43 -0
- package/templates/f7-app/.claude/rules/native.md +47 -0
- package/templates/f7-app/.claude/skills/f7-design/SKILL.md +67 -0
- package/templates/f7-app/.claude/skills/f7-design/components.md +49 -0
- package/templates/f7-app/.claude/skills/f7-design/icons.md +52 -0
- package/templates/f7-app/.claude/skills/module-architecture/SKILL.md +65 -0
- package/templates/f7-app/.claude/skills/module-architecture/file-templates.md +183 -0
- package/templates/f7-app/.claude/skills/reactive-data/SKILL.md +89 -0
- package/templates/f7-app/.claude/skills/reactive-data/testing.md +55 -0
- package/templates/f7-app/CLAUDE.md +105 -0
- package/templates/f7-app/auto-imports.d.ts +667 -0
- package/templates/f7-app/capacitor.config.ts +14 -0
- package/templates/f7-app/components.d.ts +59 -0
- package/templates/f7-app/index.html +15 -0
- package/templates/f7-app/package.json +51 -0
- package/templates/f7-app/src/App.vue +72 -0
- package/templates/f7-app/src/app/tabs.ts +25 -0
- package/templates/f7-app/src/assets/css/app.css +1 -0
- package/templates/f7-app/src/assets/css/icons.css +50 -0
- package/templates/f7-app/src/assets/fonts/material-icons-outlined.woff2 +0 -0
- package/templates/f7-app/src/assets/fonts/material-icons-round.woff2 +0 -0
- package/templates/f7-app/src/domains/sales/sales.repository.ts +458 -0
- package/templates/f7-app/src/env.d.ts +23 -0
- package/templates/f7-app/src/locales/en.json +179 -0
- package/templates/f7-app/src/locales/fr.json +179 -0
- package/templates/f7-app/src/main.ts +50 -0
- package/templates/f7-app/src/modules/demo/components/DemoBusLog.vue +26 -0
- package/templates/f7-app/src/modules/demo/components/DemoCreateOrderSheet.vue +160 -0
- package/templates/f7-app/src/modules/demo/components/DemoOrderList.vue +73 -0
- package/templates/f7-app/src/modules/demo/components/DemoPipelineBenchmark.vue +52 -0
- package/templates/f7-app/src/modules/demo/components/DemoStatCards.vue +63 -0
- package/templates/f7-app/src/modules/demo/composables/useReactiveDemo.ts +168 -0
- package/templates/f7-app/src/modules/demo/router/routes/demo.routes.ts +34 -0
- package/templates/f7-app/src/modules/demo/views/DemoView.vue +126 -0
- package/templates/f7-app/src/modules/demo/views/OrderDetailView.vue +131 -0
- package/templates/f7-app/src/modules/demo/views/OrderSearchView.vue +106 -0
- package/templates/f7-app/src/modules/home/composables/useHomeFeatures.ts +133 -0
- package/templates/f7-app/src/modules/home/router/routes/home.routes.ts +29 -0
- package/templates/f7-app/src/modules/home/views/FeatureDetailView.vue +108 -0
- package/templates/f7-app/src/modules/home/views/HomeView.vue +53 -0
- package/templates/f7-app/src/modules/settings/router/routes/settings.routes.ts +16 -0
- package/templates/f7-app/src/modules/settings/views/SettingsView.vue +126 -0
- package/templates/f7-app/src/plugins/capacitor/index.ts +14 -0
- package/templates/f7-app/src/plugins/capacitor/useAndroidBackButton.ts +68 -0
- package/templates/f7-app/src/plugins/capacitor/useKeyboard.ts +61 -0
- package/templates/f7-app/src/plugins/capacitor/useSplashScreen.ts +11 -0
- package/templates/f7-app/src/plugins/capacitor/useStatusBar.ts +15 -0
- package/templates/f7-app/src/plugins/framework7.plugin.ts +41 -0
- package/templates/f7-app/src/plugins/i18n.plugin.ts +10 -0
- package/templates/f7-app/src/plugins/seed.plugin.ts +17 -0
- package/templates/f7-app/src/plugins/sqlite.plugin.ts +21 -0
- package/templates/f7-app/src/router/global/global.routes.ts +16 -0
- package/templates/f7-app/src/router/index.ts +18 -0
- package/templates/f7-app/src/shared/components/error/404.vue +12 -0
- package/templates/f7-app/src/shared/components/metrics/MetricsPanel.vue +79 -0
- package/templates/f7-app/src/shared/composables/theme/useAppTheme.ts +68 -0
- package/templates/f7-app/src/shared/composables/useTabbarVisibility.ts +50 -0
- package/templates/f7-app/src/shared/database/database.ts +86 -0
- package/templates/f7-app/src/shared/database/index.ts +3 -0
- package/templates/f7-app/src/shared/database/migrations.ts +81 -0
- package/templates/f7-app/src/shared/database/queries.ts +22 -0
- package/templates/f7-app/src/shared/database/schema.ts +59 -0
- package/templates/f7-app/src/shared/utils/resolvers/resolvers.ts +152 -0
- package/templates/f7-app/tests/icons.test.ts +81 -0
- package/templates/f7-app/tests/sales.repository.test.ts +329 -0
- package/templates/f7-app/tsconfig.json +21 -0
- package/templates/f7-app/tsconfig.node.json +14 -0
- package/templates/f7-app/vite.config.ts +95 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Capacitor } from "@capacitor/core";
|
|
2
|
+
import type { TableChangeEvent } from "@cavulsqa/reactive-db";
|
|
3
|
+
import {
|
|
4
|
+
advanceOrderStatus,
|
|
5
|
+
clearAll,
|
|
6
|
+
deleteOrder,
|
|
7
|
+
ensureReferenceData,
|
|
8
|
+
loadDashboardStats,
|
|
9
|
+
saveOrder,
|
|
10
|
+
searchOrders,
|
|
11
|
+
seedSampleData,
|
|
12
|
+
setOrderStatus,
|
|
13
|
+
type DashboardStats,
|
|
14
|
+
type DraftLine,
|
|
15
|
+
type OrderRow,
|
|
16
|
+
} from "@/domains/sales/sales.repository";
|
|
17
|
+
import { changeBus, getDatabase, rdb } from "@/shared/database/database";
|
|
18
|
+
import { uniqueQueryKey, useReactiveQuery } from "@/shared/database/queries";
|
|
19
|
+
|
|
20
|
+
export interface BusEntry {
|
|
21
|
+
/** Monotonic, because a millisecond timestamp is not unique - several events share one. */
|
|
22
|
+
id: number;
|
|
23
|
+
at: number;
|
|
24
|
+
table: string;
|
|
25
|
+
type: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PipelineResult {
|
|
29
|
+
parallelMs: number;
|
|
30
|
+
sequentialMs: number;
|
|
31
|
+
ratio: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const EVENT_LOG_LIMIT = 20;
|
|
35
|
+
const READS_PER_RUN = 5;
|
|
36
|
+
const EMPTY_STATS: DashboardStats = {
|
|
37
|
+
customers: 0,
|
|
38
|
+
products: 0,
|
|
39
|
+
orders: 0,
|
|
40
|
+
draft: 0,
|
|
41
|
+
confirmed: 0,
|
|
42
|
+
delivered: 0,
|
|
43
|
+
revenueCents: 0,
|
|
44
|
+
committedCents: 0,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Everything the Reactive screen shows, so the view stays presentational.
|
|
49
|
+
*
|
|
50
|
+
* Nothing here tells a query to refresh. Writes go through `rdb`, which announces the tables they
|
|
51
|
+
* touched, and both queries below watch those tables - which is why creating one order moves the
|
|
52
|
+
* revenue tile, the order count and the list at the same time.
|
|
53
|
+
*/
|
|
54
|
+
export function useReactiveDemo() {
|
|
55
|
+
const busy = ref(false);
|
|
56
|
+
const busLog = ref<BusEntry[]>([]);
|
|
57
|
+
const pipeline = ref<PipelineResult | null>(null);
|
|
58
|
+
const measuring = ref(false);
|
|
59
|
+
|
|
60
|
+
const statsQuery = useReactiveQuery(() => loadDashboardStats(getDatabase().db), {
|
|
61
|
+
// Four tables, because the tiles aggregate across all of them.
|
|
62
|
+
tables: ["customer", "product", "sales_order", "order_line"],
|
|
63
|
+
queryKey: uniqueQueryKey("demo:stats"),
|
|
64
|
+
debounce: 250,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const ordersQuery = useReactiveQuery(() => searchOrders(getDatabase().db, ""), {
|
|
68
|
+
tables: ["sales_order", "order_line", "customer"],
|
|
69
|
+
queryKey: uniqueQueryKey("demo:orders"),
|
|
70
|
+
debounce: 250,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
let busSequence = 0;
|
|
74
|
+
const stopWatching = changeBus.on(["*"], (event: TableChangeEvent) => {
|
|
75
|
+
busSequence += 1;
|
|
76
|
+
busLog.value = [
|
|
77
|
+
{ id: busSequence, at: Date.now(), table: event.table, type: event.type },
|
|
78
|
+
...busLog.value,
|
|
79
|
+
].slice(0, EVENT_LOG_LIMIT);
|
|
80
|
+
});
|
|
81
|
+
onUnmounted(stopWatching);
|
|
82
|
+
|
|
83
|
+
const stats = computed<DashboardStats>(() => statsQuery.data.value ?? EMPTY_STATS);
|
|
84
|
+
const orders = computed<OrderRow[]>(() => ordersQuery.data.value ?? []);
|
|
85
|
+
|
|
86
|
+
async function withBusy(work: () => Promise<void>): Promise<void> {
|
|
87
|
+
busy.value = true;
|
|
88
|
+
try {
|
|
89
|
+
await work();
|
|
90
|
+
} finally {
|
|
91
|
+
busy.value = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const seed = () => withBusy(() => seedSampleData(rdb));
|
|
96
|
+
|
|
97
|
+
const advance = (orderId: number) => withBusy(() => advanceOrderStatus(rdb, orderId));
|
|
98
|
+
|
|
99
|
+
const setStatus = (orderId: number, status: "draft" | "confirmed" | "delivered") =>
|
|
100
|
+
withBusy(() => setOrderStatus(rdb, orderId, status));
|
|
101
|
+
|
|
102
|
+
const remove = (orderId: number) => withBusy(() => deleteOrder(rdb, orderId));
|
|
103
|
+
|
|
104
|
+
const save = (input: { customerId: number; reference: string; lines: DraftLine[] }) =>
|
|
105
|
+
withBusy(() => saveOrder(rdb, input));
|
|
106
|
+
/**
|
|
107
|
+
* Wipe the demo data, then put the catalogue back. Deleting the products and customers too would
|
|
108
|
+
* leave the app in a state where no order can be written and the only way out is a restart.
|
|
109
|
+
*/
|
|
110
|
+
const clear = () =>
|
|
111
|
+
withBusy(async () => {
|
|
112
|
+
await clearAll(rdb);
|
|
113
|
+
await ensureReferenceData(rdb);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Reads issued together against reads awaited one by one.
|
|
118
|
+
*
|
|
119
|
+
* On a device the ratio is the point: the native bridge pipelines concurrent calls and the dialect
|
|
120
|
+
* keeps reads out of the write lock, so a screen loading with `Promise.all` pays once rather than
|
|
121
|
+
* N times. In a browser it sits near 1x because sql.js in memory has no bridge to pipeline, and
|
|
122
|
+
* the screen says so rather than reporting a number that reads as a regression.
|
|
123
|
+
*/
|
|
124
|
+
async function measurePipelining(): Promise<void> {
|
|
125
|
+
measuring.value = true;
|
|
126
|
+
try {
|
|
127
|
+
const db = getDatabase().db;
|
|
128
|
+
const read = () => db.selectFrom("sales_order").select("id").limit(5).execute();
|
|
129
|
+
await read();
|
|
130
|
+
|
|
131
|
+
const parallelStart = performance.now();
|
|
132
|
+
await Promise.all(Array.from({ length: READS_PER_RUN }, read));
|
|
133
|
+
const parallelMs = performance.now() - parallelStart;
|
|
134
|
+
|
|
135
|
+
const sequentialStart = performance.now();
|
|
136
|
+
for (let index = 0; index < READS_PER_RUN; index++) await read();
|
|
137
|
+
const sequentialMs = performance.now() - sequentialStart;
|
|
138
|
+
|
|
139
|
+
pipeline.value = {
|
|
140
|
+
parallelMs: Number(parallelMs.toFixed(1)),
|
|
141
|
+
sequentialMs: Number(sequentialMs.toFixed(1)),
|
|
142
|
+
ratio: Number((sequentialMs / Math.max(parallelMs, 0.01)).toFixed(2)),
|
|
143
|
+
};
|
|
144
|
+
} finally {
|
|
145
|
+
measuring.value = false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
stats,
|
|
151
|
+
orders,
|
|
152
|
+
loading: ordersQuery.loading,
|
|
153
|
+
busy,
|
|
154
|
+
busLog,
|
|
155
|
+
pipeline,
|
|
156
|
+
measuring,
|
|
157
|
+
readsPerRun: READS_PER_RUN,
|
|
158
|
+
isNative: Capacitor.isNativePlatform(),
|
|
159
|
+
platform: Capacitor.getPlatform(),
|
|
160
|
+
seed,
|
|
161
|
+
advance,
|
|
162
|
+
setStatus,
|
|
163
|
+
remove,
|
|
164
|
+
save,
|
|
165
|
+
clear,
|
|
166
|
+
measurePipelining,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Router } from "framework7/types";
|
|
2
|
+
|
|
3
|
+
const demoRoutes: Router.RouteParameters[] = [
|
|
4
|
+
{
|
|
5
|
+
name: "demo",
|
|
6
|
+
path: "/demo/",
|
|
7
|
+
// `async` is Framework7's route hook, not an async function - resolve from the promise.
|
|
8
|
+
async({ resolve }) {
|
|
9
|
+
void import("@/modules/demo/views/DemoView.vue").then((view) => {
|
|
10
|
+
resolve({ component: view.default });
|
|
11
|
+
});
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "demo-search",
|
|
16
|
+
path: "/demo/search/",
|
|
17
|
+
async({ resolve }) {
|
|
18
|
+
void import("@/modules/demo/views/OrderSearchView.vue").then((view) => {
|
|
19
|
+
resolve({ component: view.default });
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "demo-order",
|
|
25
|
+
path: "/demo/order/:id/",
|
|
26
|
+
async({ resolve }) {
|
|
27
|
+
void import("@/modules/demo/views/OrderDetailView.vue").then((view) => {
|
|
28
|
+
resolve({ component: view.default });
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export default demoRoutes;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<F7Page :page-content="false">
|
|
3
|
+
<F7Navbar :title="t('demo.title')" :sliding="true">
|
|
4
|
+
<F7NavRight>
|
|
5
|
+
<F7Link icon-f7="search" href="/demo/search/" />
|
|
6
|
+
<F7Link icon-f7="chart_bar_alt_fill" panel-open="right" />
|
|
7
|
+
</F7NavRight>
|
|
8
|
+
</F7Navbar>
|
|
9
|
+
|
|
10
|
+
<!-- Real Framework7 tabs, so the panes swipe rather than switching on a v-if. -->
|
|
11
|
+
<F7Toolbar tabbar top>
|
|
12
|
+
<F7Link tab-link="#demo-tab-data" tab-link-active>{{ t("demo.data") }}</F7Link>
|
|
13
|
+
<F7Link tab-link="#demo-tab-diagnostics">{{ t("demo.diagnostics") }}</F7Link>
|
|
14
|
+
</F7Toolbar>
|
|
15
|
+
|
|
16
|
+
<F7Tabs swipeable>
|
|
17
|
+
<F7Tab id="demo-tab-data" class="page-content" tab-active>
|
|
18
|
+
<DemoStatCards :stats="stats" class="mt-3" />
|
|
19
|
+
|
|
20
|
+
<F7BlockTitle>
|
|
21
|
+
{{ t("demo.orders") }}
|
|
22
|
+
<span v-if="loading" class="ml-2 text-xs opacity-60">{{ t("demo.refetching") }}</span>
|
|
23
|
+
</F7BlockTitle>
|
|
24
|
+
|
|
25
|
+
<DemoOrderList
|
|
26
|
+
v-if="orders.length"
|
|
27
|
+
:orders="orders"
|
|
28
|
+
@open="openOrder"
|
|
29
|
+
@advance="advance"
|
|
30
|
+
@remove="remove"
|
|
31
|
+
/>
|
|
32
|
+
<F7Block v-else strong inset class="rounded-2xl!">
|
|
33
|
+
<p class="m-0 text-sm opacity-70">{{ t("demo.empty") }}</p>
|
|
34
|
+
</F7Block>
|
|
35
|
+
|
|
36
|
+
<F7Block class="text-center">
|
|
37
|
+
<p class="m-0 text-xs opacity-45">{{ t("demo.swipeHint") }} {{ t("demo.tapHint") }}</p>
|
|
38
|
+
</F7Block>
|
|
39
|
+
</F7Tab>
|
|
40
|
+
|
|
41
|
+
<F7Tab id="demo-tab-diagnostics" class="page-content">
|
|
42
|
+
<DemoBusLog :entries="busLog" />
|
|
43
|
+
<DemoPipelineBenchmark
|
|
44
|
+
:result="pipeline"
|
|
45
|
+
:measuring="measuring"
|
|
46
|
+
:reads-per-run="readsPerRun"
|
|
47
|
+
:is-native="isNative"
|
|
48
|
+
:platform="platform"
|
|
49
|
+
@measure="measurePipelining"
|
|
50
|
+
/>
|
|
51
|
+
</F7Tab>
|
|
52
|
+
</F7Tabs>
|
|
53
|
+
|
|
54
|
+
<!--
|
|
55
|
+
Buttons open upward: the app's tab bar sits at the bottom of the shell, so a downward or
|
|
56
|
+
morphed menu disappears behind it.
|
|
57
|
+
-->
|
|
58
|
+
<F7Fab slot="fixed" position="right-bottom">
|
|
59
|
+
<F7Icon f7="plus" />
|
|
60
|
+
<F7Icon f7="xmark" />
|
|
61
|
+
<F7FabButtons position="top">
|
|
62
|
+
<F7FabButton fab-close :label="t('demo.newOrder')" @click="createOpen = true">
|
|
63
|
+
<F7Icon f7="square_pencil_fill" size="20" />
|
|
64
|
+
</F7FabButton>
|
|
65
|
+
<F7FabButton fab-close :label="t('demo.seed')" @click="run(seed)">
|
|
66
|
+
<F7Icon f7="wand_stars" size="20" />
|
|
67
|
+
</F7FabButton>
|
|
68
|
+
<F7FabButton fab-close :label="t('demo.clear')" @click="run(clear)">
|
|
69
|
+
<F7Icon f7="trash" size="20" />
|
|
70
|
+
</F7FabButton>
|
|
71
|
+
</F7FabButtons>
|
|
72
|
+
</F7Fab>
|
|
73
|
+
|
|
74
|
+
<DemoCreateOrderSheet :opened="createOpen" @close="createOpen = false" @save="onSaveOrder" />
|
|
75
|
+
</F7Page>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<script setup lang="ts">
|
|
79
|
+
import type { OrderRow } from "@/domains/sales/sales.repository";
|
|
80
|
+
import DemoBusLog from "@/modules/demo/components/DemoBusLog.vue";
|
|
81
|
+
import DemoCreateOrderSheet from "@/modules/demo/components/DemoCreateOrderSheet.vue";
|
|
82
|
+
import DemoOrderList from "@/modules/demo/components/DemoOrderList.vue";
|
|
83
|
+
import DemoPipelineBenchmark from "@/modules/demo/components/DemoPipelineBenchmark.vue";
|
|
84
|
+
import DemoStatCards from "@/modules/demo/components/DemoStatCards.vue";
|
|
85
|
+
import { useReactiveDemo } from "@/modules/demo/composables/useReactiveDemo";
|
|
86
|
+
|
|
87
|
+
import type { Router } from "framework7/types";
|
|
88
|
+
|
|
89
|
+
const { t } = useI18n();
|
|
90
|
+
const props = defineProps<{ f7router: Router.Router }>();
|
|
91
|
+
const createOpen = ref(false);
|
|
92
|
+
|
|
93
|
+
const {
|
|
94
|
+
stats,
|
|
95
|
+
orders,
|
|
96
|
+
loading,
|
|
97
|
+
busy,
|
|
98
|
+
busLog,
|
|
99
|
+
pipeline,
|
|
100
|
+
measuring,
|
|
101
|
+
readsPerRun,
|
|
102
|
+
isNative,
|
|
103
|
+
platform,
|
|
104
|
+
seed,
|
|
105
|
+
advance,
|
|
106
|
+
remove,
|
|
107
|
+
save,
|
|
108
|
+
clear,
|
|
109
|
+
measurePipelining,
|
|
110
|
+
} = useReactiveDemo();
|
|
111
|
+
|
|
112
|
+
async function run(action: () => Promise<void>): Promise<void> {
|
|
113
|
+
if (busy.value) return;
|
|
114
|
+
await action();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Tapping a row opens the order; the actions live there, in a bar fixed to the bottom. */
|
|
118
|
+
function openOrder(order: OrderRow) {
|
|
119
|
+
props.f7router.navigate(`/demo/order/${String(order.id)}/`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function onSaveOrder(payload: Parameters<typeof save>[0]): Promise<void> {
|
|
123
|
+
await save(payload);
|
|
124
|
+
createOpen.value = false;
|
|
125
|
+
}
|
|
126
|
+
</script>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<F7Page>
|
|
3
|
+
<F7Navbar
|
|
4
|
+
:title="order?.reference ?? t('demo.order')"
|
|
5
|
+
:subtitle="order?.customerName"
|
|
6
|
+
back-link
|
|
7
|
+
>
|
|
8
|
+
<F7NavRight>
|
|
9
|
+
<F7Link v-if="order" icon-f7="trash" @click="confirmDelete" />
|
|
10
|
+
</F7NavRight>
|
|
11
|
+
</F7Navbar>
|
|
12
|
+
|
|
13
|
+
<template v-if="order">
|
|
14
|
+
<F7Block strong inset class="rounded-2xl! mt-3!">
|
|
15
|
+
<div class="flex items-start justify-between">
|
|
16
|
+
<div>
|
|
17
|
+
<F7Chip :text="order.status" :color="statusColor" />
|
|
18
|
+
<div class="mt-1.5 text-[13px] opacity-60">{{ order.city }}</div>
|
|
19
|
+
<div v-if="order.tags.length" class="mt-2 flex flex-wrap gap-1.5">
|
|
20
|
+
<F7Chip v-for="tag in order.tags" :key="tag" :text="tag" outline />
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="text-right">
|
|
24
|
+
<div class="text-[11px] uppercase tracking-wide opacity-55">{{ t("demo.total") }}</div>
|
|
25
|
+
<div class="text-2xl font-semibold tabular-nums leading-tight">
|
|
26
|
+
{{ money(order.totalCents) }}
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</F7Block>
|
|
31
|
+
|
|
32
|
+
<F7BlockTitle>{{ t("demo.lineItems") }}</F7BlockTitle>
|
|
33
|
+
<!--
|
|
34
|
+
media-list, not a plain list: Framework7 only renders `subtitle` in a media list, so the
|
|
35
|
+
quantity and unit price were being dropped silently.
|
|
36
|
+
-->
|
|
37
|
+
<F7List media-list strong inset dividers class="rounded-2xl!">
|
|
38
|
+
<F7ListItem
|
|
39
|
+
v-for="line in order.lines"
|
|
40
|
+
:key="line.id"
|
|
41
|
+
:title="line.productName"
|
|
42
|
+
:after="money(line.lineTotalCents)"
|
|
43
|
+
>
|
|
44
|
+
<template #subtitle>
|
|
45
|
+
<span class="text-[13px] tabular-nums opacity-60">
|
|
46
|
+
{{ line.quantity }} × {{ money(line.unitPriceCents) }}
|
|
47
|
+
</span>
|
|
48
|
+
</template>
|
|
49
|
+
</F7ListItem>
|
|
50
|
+
</F7List>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<F7Block v-else strong inset class="rounded-2xl! mt-3!">
|
|
54
|
+
<p class="m-0 text-sm opacity-70">{{ t("demo.orderGone") }}</p>
|
|
55
|
+
</F7Block>
|
|
56
|
+
|
|
57
|
+
<!--
|
|
58
|
+
A bottom toolbar rather than a hand-built bar: Framework7 spaces its links evenly and keeps
|
|
59
|
+
them clear of the safe area, which the previous stacked segmented control did neither of.
|
|
60
|
+
Delete moved to the navbar - it does not belong next to three statuses you pick between.
|
|
61
|
+
-->
|
|
62
|
+
<F7Toolbar v-if="order" bottom>
|
|
63
|
+
<F7Link
|
|
64
|
+
v-for="option in statuses"
|
|
65
|
+
:key="option"
|
|
66
|
+
:class="{ 'text-color-primary font-semibold': order.status === option }"
|
|
67
|
+
@click="apply(option)"
|
|
68
|
+
>
|
|
69
|
+
{{ t(`demo.status${option.charAt(0).toUpperCase()}${option.slice(1)}`) }}
|
|
70
|
+
</F7Link>
|
|
71
|
+
</F7Toolbar>
|
|
72
|
+
</F7Page>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<script setup lang="ts">
|
|
76
|
+
import type { Router } from "framework7/types";
|
|
77
|
+
import {
|
|
78
|
+
deleteOrder,
|
|
79
|
+
loadOrderDetail,
|
|
80
|
+
setOrderStatus,
|
|
81
|
+
type OrderDetail,
|
|
82
|
+
} from "@/domains/sales/sales.repository";
|
|
83
|
+
import { useHiddenTabbar } from "@/shared/composables/useTabbarVisibility";
|
|
84
|
+
import { getDatabase, rdb } from "@/shared/database/database";
|
|
85
|
+
import { uniqueQueryKey, useReactiveQuery } from "@/shared/database/queries";
|
|
86
|
+
|
|
87
|
+
const { t } = useI18n();
|
|
88
|
+
|
|
89
|
+
// A pushed page owns the whole screen; the tab bar belongs to the tab roots.
|
|
90
|
+
useHiddenTabbar();
|
|
91
|
+
|
|
92
|
+
const props = defineProps<{ f7route: Router.Route; f7router: Router.Router }>();
|
|
93
|
+
|
|
94
|
+
const orderId = Number(props.f7route.params.id ?? 0);
|
|
95
|
+
const statuses = ["draft", "confirmed", "delivered"] as const;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reads five tables, so any write in that set refreshes this page - including the status changes
|
|
99
|
+
* made from the toolbar below, which is why nothing here calls refetch by hand.
|
|
100
|
+
*/
|
|
101
|
+
const query = useReactiveQuery(() => loadOrderDetail(getDatabase().db, orderId), {
|
|
102
|
+
tables: ["sales_order", "order_line", "customer", "product", "customer_tag"],
|
|
103
|
+
queryKey: uniqueQueryKey(`demo:order-${String(orderId)}`),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const order = computed<OrderDetail | null>(() => query.data.value ?? null);
|
|
107
|
+
|
|
108
|
+
const formatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
|
|
109
|
+
const money = (cents: number) => formatter.format(cents / 100);
|
|
110
|
+
|
|
111
|
+
const statusColor = computed(() =>
|
|
112
|
+
order.value?.status === "delivered"
|
|
113
|
+
? "green"
|
|
114
|
+
: order.value?.status === "confirmed"
|
|
115
|
+
? "orange"
|
|
116
|
+
: "gray",
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
async function apply(status: (typeof statuses)[number]) {
|
|
120
|
+
if (!order.value || order.value.status === status) return;
|
|
121
|
+
await setOrderStatus(rdb, orderId, status);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function confirmDelete() {
|
|
125
|
+
f7.dialog.confirm(t("demo.deleteConfirm"), t("demo.delete"), () => {
|
|
126
|
+
void deleteOrder(rdb, orderId).then(() => {
|
|
127
|
+
props.f7router.back();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
</script>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<F7Page>
|
|
3
|
+
<!--
|
|
4
|
+
The shape from the Framework7 searchbar docs: an expandable searchbar inside the navbar, opened
|
|
5
|
+
by a `searchbar-enable` link that points at it. Two earlier attempts got this wrong - a bare
|
|
6
|
+
`expandable` bar with nothing to enable it renders nothing, and moving it to a subnavbar puts
|
|
7
|
+
it below the title instead of taking the navbar over.
|
|
8
|
+
-->
|
|
9
|
+
<F7Navbar :title="t('demo.searchTitle')" back-link>
|
|
10
|
+
<F7NavRight>
|
|
11
|
+
<F7Link
|
|
12
|
+
class="searchbar-enable"
|
|
13
|
+
data-searchbar=".searchbar-orders"
|
|
14
|
+
icon-ios="f7:search"
|
|
15
|
+
icon-md="material:search"
|
|
16
|
+
/>
|
|
17
|
+
</F7NavRight>
|
|
18
|
+
<F7Searchbar
|
|
19
|
+
class="searchbar-orders"
|
|
20
|
+
expandable
|
|
21
|
+
:placeholder="t('demo.searchPlaceholder')"
|
|
22
|
+
:custom-search="true"
|
|
23
|
+
:clear-button="true"
|
|
24
|
+
@input="onInput"
|
|
25
|
+
@searchbar:clear="onClear"
|
|
26
|
+
@searchbar:disable="onClear"
|
|
27
|
+
/>
|
|
28
|
+
</F7Navbar>
|
|
29
|
+
|
|
30
|
+
<F7BlockTitle>
|
|
31
|
+
{{ term ? t("demo.matches", { count: results.length }) : t("demo.allOrders") }}
|
|
32
|
+
</F7BlockTitle>
|
|
33
|
+
|
|
34
|
+
<F7List v-if="results.length" media-list strong inset dividers class="rounded-2xl!">
|
|
35
|
+
<F7ListItem
|
|
36
|
+
v-for="order in results"
|
|
37
|
+
:key="order.id"
|
|
38
|
+
:title="order.reference"
|
|
39
|
+
:after="money(order.totalCents)"
|
|
40
|
+
:link="`/demo/order/${String(order.id)}/`"
|
|
41
|
+
>
|
|
42
|
+
<template #media>
|
|
43
|
+
<F7Icon :f7="statusIcon(order.status)" :color="statusColor(order.status)" size="22" />
|
|
44
|
+
</template>
|
|
45
|
+
<template #subtitle>
|
|
46
|
+
<span class="text-[13px] opacity-60">{{ order.customerName }} · {{ order.city }}</span>
|
|
47
|
+
</template>
|
|
48
|
+
</F7ListItem>
|
|
49
|
+
</F7List>
|
|
50
|
+
|
|
51
|
+
<F7Block v-else strong inset class="rounded-2xl!">
|
|
52
|
+
<p class="m-0 text-sm opacity-60">
|
|
53
|
+
{{ term ? t("demo.noMatches") : t("demo.empty") }}
|
|
54
|
+
</p>
|
|
55
|
+
</F7Block>
|
|
56
|
+
</F7Page>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<script setup lang="ts">
|
|
60
|
+
import { useHiddenTabbar } from "@/shared/composables/useTabbarVisibility";
|
|
61
|
+
import { searchOrders, type OrderRow } from "@/domains/sales/sales.repository";
|
|
62
|
+
import { getDatabase } from "@/shared/database/database";
|
|
63
|
+
import { uniqueQueryKey, useReactiveQuery } from "@/shared/database/queries";
|
|
64
|
+
|
|
65
|
+
const { t } = useI18n();
|
|
66
|
+
|
|
67
|
+
// A pushed page owns the whole screen; the tab bar belongs to the tab roots.
|
|
68
|
+
useHiddenTabbar();
|
|
69
|
+
|
|
70
|
+
const term = ref("");
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The list is populated before anything is typed - an empty search screen gives no sense of what is
|
|
74
|
+
* searchable. An empty term returns the most recent orders, which is what the query already does.
|
|
75
|
+
*/
|
|
76
|
+
const query = useReactiveQuery(() => searchOrders(getDatabase().db, term.value), {
|
|
77
|
+
tables: ["sales_order", "order_line", "customer"],
|
|
78
|
+
queryKey: uniqueQueryKey("demo:search"),
|
|
79
|
+
debounce: 200,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const results = computed<OrderRow[]>(() => query.data.value ?? []);
|
|
83
|
+
|
|
84
|
+
function onInput(event: Event) {
|
|
85
|
+
term.value = (event.target as HTMLInputElement).value;
|
|
86
|
+
void query.refetch();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function onClear() {
|
|
90
|
+
term.value = "";
|
|
91
|
+
void query.refetch();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const formatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
|
|
95
|
+
const money = (cents: number) => formatter.format(cents / 100);
|
|
96
|
+
|
|
97
|
+
const statusColor = (status: string) =>
|
|
98
|
+
status === "delivered" ? "green" : status === "confirmed" ? "orange" : "gray";
|
|
99
|
+
|
|
100
|
+
const statusIcon = (status: string) =>
|
|
101
|
+
status === "delivered"
|
|
102
|
+
? "checkmark_seal_fill"
|
|
103
|
+
: status === "confirmed"
|
|
104
|
+
? "shippingbox_fill"
|
|
105
|
+
: "doc_plaintext";
|
|
106
|
+
</script>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/** Framework7 page transitions, one per detail route so each push looks different. */
|
|
2
|
+
export type PageTransition =
|
|
3
|
+
| "f7-circle"
|
|
4
|
+
| "f7-cover"
|
|
5
|
+
| "f7-cover-v"
|
|
6
|
+
| "f7-dive"
|
|
7
|
+
| "f7-fade"
|
|
8
|
+
| "f7-flip"
|
|
9
|
+
| "f7-parallax"
|
|
10
|
+
| "f7-push";
|
|
11
|
+
|
|
12
|
+
/** How the detail page's navbar is configured, to show the variants side by side. */
|
|
13
|
+
export interface NavbarVariant {
|
|
14
|
+
large: boolean;
|
|
15
|
+
transparent: boolean;
|
|
16
|
+
hideOnScroll: boolean;
|
|
17
|
+
/** Adds a second row under the title, which stays put while the large title collapses. */
|
|
18
|
+
subnavbar: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HomeFeature {
|
|
22
|
+
id: string;
|
|
23
|
+
icon: string;
|
|
24
|
+
/** Framework7 colour name - the palette the theme already ships. */
|
|
25
|
+
color: string;
|
|
26
|
+
titleKey: string;
|
|
27
|
+
subtitleKey: string;
|
|
28
|
+
textKey: string;
|
|
29
|
+
transition: PageTransition;
|
|
30
|
+
navbar: NavbarVariant;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const navbar = (
|
|
34
|
+
large: boolean,
|
|
35
|
+
transparent: boolean,
|
|
36
|
+
hideOnScroll: boolean,
|
|
37
|
+
subnavbar = false,
|
|
38
|
+
): NavbarVariant => ({ large, transparent, hideOnScroll, subnavbar });
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Data rather than markup, so removing a layer means removing an entry and the screen cannot keep
|
|
42
|
+
* advertising something the generator left out.
|
|
43
|
+
*
|
|
44
|
+
* Each entry also drives a detail route inside the Home tab's own view. That is the point of the
|
|
45
|
+
* nesting: the push happens within the tab, so the tab bar stays put, the back gesture unwinds only
|
|
46
|
+
* this tab's history, and every other tab keeps the screen it was on.
|
|
47
|
+
*/
|
|
48
|
+
export const features: HomeFeature[] = [
|
|
49
|
+
{
|
|
50
|
+
id: "tabs",
|
|
51
|
+
icon: "square_grid_2x2_fill",
|
|
52
|
+
color: "blue",
|
|
53
|
+
titleKey: "features.tabs.title",
|
|
54
|
+
subtitleKey: "features.tabs.subtitle",
|
|
55
|
+
textKey: "features.tabs.text",
|
|
56
|
+
transition: "f7-parallax",
|
|
57
|
+
navbar: navbar(true, false, false),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "f7",
|
|
61
|
+
icon: "layers_alt_fill",
|
|
62
|
+
color: "pink",
|
|
63
|
+
titleKey: "features.f7.title",
|
|
64
|
+
subtitleKey: "features.f7.subtitle",
|
|
65
|
+
textKey: "features.f7.text",
|
|
66
|
+
transition: "f7-cover",
|
|
67
|
+
navbar: navbar(true, true, false),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "sqlite",
|
|
71
|
+
icon: "archivebox_fill",
|
|
72
|
+
color: "green",
|
|
73
|
+
titleKey: "features.sqlite.title",
|
|
74
|
+
subtitleKey: "features.sqlite.subtitle",
|
|
75
|
+
textKey: "features.sqlite.text",
|
|
76
|
+
transition: "f7-push",
|
|
77
|
+
navbar: navbar(false, false, true),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "reactive",
|
|
81
|
+
icon: "arrow_2_circlepath",
|
|
82
|
+
color: "teal",
|
|
83
|
+
titleKey: "features.reactive.title",
|
|
84
|
+
subtitleKey: "features.reactive.subtitle",
|
|
85
|
+
textKey: "features.reactive.text",
|
|
86
|
+
transition: "f7-dive",
|
|
87
|
+
navbar: navbar(true, false, false, true),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "back",
|
|
91
|
+
icon: "arrow_uturn_left_circle_fill",
|
|
92
|
+
color: "orange",
|
|
93
|
+
titleKey: "features.back.title",
|
|
94
|
+
subtitleKey: "features.back.subtitle",
|
|
95
|
+
textKey: "features.back.text",
|
|
96
|
+
transition: "f7-flip",
|
|
97
|
+
navbar: navbar(false, false, false),
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "keyboard",
|
|
101
|
+
icon: "keyboard",
|
|
102
|
+
color: "purple",
|
|
103
|
+
titleKey: "features.keyboard.title",
|
|
104
|
+
subtitleKey: "features.keyboard.subtitle",
|
|
105
|
+
textKey: "features.keyboard.text",
|
|
106
|
+
transition: "f7-fade",
|
|
107
|
+
navbar: navbar(false, true, false),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "metrics",
|
|
111
|
+
icon: "chart_bar_alt_fill",
|
|
112
|
+
color: "red",
|
|
113
|
+
titleKey: "features.metrics.title",
|
|
114
|
+
subtitleKey: "features.metrics.subtitle",
|
|
115
|
+
textKey: "features.metrics.text",
|
|
116
|
+
transition: "f7-circle",
|
|
117
|
+
navbar: navbar(true, true, true),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "dx",
|
|
121
|
+
icon: "rocket_fill",
|
|
122
|
+
color: "yellow",
|
|
123
|
+
titleKey: "features.dx.title",
|
|
124
|
+
subtitleKey: "features.dx.subtitle",
|
|
125
|
+
textKey: "features.dx.text",
|
|
126
|
+
transition: "f7-cover-v",
|
|
127
|
+
navbar: navbar(true, false, false),
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
export function findFeature(id: string): HomeFeature | undefined {
|
|
132
|
+
return features.find((feature) => feature.id === id);
|
|
133
|
+
}
|