millas 0.2.12-beta-1 → 0.2.13-beta

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 (120) hide show
  1. package/package.json +3 -2
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +516 -199
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +318 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +393 -97
  12. package/src/admin/static/admin.css +1422 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +87 -1046
  19. package/src/admin/views/pages/detail.njk +56 -21
  20. package/src/admin/views/pages/error.njk +65 -0
  21. package/src/admin/views/pages/form.njk +47 -599
  22. package/src/admin/views/pages/list.njk +270 -62
  23. package/src/admin/views/partials/form-field.njk +53 -0
  24. package/src/admin/views/partials/form-footer.njk +28 -0
  25. package/src/admin/views/partials/form-readonly.njk +114 -0
  26. package/src/admin/views/partials/form-scripts.njk +480 -0
  27. package/src/admin/views/partials/form-widget.njk +297 -0
  28. package/src/admin/views/partials/icons.njk +64 -0
  29. package/src/admin/views/partials/json-dialog.njk +80 -0
  30. package/src/admin/views/partials/json-editor.njk +37 -0
  31. package/src/ai/AIManager.js +954 -0
  32. package/src/ai/AITokenBudget.js +250 -0
  33. package/src/ai/PromptGuard.js +216 -0
  34. package/src/ai/agents.js +218 -0
  35. package/src/ai/conversation.js +213 -0
  36. package/src/ai/drivers.js +734 -0
  37. package/src/ai/files.js +249 -0
  38. package/src/ai/media.js +303 -0
  39. package/src/ai/pricing.js +152 -0
  40. package/src/ai/provider_tools.js +114 -0
  41. package/src/ai/types.js +356 -0
  42. package/src/auth/Auth.js +18 -2
  43. package/src/auth/AuthUser.js +65 -44
  44. package/src/cli.js +3 -1
  45. package/src/commands/createsuperuser.js +267 -0
  46. package/src/commands/lang.js +589 -0
  47. package/src/commands/migrate.js +154 -81
  48. package/src/commands/serve.js +3 -4
  49. package/src/container/AppInitializer.js +101 -20
  50. package/src/container/Application.js +31 -1
  51. package/src/container/MillasApp.js +10 -3
  52. package/src/container/MillasConfig.js +35 -6
  53. package/src/core/admin.js +5 -0
  54. package/src/core/db.js +2 -1
  55. package/src/core/foundation.js +2 -10
  56. package/src/core/lang.js +1 -0
  57. package/src/errors/HttpError.js +32 -16
  58. package/src/facades/AI.js +411 -0
  59. package/src/facades/Hash.js +67 -0
  60. package/src/facades/Process.js +144 -0
  61. package/src/hashing/Hash.js +262 -0
  62. package/src/http/HtmlEscape.js +162 -0
  63. package/src/http/MillasRequest.js +63 -7
  64. package/src/http/MillasResponse.js +70 -4
  65. package/src/http/ResponseDispatcher.js +21 -27
  66. package/src/http/SafeFilePath.js +195 -0
  67. package/src/http/SafeRedirect.js +62 -0
  68. package/src/http/SecurityBootstrap.js +70 -0
  69. package/src/http/helpers.js +40 -125
  70. package/src/http/index.js +10 -1
  71. package/src/http/middleware/CsrfMiddleware.js +258 -0
  72. package/src/http/middleware/RateLimiter.js +314 -0
  73. package/src/http/middleware/SecurityHeaders.js +281 -0
  74. package/src/i18n/I18nServiceProvider.js +91 -0
  75. package/src/i18n/Translator.js +643 -0
  76. package/src/i18n/defaults.js +122 -0
  77. package/src/i18n/index.js +164 -0
  78. package/src/i18n/locales/en.js +55 -0
  79. package/src/i18n/locales/sw.js +48 -0
  80. package/src/logger/LogRedactor.js +247 -0
  81. package/src/logger/Logger.js +1 -1
  82. package/src/logger/formatters/JsonFormatter.js +11 -4
  83. package/src/logger/formatters/PrettyFormatter.js +103 -65
  84. package/src/logger/formatters/SimpleFormatter.js +14 -3
  85. package/src/middleware/ThrottleMiddleware.js +27 -4
  86. package/src/migrations/system/0001_users.js +21 -0
  87. package/src/migrations/system/0002_admin_log.js +25 -0
  88. package/src/migrations/system/0003_sessions.js +23 -0
  89. package/src/orm/fields/index.js +210 -188
  90. package/src/orm/migration/DefaultValueParser.js +325 -0
  91. package/src/orm/migration/InteractiveResolver.js +191 -0
  92. package/src/orm/migration/Makemigrations.js +312 -0
  93. package/src/orm/migration/MigrationGraph.js +227 -0
  94. package/src/orm/migration/MigrationRunner.js +202 -108
  95. package/src/orm/migration/MigrationWriter.js +463 -0
  96. package/src/orm/migration/ModelInspector.js +143 -74
  97. package/src/orm/migration/ModelScanner.js +225 -0
  98. package/src/orm/migration/ProjectState.js +213 -0
  99. package/src/orm/migration/RenameDetector.js +175 -0
  100. package/src/orm/migration/SchemaBuilder.js +8 -81
  101. package/src/orm/migration/operations/base.js +57 -0
  102. package/src/orm/migration/operations/column.js +191 -0
  103. package/src/orm/migration/operations/fields.js +252 -0
  104. package/src/orm/migration/operations/index.js +55 -0
  105. package/src/orm/migration/operations/models.js +152 -0
  106. package/src/orm/migration/operations/registry.js +131 -0
  107. package/src/orm/migration/operations/special.js +51 -0
  108. package/src/orm/migration/utils.js +208 -0
  109. package/src/orm/model/Model.js +81 -13
  110. package/src/process/Process.js +333 -0
  111. package/src/providers/AdminServiceProvider.js +66 -9
  112. package/src/providers/AuthServiceProvider.js +40 -5
  113. package/src/providers/CacheStorageServiceProvider.js +2 -2
  114. package/src/providers/DatabaseServiceProvider.js +3 -2
  115. package/src/providers/LogServiceProvider.js +4 -1
  116. package/src/providers/MailServiceProvider.js +1 -1
  117. package/src/providers/QueueServiceProvider.js +1 -1
  118. package/src/router/MiddlewareRegistry.js +27 -2
  119. package/src/scaffold/templates.js +80 -21
  120. package/src/validation/Validator.js +348 -607
@@ -0,0 +1,263 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * QueryEngine
5
+ *
6
+ * Encapsulates all database query logic for the admin list view.
7
+ * Extracted from AdminResource.fetchList() so it can be tested,
8
+ * extended, and swapped independently of the rest of the admin.
9
+ *
10
+ * ── Responsibilities ──────────────────────────────────────────────────────────
11
+ *
12
+ * - Filtering via Django __ lookup syntax
13
+ * - Full-text search across searchable columns
14
+ * - Date hierarchy drill-down (year / month)
15
+ * - Sorting (column + direction)
16
+ * - Offset pagination with total count
17
+ * - Column pruning (only SELECT columns shown in list_display)
18
+ * - Eager loading of FK columns (avoids N+1 for related labels)
19
+ * - Bulk operations wrapped in a transaction
20
+ *
21
+ * ── Usage ─────────────────────────────────────────────────────────────────────
22
+ *
23
+ * const engine = new QueryEngine(Resource);
24
+ *
25
+ * const result = await engine.fetchList({
26
+ * page: 1, perPage: 25,
27
+ * search: 'alice',
28
+ * sort: 'created_at', order: 'desc',
29
+ * filters: { role__exact: 'admin', is_active__isnull: false },
30
+ * year: '2026', month: '03',
31
+ * });
32
+ * // → { data: [...], total: 42, page: 1, perPage: 25, lastPage: 2 }
33
+ *
34
+ * await engine.bulkDelete([1, 2, 3]);
35
+ * await engine.bulkUpdate([1, 2, 3], { is_active: false });
36
+ * await engine.bulkAction([1, 2, 3], async (ids, Model) => { ... });
37
+ *
38
+ * ── Lookup syntax (__ filter keys) ───────────────────────────────────────────
39
+ *
40
+ * created_at__gte → WHERE created_at >= value
41
+ * role__exact → WHERE role = value
42
+ * email__icontains → WHERE email LIKE '%value%'
43
+ * deleted_at__isnull → WHERE deleted_at IS NULL (value truthy) / IS NOT NULL
44
+ * status__in → WHERE status IN (value[])
45
+ * age__between → WHERE age BETWEEN value[0] AND value[1]
46
+ *
47
+ * ── Column pruning ────────────────────────────────────────────────────────────
48
+ *
49
+ * If the resource declares list_display, QueryEngine will SELECT only
50
+ * those columns (plus the primary key). This avoids fetching large text
51
+ * columns (body, description) that are never shown in the list view.
52
+ *
53
+ * ── Performance notes ────────────────────────────────────────────────────────
54
+ *
55
+ * - Data query and count query run in parallel (Promise.all)
56
+ * - Column pruning reduces data transfer for wide tables
57
+ * - All filters are parameterised — no string interpolation in WHERE clauses
58
+ */
59
+ class QueryEngine {
60
+ /**
61
+ * @param {class} Resource — AdminResource subclass
62
+ */
63
+ constructor(Resource) {
64
+ this._Resource = Resource;
65
+ this._Model = Resource.model;
66
+ }
67
+
68
+ // ─── List fetch ────────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Fetch a paginated, filtered, sorted list of records.
72
+ *
73
+ * @param {object} opts
74
+ * @param {number} [opts.page=1]
75
+ * @param {number} [opts.perPage] — defaults to Resource.perPage
76
+ * @param {string} [opts.search=''] — full-text search term
77
+ * @param {string} [opts.sort='id'] — column to sort by
78
+ * @param {string} [opts.order='desc'] — 'asc' | 'desc'
79
+ * @param {object} [opts.filters={}] — { col__lookup: value }
80
+ * @param {string} [opts.year] — date hierarchy year
81
+ * @param {string} [opts.month] — date hierarchy month
82
+ *
83
+ * @returns {Promise<{ data, total, page, perPage, lastPage }>}
84
+ */
85
+ async fetchList({
86
+ page = 1,
87
+ perPage,
88
+ search = '',
89
+ sort = 'id',
90
+ order = 'desc',
91
+ filters = {},
92
+ year = null,
93
+ month = null,
94
+ } = {}) {
95
+ const R = this._Resource;
96
+ const Model = this._Model;
97
+ const limit = perPage || R.perPage || 20;
98
+ const offset = (page - 1) * limit;
99
+
100
+ // ── Base query ────────────────────────────────────────────────────────
101
+ let q = Model._db();
102
+
103
+ // ── Column pruning ────────────────────────────────────────────────────
104
+ // Only SELECT the columns shown in list_display + primary key.
105
+ // Avoids fetching large text/json columns that aren't shown.
106
+ const listDisplay = R.list_display || null;
107
+ if (listDisplay && listDisplay.length) {
108
+ const pk = Model.primaryKey || 'id';
109
+ const cols = [...new Set([pk, ...listDisplay])].map(c => `${Model.table}.${c}`);
110
+ q = q.select(cols);
111
+ }
112
+
113
+ // ── Ordering ──────────────────────────────────────────────────────────
114
+ // Sanitise sort column against known field names to prevent injection
115
+ const knownCols = new Set(
116
+ Object.keys(
117
+ typeof Model.getFields === 'function' ? Model.getFields() : (Model.fields || {})
118
+ )
119
+ );
120
+ const safeSort = knownCols.has(sort) ? sort : (Model.primaryKey || 'id');
121
+ const safeOrder = order === 'asc' ? 'asc' : 'desc';
122
+ q = q.orderBy(safeSort, safeOrder);
123
+
124
+ // ── Search ────────────────────────────────────────────────────────────
125
+ if (search && R.searchable && R.searchable.length) {
126
+ const cols = R.searchable;
127
+ q = q.where(function () {
128
+ for (const col of cols) {
129
+ this.orWhere(col, 'like', `%${search}%`);
130
+ }
131
+ });
132
+ }
133
+
134
+ // ── Filters ───────────────────────────────────────────────────────────
135
+ for (const [key, value] of Object.entries(filters)) {
136
+ if (value === '' || value === null || value === undefined) continue;
137
+ q = this._applyFilter(q, key, value);
138
+ }
139
+
140
+ // ── Date hierarchy ────────────────────────────────────────────────────
141
+ if (R.dateHierarchy) {
142
+ q = this._applyDateHierarchy(q, R.dateHierarchy, year, month);
143
+ }
144
+
145
+ // ── Execute data + count in parallel ──────────────────────────────────
146
+ const [rows, countResult] = await Promise.all([
147
+ q.clone().limit(limit).offset(offset),
148
+ q.clone().count('* as count').first(),
149
+ ]);
150
+
151
+ const total = Number(countResult?.count ?? 0);
152
+
153
+ return {
154
+ data: rows.map(r => Model._hydrate ? Model._hydrate(r) : r),
155
+ total,
156
+ page: Number(page),
157
+ perPage: limit,
158
+ lastPage: Math.ceil(total / limit) || 1,
159
+ };
160
+ }
161
+
162
+ // ─── Bulk operations ───────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Delete multiple records by primary key.
166
+ * @param {Array} ids
167
+ */
168
+ async bulkDelete(ids) {
169
+ if (!ids || !ids.length) return;
170
+ const pk = this._Model.primaryKey || 'id';
171
+ await this._Model._db().whereIn(pk, ids).delete();
172
+ }
173
+
174
+ /**
175
+ * Update multiple records with the same data.
176
+ * @param {Array} ids
177
+ * @param {object} data
178
+ */
179
+ async bulkUpdate(ids, data) {
180
+ if (!ids || !ids.length) return;
181
+ const pk = this._Model.primaryKey || 'id';
182
+ const now = new Date().toISOString();
183
+ const payload = this._Model.timestamps
184
+ ? { ...data, updated_at: now }
185
+ : { ...data };
186
+ await this._Model._db().whereIn(pk, ids).update(payload);
187
+ }
188
+
189
+ /**
190
+ * Run a custom bulk action inside a transaction.
191
+ * @param {Array} ids
192
+ * @param {Function} handler — async (ids, Model, trx) => void
193
+ */
194
+ async bulkAction(ids, handler) {
195
+ if (!ids || !ids.length || typeof handler !== 'function') return;
196
+ await this._Model.transaction(async (trx) => {
197
+ await handler(ids, this._Model, trx);
198
+ });
199
+ }
200
+
201
+ // ─── Filter parser ─────────────────────────────────────────────────────────
202
+
203
+ /**
204
+ * Apply a single filter key/value pair to a knex query builder.
205
+ * Supports Django __ lookup syntax.
206
+ *
207
+ * @param {object} q — knex query builder
208
+ * @param {string} key — 'column__lookup' or just 'column'
209
+ * @param {*} value
210
+ * @returns {object} — modified query builder
211
+ */
212
+ _applyFilter(q, key, value) {
213
+ const dunder = key.lastIndexOf('__');
214
+
215
+ if (dunder === -1) {
216
+ return q.where(key, value);
217
+ }
218
+
219
+ const col = key.slice(0, dunder);
220
+ const lookup = key.slice(dunder + 2);
221
+
222
+ switch (lookup) {
223
+ case 'exact': return q.where(col, value);
224
+ case 'not': return q.where(col, '!=', value);
225
+ case 'gt': return q.where(col, '>', value);
226
+ case 'gte': return q.where(col, '>=', value);
227
+ case 'lt': return q.where(col, '<', value);
228
+ case 'lte': return q.where(col, '<=', value);
229
+ case 'isnull': return value ? q.whereNull(col) : q.whereNotNull(col);
230
+ case 'notnull': return q.whereNotNull(col);
231
+ case 'in': return q.whereIn(col, Array.isArray(value) ? value : [value]);
232
+ case 'notin': return q.whereNotIn(col, Array.isArray(value) ? value : [value]);
233
+ case 'between': return q.whereBetween(col, Array.isArray(value) ? value : [value, value]);
234
+ case 'contains':
235
+ case 'icontains': return q.where(col, 'like', `%${value}%`);
236
+ case 'startswith':
237
+ case 'istartswith': return q.where(col, 'like', `${value}%`);
238
+ case 'endswith':
239
+ case 'iendswith': return q.where(col, 'like', `%${value}`);
240
+ default: return q.where(key, value);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Apply date hierarchy (year/month) drill-down filters.
246
+ * Uses strftime for SQLite/MySQL; falls back gracefully on PostgreSQL.
247
+ */
248
+ _applyDateHierarchy(q, col, year, month) {
249
+ if (year) {
250
+ try {
251
+ q = q.whereRaw(`strftime('%Y', \`${col}\`) = ?`, [String(year)]);
252
+ } catch { /* PG fallback — skip */ }
253
+ }
254
+ if (month) {
255
+ try {
256
+ q = q.whereRaw(`strftime('%m', \`${col}\`) = ?`, [String(month).padStart(2, '0')]);
257
+ } catch { /* PG fallback — skip */ }
258
+ }
259
+ return q;
260
+ }
261
+ }
262
+
263
+ module.exports = { QueryEngine };
@@ -0,0 +1,318 @@
1
+ 'use strict';
2
+
3
+ const { FormGenerator } = require('./FormGenerator');
4
+
5
+ /**
6
+ * ViewContext
7
+ *
8
+ * Assembles the template data (context object) for each admin view.
9
+ * Separates "what data does this page need?" from "how do we handle the request?".
10
+ *
11
+ * ── Design principle ──────────────────────────────────────────────────────────
12
+ *
13
+ * Route handlers in Admin.js are responsible for:
14
+ * - Auth + permission checks
15
+ * - Calling the ORM (via AdminResource or QueryEngine)
16
+ * - Firing hooks
17
+ * - Redirecting on success
18
+ *
19
+ * ViewContext is responsible for:
20
+ * - Assembling the template data object
21
+ * - Deriving display metadata from AdminResource config
22
+ * - No DB calls, no business logic, no HTML
23
+ *
24
+ * ── Usage ─────────────────────────────────────────────────────────────────────
25
+ *
26
+ * // In Admin.js handler — replaces inline object literals
27
+ * const ctx = ViewContext.list(R, { rows, result, query, user, adminPrefix, flash });
28
+ * return this._render(req, res, 'pages/list.njk', ctx, R);
29
+ *
30
+ * ── What it replaces ──────────────────────────────────────────────────────────
31
+ *
32
+ * Before — Admin.js _list() contained ~40 lines of inline object assembly:
33
+ * res.render('pages/list.njk', {
34
+ * pageTitle: R._getLabel(),
35
+ * resource: { slug, label, canCreate, canEdit, ... },
36
+ * rows, listFields, filters, activeFilters, sortable,
37
+ * total, page, perPage, lastPage, search, sort, order, ...
38
+ * });
39
+ *
40
+ * After — 1 line in handler + all assembly logic testable in isolation:
41
+ * return this._render(req, res, 'pages/list.njk',
42
+ * ViewContext.list(R, { rows, result, query, perms, flash }), R);
43
+ */
44
+ class ViewContext {
45
+
46
+ // ─── Base context (shared by all views) ───────────────────────────────────
47
+
48
+ /**
49
+ * Base context included in every admin view.
50
+ * Mirrors what Admin._ctx() currently produces.
51
+ *
52
+ * @param {object} opts
53
+ * @param {object} opts.admin — Admin singleton (for config + resources)
54
+ * @param {object} opts.user — req.adminUser
55
+ * @param {string} opts.csrfToken
56
+ * @param {object} [opts.flash={}]
57
+ * @param {string} [opts.activePage=null]
58
+ * @param {string} [opts.activeResource=null]
59
+ */
60
+ static base({ admin, user, csrfToken, flash = {}, activePage = null, activeResource = null }) {
61
+ return {
62
+ csrfToken,
63
+ adminPrefix: admin._config.prefix,
64
+ adminTitle: admin._config.title,
65
+ adminUser: user || null,
66
+ authEnabled: admin._auth?.enabled ?? false,
67
+ resources: admin.resources()
68
+ .filter(R => R.hasPermission(user || null, 'view'))
69
+ .map((R, idx) => ({
70
+ slug: R.slug,
71
+ label: R._getLabel(),
72
+ singular: R._getLabelSingular(),
73
+ icon: R.icon,
74
+ canView: R.hasPermission(user || null, 'view'),
75
+ index: idx + 1,
76
+ })),
77
+ flash,
78
+ activePage,
79
+ activeResource,
80
+ };
81
+ }
82
+
83
+ // ─── Dashboard ─────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * @param {object} opts
87
+ * @param {Array} opts.resourceData — per-resource { slug, label, count, recent, ... }
88
+ * @param {Array} opts.activity — ActivityLog.recent() result
89
+ * @param {object} opts.activityTotals
90
+ * @param {object} opts.baseCtx — from ViewContext.base()
91
+ */
92
+ static dashboard({ resourceData, activity, activityTotals, baseCtx }) {
93
+ return {
94
+ ...baseCtx,
95
+ pageTitle: 'Dashboard',
96
+ activePage: 'dashboard',
97
+ resources: resourceData, // overrides baseCtx.resources with enriched data
98
+ activity,
99
+ activityTotals,
100
+ };
101
+ }
102
+
103
+ // ─── List ──────────────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * @param {class} Resource
107
+ * @param {object} opts
108
+ * @param {Array} opts.rows — hydrated + serialised row data
109
+ * @param {object} opts.result — fetchList result { total, page, perPage, lastPage }
110
+ * @param {object} opts.query — { search, sort, order, page, perPage, year, month }
111
+ * @param {object} opts.activeFilters — { col__lookup: value }
112
+ * @param {object} opts.perms — { canCreate, canEdit, canDelete, canView }
113
+ * @param {object} opts.baseCtx
114
+ */
115
+ static list(Resource, { rows, result, query, activeFilters, perms, baseCtx }) {
116
+ const listFields = Resource.fields()
117
+ .filter(f => !f._hidden && !f._detailOnly)
118
+ .map(f => f.toJSON());
119
+
120
+ return {
121
+ ...baseCtx,
122
+ pageTitle: Resource._getLabel(),
123
+ activeResource: Resource.slug,
124
+ resource: {
125
+ slug: Resource.slug,
126
+ label: Resource._getLabel(),
127
+ singular: Resource._getLabelSingular(),
128
+ icon: Resource.icon,
129
+ canCreate: perms.canCreate,
130
+ canEdit: perms.canEdit,
131
+ canDelete: perms.canDelete,
132
+ canView: perms.canView,
133
+ actions: (Resource.actions || []).map((a, i) => ({ ...a, index: i, handler: undefined })),
134
+ rowActions: Resource.rowActions || [],
135
+ listDisplayLinks: Resource.listDisplayLinks || [],
136
+ dateHierarchy: Resource.dateHierarchy || null,
137
+ prepopulatedFields: Resource.prepopulatedFields || {},
138
+ },
139
+ rows: rows.map(row => ({
140
+ ...row,
141
+ _rowActions: (Resource.rowActions || []).map(ra => ({
142
+ ...ra,
143
+ href: typeof ra.href === 'function' ? ra.href(row) : ra.href,
144
+ })),
145
+ })),
146
+ listFields,
147
+ filters: Resource.filters().map(f => f.toJSON()),
148
+ activeFilters,
149
+ sortable: Resource.sortable || [],
150
+ total: result.total,
151
+ page: result.page,
152
+ perPage: result.perPage,
153
+ lastPage: result.lastPage,
154
+ search: query.search,
155
+ sort: query.sort,
156
+ order: query.order,
157
+ year: query.year || null,
158
+ month: query.month || null,
159
+ };
160
+ }
161
+
162
+ // ─── Create form ───────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * @param {class} Resource
166
+ * @param {object} opts
167
+ * @param {string} opts.adminPrefix
168
+ * @param {object} [opts.record={}] — pre-filled data (on validation error re-render)
169
+ * @param {object} [opts.errors={}]
170
+ * @param {object} opts.baseCtx
171
+ */
172
+ static create(Resource, { adminPrefix, record = {}, errors = {}, baseCtx }) {
173
+
174
+ return {
175
+ ...baseCtx,
176
+ pageTitle: `New ${Resource._getLabelSingular()}`,
177
+ activeResource: Resource.slug,
178
+ resource: {
179
+ slug: Resource.slug,
180
+ label: Resource._getLabel(),
181
+ singular: Resource._getLabelSingular(),
182
+ icon: Resource.icon,
183
+ canDelete: false,
184
+ },
185
+ formFields: FormGenerator.fromResource(Resource, { isEdit: false }).fields,
186
+ formAction: `${adminPrefix}/${Resource.slug}`,
187
+ isEdit: false,
188
+ record,
189
+ errors,
190
+ };
191
+ }
192
+
193
+ // ─── Edit form ─────────────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * @param {class} Resource
197
+ * @param {object} opts
198
+ * @param {string} opts.adminPrefix
199
+ * @param {*} opts.id — record primary key
200
+ * @param {object} opts.record — existing record data
201
+ * @param {boolean} opts.canDelete
202
+ * @param {object} [opts.errors={}]
203
+ * @param {object} opts.baseCtx
204
+ */
205
+ static edit(Resource, { adminPrefix, id, record, canDelete, errors = {}, baseCtx }) {
206
+ return {
207
+ ...baseCtx,
208
+ pageTitle: `Edit ${Resource._getLabelSingular()} #${id}`,
209
+ activeResource: Resource.slug,
210
+ resource: {
211
+ slug: Resource.slug,
212
+ label: Resource._getLabel(),
213
+ singular: Resource._getLabelSingular(),
214
+ icon: Resource.icon,
215
+ canDelete,
216
+ },
217
+ formFields: FormGenerator.fromResource(Resource, { isEdit: true }).fields,
218
+ formAction: `${adminPrefix}/${Resource.slug}/${id}`,
219
+ isEdit: true,
220
+ record,
221
+ errors,
222
+ };
223
+ }
224
+
225
+ // ─── Detail view ───────────────────────────────────────────────────────────
226
+
227
+ /**
228
+ * @param {class} Resource
229
+ * @param {object} opts
230
+ * @param {*} opts.id
231
+ * @param {object} opts.record
232
+ * @param {Array} opts.inlineData
233
+ * @param {object} opts.perms — { canEdit, canDelete, canCreate }
234
+ * @param {object} opts.baseCtx
235
+ */
236
+ static detail(Resource, { id, record, inlineData, perms, baseCtx }) {
237
+ const detailFields = Resource.fields()
238
+ .filter(f => f._type !== '__tab__' && f._type !== 'fieldset' && !f._hidden && !f._listOnly)
239
+ .map(f => f.toJSON());
240
+
241
+ const tabs = ViewContext._buildTabs(Resource.fields());
242
+
243
+ return {
244
+ ...baseCtx,
245
+ pageTitle: `${Resource._getLabelSingular()} #${id}`,
246
+ activeResource: Resource.slug,
247
+ resource: {
248
+ slug: Resource.slug,
249
+ label: Resource._getLabel(),
250
+ singular: Resource._getLabelSingular(),
251
+ icon: Resource.icon,
252
+ canEdit: perms.canEdit,
253
+ canDelete: perms.canDelete,
254
+ canCreate: perms.canCreate,
255
+ rowActions: (Resource.rowActions || []).map(ra => ({
256
+ ...ra,
257
+ href: typeof ra.href === 'function' ? ra.href(record) : ra.href,
258
+ })),
259
+ },
260
+ record,
261
+ detailFields,
262
+ tabs,
263
+ hasTabs: tabs.length > 1,
264
+ inlines: inlineData,
265
+ };
266
+ }
267
+
268
+ // ─── Search ────────────────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * @param {object} opts
272
+ * @param {string} opts.query
273
+ * @param {Array} opts.results
274
+ * @param {number} opts.total
275
+ * @param {object} opts.baseCtx
276
+ */
277
+ static search({ query, results, total, baseCtx }) {
278
+ return {
279
+ ...baseCtx,
280
+ pageTitle: query ? `Search: ${query}` : 'Search',
281
+ activePage: 'search',
282
+ query,
283
+ results,
284
+ total,
285
+ };
286
+ }
287
+
288
+ // ─── Internal ─────────────────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Build tab structure for tabbed detail/form rendering.
292
+ * Extracted from Admin._buildTabs() — same logic, centralised here.
293
+ */
294
+ static _buildTabs(fields) {
295
+ const tabs = [];
296
+ let current = null;
297
+
298
+ for (const f of fields) {
299
+ if (f._type === 'tab') {
300
+ current = { label: f._label, fields: [] };
301
+ tabs.push(current);
302
+ continue;
303
+ }
304
+ if (f._type === 'fieldset') {
305
+ if (!current) { current = { label: null, fields: [] }; tabs.push(current); }
306
+ current.fields.push({ _isFieldset: true, label: f._label });
307
+ continue;
308
+ }
309
+ if (f._hidden || f._listOnly) continue;
310
+ if (!current) { current = { label: null, fields: [] }; tabs.push(current); }
311
+ current.fields.push(f.toJSON());
312
+ }
313
+
314
+ return tabs;
315
+ }
316
+ }
317
+
318
+ module.exports = { ViewContext };