@cosider.construction/eapp-maker 1.0.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.
@@ -0,0 +1,38 @@
1
+ <script setup>
2
+ import { inject } from 'vue';
3
+ import AppMenuH from '@/components/AppMenuH.vue';
4
+ import AppMenuV from '@/components/AppMenuV.vue';
5
+ import moduleMenu from '@/menus/MODULE_NAME.menu.json';
6
+
7
+ // Views inside this layout can inject 'hMenuConfig' to add a sub hMenu
8
+ // Layout owns the main hMenu and vMenu from the module's menu.json
9
+ const subHMenu = inject('hMenuConfig', null);
10
+
11
+ const hMenuConfig = moduleMenu.hMenu || { style: 'classic', items: [] };
12
+ const vMenuConfig = moduleMenu.vMenu || { items: [] };
13
+ </script>
14
+
15
+ <template>
16
+ <div class="module-layout">
17
+ <!-- Module hMenu -->
18
+ <AppMenuH :config="hMenuConfig" />
19
+
20
+ <!-- Optional sub hMenu injected by the view -->
21
+ <AppMenuH v-if="subHMenu" :config="subHMenu" sub />
22
+
23
+ <div class="module-layout__body">
24
+ <!-- Module vMenu -->
25
+ <AppMenuV :config="vMenuConfig" />
26
+
27
+ <main class="module-layout__main">
28
+ <slot />
29
+ </main>
30
+ </div>
31
+ </div>
32
+ </template>
33
+
34
+ <style scoped>
35
+ .module-layout { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
36
+ .module-layout__body { display: flex; flex: 1; overflow: hidden; }
37
+ .module-layout__main { flex: 1; overflow: auto; padding: 1.5rem; background: #1e1e2e; }
38
+ </style>
@@ -0,0 +1,94 @@
1
+ import { ipcMain } from 'electron';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { checkPermission } from '../engine/auth.js';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const BACKEND = path.resolve(__dirname, '..');
9
+
10
+ /**
11
+ * loader.ipc.js
12
+ * Scans all ipc.json files under shared/ and modules/,
13
+ * registers each declared method as an Electron IPC handler.
14
+ *
15
+ * Channel pattern: <route>:<method>
16
+ * e.g. modules.RH.EMPLOYEE:getAll
17
+ */
18
+ export async function loadIpc() {
19
+ const dirs = [
20
+ path.join(BACKEND, 'shared'),
21
+ path.join(BACKEND, 'modules'),
22
+ ];
23
+
24
+ for (const dir of dirs) {
25
+ if (!fs.existsSync(dir)) continue;
26
+ for (const ipcPath of walkIpc(dir)) {
27
+ await registerIpc(ipcPath);
28
+ }
29
+ }
30
+ }
31
+
32
+ function walkIpc(root, out = []) {
33
+ for (const e of fs.readdirSync(root, { withFileTypes: true })) {
34
+ const full = path.join(root, e.name);
35
+ if (e.isDirectory()) walkIpc(full, out);
36
+ else if (e.name === 'ipc.json') out.push(full);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ async function registerIpc(ipcJsonPath) {
42
+ let config;
43
+ try {
44
+ config = JSON.parse(fs.readFileSync(ipcJsonPath, 'utf8'));
45
+ } catch (e) {
46
+ console.warn('[loader.ipc] Failed to parse:', ipcJsonPath);
47
+ return;
48
+ }
49
+
50
+ const { route, methods = {} } = config;
51
+ const entityDir = path.dirname(ipcJsonPath);
52
+
53
+ let ctrl;
54
+ try {
55
+ ctrl = await import(path.join(entityDir, 'controller.js'));
56
+ } catch (e) {
57
+ console.warn('[loader.ipc] No controller at:', entityDir);
58
+ return;
59
+ }
60
+
61
+ for (const [methodName, def] of Object.entries(methods)) {
62
+ const handler = typeof def === 'string' ? def : def.handler;
63
+ const permission = typeof def === 'object' ? def.permission : null;
64
+ const channel = `${route}:${methodName}`;
65
+
66
+ if (typeof ctrl[handler] !== 'function') {
67
+ console.warn(`[loader.ipc] "${handler}" not found in ${entityDir}`);
68
+ continue;
69
+ }
70
+
71
+ ipcMain.handle(channel, async (_, payload) => {
72
+ // Permission check
73
+ if (permission) {
74
+ const userId = payload?.__userId;
75
+ const ok = await checkPermission(userId, permission);
76
+ if (!ok) return { error: 'FORBIDDEN', message: `Missing permission: ${permission}` };
77
+ }
78
+
79
+ try {
80
+ const { id, data } = payload || {};
81
+ if (methodName === 'getById') return await ctrl[handler](id);
82
+ if (methodName === 'update') return await ctrl[handler](id, data);
83
+ if (methodName === 'delete') return await ctrl[handler](id);
84
+ if (methodName === 'add') return await ctrl[handler](data);
85
+ return await ctrl[handler](payload);
86
+ } catch (err) {
87
+ console.error(`[loader.ipc] Error in ${channel}:`, err.message);
88
+ return { error: 'INTERNAL_ERROR', message: err.message };
89
+ }
90
+ });
91
+
92
+ console.log('[loader.ipc] registered:', channel);
93
+ }
94
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * loader.menu.js
3
+ *
4
+ * Scans all *.menu.json files in src/frontend/menus/.
5
+ * Builds two structures:
6
+ * - vMenuTree : full sidebar navigation (merged from all modules)
7
+ * - hMenuMap : keyed by route path, returns hMenu config for that route
8
+ *
9
+ * Layout usage:
10
+ * import { useMenuLoader } from '@/loaders/loader.menu.js'
11
+ * const { vMenuTree, hMenuFor } = useMenuLoader()
12
+ * const subHMenu = computed(() => hMenuFor(route.path))
13
+ *
14
+ * Menu file types:
15
+ * main.menu.json → app-level
16
+ * rh.menu.json → module-level hMenu + vMenu
17
+ * rh.employee.menu.json → view-level sub hMenu (ribbon or classic)
18
+ */
19
+
20
+ import { computed } from 'vue';
21
+ import { useRoute } from 'vue-router';
22
+ import { useAuthStore } from '@/stores/auth.js';
23
+
24
+ // Vite glob — eager load all menu files
25
+ const allMenuFiles = import.meta.glob('@/menus/*.menu.json', { eager: true });
26
+
27
+ function getMenu(file) {
28
+ return file?.default ?? file ?? {};
29
+ }
30
+
31
+ // ─── Build structures ─────────────────────────────────────────────────────────
32
+
33
+ let _vMenuTree = null;
34
+ let _hMenuMap = null;
35
+
36
+ function build(auth) {
37
+ const vMenuTree = [];
38
+ const hMenuMap = {};
39
+
40
+ for (const [filePath, raw] of Object.entries(allMenuFiles)) {
41
+ const menu = getMenu(raw);
42
+ const fileName = filePath.split('/').pop().replace('.menu.json', '');
43
+ const parts = fileName.split('.');
44
+
45
+ if (parts.length === 1 || (parts.length === 2 && parts[0] !== 'main')) {
46
+ // Module-level or main: contribute to vMenuTree
47
+ if (menu.vMenu?.items) {
48
+ const filtered = filterItems(menu.vMenu.items, auth);
49
+ if (filtered.length) vMenuTree.push(...filtered);
50
+ }
51
+
52
+ // Module hMenu keyed by module root route
53
+ if (menu.hMenu && menu.module) {
54
+ hMenuMap[`/${menu.module}`] = menu.hMenu;
55
+ }
56
+ }
57
+
58
+ if (parts.length >= 2) {
59
+ // View-level: rh.employee → keyed by /rh/employee
60
+ const route = '/' + parts.join('/');
61
+ if (menu.hMenu) {
62
+ hMenuMap[route] = menu.hMenu;
63
+ }
64
+ }
65
+ }
66
+
67
+ return { vMenuTree, hMenuMap };
68
+ }
69
+
70
+ // ─── Filter by permission ─────────────────────────────────────────────────────
71
+
72
+ function filterItems(items, auth) {
73
+ return items
74
+ .filter(item => !item.permission || auth.hasPermission(item.permission))
75
+ .map(item => ({
76
+ ...item,
77
+ children: item.children ? filterItems(item.children, auth) : undefined,
78
+ }));
79
+ }
80
+
81
+ // ─── Composable ───────────────────────────────────────────────────────────────
82
+
83
+ export function useMenuLoader() {
84
+ const auth = useAuthStore();
85
+ const route = useRoute();
86
+
87
+ const { vMenuTree, hMenuMap } = build(auth);
88
+
89
+ /**
90
+ * Returns the hMenu config for a given route path.
91
+ * Checks exact match first, then parent path.
92
+ */
93
+ function hMenuFor(routePath) {
94
+ if (hMenuMap[routePath]) return hMenuMap[routePath];
95
+ // Try parent path (e.g. /rh/employee/1 → /rh/employee → /rh)
96
+ const parts = routePath.split('/').filter(Boolean);
97
+ while (parts.length > 0) {
98
+ parts.pop();
99
+ const parent = '/' + parts.join('/');
100
+ if (hMenuMap[parent]) return hMenuMap[parent];
101
+ }
102
+ return null;
103
+ }
104
+
105
+ const currentHMenu = computed(() => hMenuFor(route.path));
106
+ const currentSubHMenu = computed(() => {
107
+ // Sub hMenu = view-level menu (2-part key like rh.employee)
108
+ const parts = route.path.split('/').filter(Boolean);
109
+ if (parts.length >= 2) {
110
+ const key = '/' + parts.slice(0, 2).join('/');
111
+ return hMenuMap[key] || null;
112
+ }
113
+ return null;
114
+ });
115
+
116
+ return {
117
+ vMenuTree,
118
+ hMenuMap,
119
+ hMenuFor,
120
+ currentHMenu,
121
+ currentSubHMenu,
122
+ };
123
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * loader.routes.js
3
+ *
4
+ * Discovers all view components under src/frontend/views/
5
+ * and builds Vue Router route entries automatically.
6
+ *
7
+ * Convention:
8
+ * views/AppHome.vue → /
9
+ * views/modules/rh/RhHome.vue → /rh
10
+ * views/modules/rh/employee/EmployeeIndex.vue → /rh/employee
11
+ * views/shared/client/ClientIndex.vue → /shared/client
12
+ *
13
+ * Layout mapping:
14
+ * views/modules/<mod>/... → tries to import <Mod>Layout, falls back to DefaultLayout
15
+ * views/shared/... → DefaultLayout
16
+ *
17
+ * Meta:
18
+ * Each route gets meta.permission from the module/entity name
19
+ * e.g. /rh/employee → 'rh.employee.view'
20
+ */
21
+
22
+ import { createRouter, createWebHashHistory } from 'vue-router';
23
+ import { useAuthStore } from '@/stores/auth.js';
24
+
25
+ // Vite globs — discover all Index and Home views
26
+ const indexViews = import.meta.glob('@/views/modules/*/*/Index.vue'); // not matching — see below
27
+ const moduleViews = import.meta.glob([
28
+ '@/views/modules/**/*Index.vue',
29
+ '@/views/modules/**/*Home.vue',
30
+ '@/views/shared/**/*Index.vue',
31
+ '@/views/AppHome.vue',
32
+ '@/views/LoginView.vue',
33
+ '@/views/AccessDeniedView.vue',
34
+ ]);
35
+
36
+ // Layout map — tries PascalCase module layout, falls back to Default
37
+ const layouts = import.meta.glob('@/layouts/*Layout.vue');
38
+
39
+ async function resolveLayout(moduleName) {
40
+ if (!moduleName) return () => import('@/layouts/DefaultLayout.vue');
41
+ const key = `/src/frontend/layouts/${moduleName.charAt(0).toUpperCase() + moduleName.slice(1)}Layout.vue`;
42
+ return layouts[key] || (() => import('@/layouts/DefaultLayout.vue'));
43
+ }
44
+
45
+ // ─── Build routes ─────────────────────────────────────────────────────────────
46
+
47
+ function pathFromFile(filePath) {
48
+ // e.g. /src/frontend/views/modules/rh/employee/EmployeeIndex.vue → /rh/employee
49
+ const match = filePath.match(/views\/modules\/([^/]+)\/([^/]+)\/\w+Index\.vue$/);
50
+ if (match) return `/${match[1]}/${match[2]}`;
51
+
52
+ const homeMatch = filePath.match(/views\/modules\/([^/]+)\/\w+Home\.vue$/);
53
+ if (homeMatch) return `/${homeMatch[1]}`;
54
+
55
+ const sharedMatch = filePath.match(/views\/shared\/([^/]+)\/\w+Index\.vue$/);
56
+ if (sharedMatch) return `/shared/${sharedMatch[1]}`;
57
+
58
+ if (filePath.includes('AppHome.vue')) return '/';
59
+ if (filePath.includes('LoginView.vue')) return '/login';
60
+ if (filePath.includes('AccessDeniedView.vue')) return '/access-denied';
61
+
62
+ return null;
63
+ }
64
+
65
+ function moduleFromPath(routePath) {
66
+ const parts = routePath.split('/').filter(Boolean);
67
+ return parts[0] || null;
68
+ }
69
+
70
+ function permissionFromPath(routePath) {
71
+ const parts = routePath.split('/').filter(Boolean);
72
+ if (parts.length >= 2) return `${parts[0]}.${parts[1]}.view`;
73
+ if (parts.length === 1) return `${parts[0]}.view`;
74
+ return null;
75
+ }
76
+
77
+ const routes = [
78
+ // Static auth routes always present
79
+ {
80
+ path: '/login',
81
+ component: () => import('@/views/LoginView.vue'),
82
+ meta: { public: true },
83
+ },
84
+ {
85
+ path: '/access-denied',
86
+ component: () => import('@/views/AccessDeniedView.vue'),
87
+ meta: { public: true },
88
+ },
89
+ ];
90
+
91
+ // Dynamically discovered routes
92
+ for (const [filePath, component] of Object.entries(moduleViews)) {
93
+ if (filePath.includes('LoginView') || filePath.includes('AccessDeniedView')) continue;
94
+
95
+ const routePath = pathFromFile(filePath);
96
+ if (!routePath) continue;
97
+
98
+ const mod = moduleFromPath(routePath);
99
+ const permission = permissionFromPath(routePath);
100
+
101
+ routes.push({
102
+ path: routePath,
103
+ component,
104
+ meta: {
105
+ permission,
106
+ module: mod,
107
+ layout: mod,
108
+ },
109
+ });
110
+ }
111
+
112
+ // ─── Create router ────────────────────────────────────────────────────────────
113
+
114
+ const router = createRouter({
115
+ history: createWebHashHistory(),
116
+ routes,
117
+ });
118
+
119
+ // ─── Navigation guard ─────────────────────────────────────────────────────────
120
+
121
+ router.beforeEach((to, _, next) => {
122
+ if (to.meta?.public) return next();
123
+
124
+ const auth = useAuthStore();
125
+
126
+ if (!auth.isAuthenticated) {
127
+ return next({ path: '/login', query: { redirect: to.fullPath } });
128
+ }
129
+
130
+ if (to.meta?.permission && !auth.canAccess(to)) {
131
+ return next('/access-denied');
132
+ }
133
+
134
+ next();
135
+ });
136
+
137
+ export default router;
@@ -0,0 +1,85 @@
1
+ <script setup>
2
+ import { ref, computed } from 'vue';
3
+
4
+ // Widget grid config — edit this to rearrange widgets
5
+ // Each widget: { id, col, row, colSpan, rowSpan, type, title, config }
6
+ const gridConfig = ref([
7
+ { id: 'w1', col: 1, row: 1, colSpan: 2, rowSpan: 1, type: 'placeholder', title: 'Widget 1' },
8
+ { id: 'w2', col: 3, row: 1, colSpan: 1, rowSpan: 1, type: 'placeholder', title: 'Widget 2' },
9
+ { id: 'w3', col: 1, row: 2, colSpan: 1, rowSpan: 1, type: 'placeholder', title: 'Widget 3' },
10
+ { id: 'w4', col: 2, row: 2, colSpan: 2, rowSpan: 1, type: 'placeholder', title: 'Widget 4' },
11
+ ]);
12
+
13
+ const COLS = 3; // grid columns
14
+
15
+ function gridStyle(widget) {
16
+ return {
17
+ gridColumn: `${widget.col} / span ${widget.colSpan}`,
18
+ gridRow: `${widget.row} / span ${widget.rowSpan}`,
19
+ };
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <div class="app-home">
25
+ <div class="app-home__header">
26
+ <h1 class="app-home__title">Dashboard</h1>
27
+ <p class="app-home__sub">Welcome back</p>
28
+ </div>
29
+
30
+ <div class="widget-grid" :style="{ '--grid-cols': COLS }">
31
+ <div
32
+ v-for="widget in gridConfig"
33
+ :key="widget.id"
34
+ class="widget"
35
+ :style="gridStyle(widget)"
36
+ >
37
+ <div class="widget__header">
38
+ <span class="widget__title">{{ widget.title }}</span>
39
+ </div>
40
+ <div class="widget__body">
41
+ <!-- placeholder — replace with real widget components -->
42
+ <div class="widget__placeholder">
43
+ <span>{{ widget.type }}</span>
44
+ <small>Configure in gridConfig</small>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ </template>
51
+
52
+ <style scoped>
53
+ .app-home { padding: 1.5rem; color: #cdd6f4; }
54
+ .app-home__header { margin-bottom: 1.5rem; }
55
+ .app-home__title { font-size: 1.5rem; font-weight: 700; margin: 0; }
56
+ .app-home__sub { color: #6c7086; margin: .25rem 0 0; font-size: .875rem; }
57
+
58
+ .widget-grid {
59
+ display: grid;
60
+ grid-template-columns: repeat(var(--grid-cols, 3), 1fr);
61
+ gap: 1rem;
62
+ }
63
+
64
+ .widget {
65
+ background: #313244;
66
+ border: 1px solid #45475a;
67
+ border-radius: 10px;
68
+ overflow: hidden;
69
+ display: flex;
70
+ flex-direction: column;
71
+ min-height: 180px;
72
+ }
73
+ .widget__header {
74
+ padding: .75rem 1rem;
75
+ border-bottom: 1px solid #45475a;
76
+ display: flex; align-items: center; justify-content: space-between;
77
+ }
78
+ .widget__title { font-weight: 600; font-size: .875rem; color: #cdd6f4; }
79
+ .widget__body { flex: 1; padding: 1rem; }
80
+ .widget__placeholder {
81
+ height: 100%; display: flex; flex-direction: column;
82
+ align-items: center; justify-content: center; gap: .35rem;
83
+ color: #585b70; font-size: .8rem;
84
+ }
85
+ </style>
@@ -0,0 +1,77 @@
1
+ <script setup>
2
+ import { ref } from 'vue';
3
+
4
+ // MODULE_NAME home page
5
+ // Customise gridConfig to add widgets for this module
6
+
7
+ const gridConfig = ref([
8
+ { id: 'w1', col: 1, row: 1, colSpan: 2, rowSpan: 1, type: 'placeholder', title: 'MODULE_NAME Overview' },
9
+ { id: 'w2', col: 3, row: 1, colSpan: 1, rowSpan: 1, type: 'placeholder', title: 'Quick Stats' },
10
+ { id: 'w3', col: 1, row: 2, colSpan: 3, rowSpan: 1, type: 'placeholder', title: 'Recent Activity' },
11
+ ]);
12
+
13
+ const COLS = 3;
14
+
15
+ function gridStyle(w) {
16
+ return {
17
+ gridColumn: `${w.col} / span ${w.colSpan}`,
18
+ gridRow: `${w.row} / span ${w.rowSpan}`,
19
+ };
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <div class="module-home">
25
+ <div class="module-home__header">
26
+ <h1 class="module-home__title">MODULE_LABEL</h1>
27
+ <p class="module-home__sub">Module overview</p>
28
+ </div>
29
+
30
+ <div class="widget-grid" :style="{ '--grid-cols': COLS }">
31
+ <div
32
+ v-for="widget in gridConfig"
33
+ :key="widget.id"
34
+ class="widget"
35
+ :style="gridStyle(widget)"
36
+ >
37
+ <div class="widget__header">
38
+ <span class="widget__title">{{ widget.title }}</span>
39
+ </div>
40
+ <div class="widget__body">
41
+ <div class="widget__placeholder">
42
+ <span>{{ widget.type }}</span>
43
+ <small>Configure in gridConfig</small>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </template>
50
+
51
+ <style scoped>
52
+ .module-home { padding: 1.5rem; color: #cdd6f4; }
53
+ .module-home__header { margin-bottom: 1.5rem; }
54
+ .module-home__title { font-size: 1.4rem; font-weight: 700; margin: 0; }
55
+ .module-home__sub { color: #6c7086; margin: .25rem 0 0; font-size: .875rem; }
56
+ .widget-grid {
57
+ display: grid;
58
+ grid-template-columns: repeat(var(--grid-cols, 3), 1fr);
59
+ gap: 1rem;
60
+ }
61
+ .widget {
62
+ background: #313244; border: 1px solid #45475a;
63
+ border-radius: 10px; overflow: hidden;
64
+ display: flex; flex-direction: column; min-height: 160px;
65
+ }
66
+ .widget__header {
67
+ padding: .75rem 1rem; border-bottom: 1px solid #45475a;
68
+ display: flex; align-items: center;
69
+ }
70
+ .widget__title { font-weight: 600; font-size: .875rem; color: #cdd6f4; }
71
+ .widget__body { flex: 1; padding: 1rem; }
72
+ .widget__placeholder {
73
+ height: 100%; display: flex; flex-direction: column;
74
+ align-items: center; justify-content: center; gap: .35rem;
75
+ color: #585b70; font-size: .8rem;
76
+ }
77
+ </style>