@lotics/cli 0.192.2 → 0.194.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/dist/src/cli.js +904 -100
- package/docs/building_an_app.md +7 -0
- package/docs/cli_reference.md +1 -1
- package/package.json +2 -1
package/dist/src/cli.js
CHANGED
|
@@ -46048,7 +46048,7 @@ function resultSideEffects(result) {
|
|
|
46048
46048
|
}
|
|
46049
46049
|
|
|
46050
46050
|
// src/version.ts
|
|
46051
|
-
var VERSION = "0.
|
|
46051
|
+
var VERSION = "0.194.0";
|
|
46052
46052
|
|
|
46053
46053
|
// src/timezone.ts
|
|
46054
46054
|
function machineTimezone() {
|
|
@@ -67730,6 +67730,7 @@ var fieldRoleSchema = zod_default.enum([
|
|
|
67730
67730
|
"amount",
|
|
67731
67731
|
"when",
|
|
67732
67732
|
"party",
|
|
67733
|
+
"parent",
|
|
67733
67734
|
"contact",
|
|
67734
67735
|
"verdict"
|
|
67735
67736
|
]);
|
|
@@ -67742,6 +67743,7 @@ var FIELD_ROLE_TYPES = {
|
|
|
67742
67743
|
amount: ["number", "formula", "rollup"],
|
|
67743
67744
|
when: ["date"],
|
|
67744
67745
|
party: ["select_record_link"],
|
|
67746
|
+
parent: ["select_record_link"],
|
|
67745
67747
|
contact: ["text"],
|
|
67746
67748
|
verdict: ["boolean", "formula"]
|
|
67747
67749
|
};
|
|
@@ -67752,6 +67754,7 @@ var SINGLE_FIELD_ROLES = [
|
|
|
67752
67754
|
"amount",
|
|
67753
67755
|
"when",
|
|
67754
67756
|
"party",
|
|
67757
|
+
"parent",
|
|
67755
67758
|
"contact",
|
|
67756
67759
|
"verdict"
|
|
67757
67760
|
];
|
|
@@ -67843,7 +67846,7 @@ var slot = (role, required2 = false) => ({ role, required: required2 });
|
|
|
67843
67846
|
var SHAPE_REGISTRY = {
|
|
67844
67847
|
lifecycle_desk: {
|
|
67845
67848
|
label: "lifecycle desk",
|
|
67846
|
-
slots: { stage: slot("lifecycle", true), identity: slot("identity", true), party: slot("party"), amount: slot("amount"), when: slot("when") },
|
|
67849
|
+
slots: { stage: slot("lifecycle", true), identity: slot("identity", true), mark: slot("mark"), party: slot("party"), amount: slot("amount"), when: slot("when") },
|
|
67847
67850
|
record: "drawer",
|
|
67848
67851
|
tabs: "lifecycle"
|
|
67849
67852
|
},
|
|
@@ -67867,7 +67870,7 @@ var SHAPE_REGISTRY = {
|
|
|
67867
67870
|
},
|
|
67868
67871
|
monitored_asset_set: {
|
|
67869
67872
|
label: "monitored-asset set",
|
|
67870
|
-
slots: { identity: slot("identity", true), level: slot("measure", true), stage: slot("lifecycle") },
|
|
67873
|
+
slots: { identity: slot("identity", true), mark: slot("mark"), level: slot("measure", true), stage: slot("lifecycle") },
|
|
67871
67874
|
record: "drawer",
|
|
67872
67875
|
tabs: "none"
|
|
67873
67876
|
},
|
|
@@ -67988,6 +67991,96 @@ function resolveScreen(app, screen, entityByAlias, roles) {
|
|
|
67988
67991
|
if (findings.length > 0) return { type: "invalid", findings };
|
|
67989
67992
|
return { type: "resolved", screen: { app, screen, entity, shapeLabel, record: record2, tabs, slots } };
|
|
67990
67993
|
}
|
|
67994
|
+
var CHILD_COLUMN_ROLES = [
|
|
67995
|
+
"identity",
|
|
67996
|
+
"mark",
|
|
67997
|
+
"party",
|
|
67998
|
+
"contact",
|
|
67999
|
+
"when",
|
|
68000
|
+
"expected_set",
|
|
68001
|
+
"measure",
|
|
68002
|
+
"amount",
|
|
68003
|
+
"lifecycle",
|
|
68004
|
+
"verdict"
|
|
68005
|
+
];
|
|
68006
|
+
function parentLink(child, entity, roles) {
|
|
68007
|
+
return child.fields.find(
|
|
68008
|
+
(field) => field.type === "select_record_link" && field.target_entity === entity.alias && roleOf(roles, child.alias, field.alias)?.role === "parent"
|
|
68009
|
+
);
|
|
68010
|
+
}
|
|
68011
|
+
function fieldsWithRole(entity, roles, role) {
|
|
68012
|
+
return entity.fields.filter((field) => roleOf(roles, entity.alias, field.alias)?.role === role);
|
|
68013
|
+
}
|
|
68014
|
+
function fileFields(entity, roles) {
|
|
68015
|
+
const files = entity.fields.filter((field) => field.type === "files");
|
|
68016
|
+
const mark = fieldsWithRole(entity, roles, "mark")[0];
|
|
68017
|
+
return mark === void 0 ? files : [mark, ...files.filter((field) => field.alias !== mark.alias)];
|
|
68018
|
+
}
|
|
68019
|
+
function recordHeader(screen) {
|
|
68020
|
+
const bound = (role) => screen.slots.find((entry) => entry.role === role && entry.field !== null)?.field ?? void 0;
|
|
68021
|
+
const title = bound("identity");
|
|
68022
|
+
if (screen.record === "drawer") return { title };
|
|
68023
|
+
return {
|
|
68024
|
+
title,
|
|
68025
|
+
subtitle: bound("when"),
|
|
68026
|
+
// Which number is THE figure is the screen's choice among the ones it
|
|
68027
|
+
// reads, so it comes off the slots — an amount the shape has no slot for
|
|
68028
|
+
// is not on the header and stays a fact.
|
|
68029
|
+
figure: bound("amount") ?? bound("measure")
|
|
68030
|
+
};
|
|
68031
|
+
}
|
|
68032
|
+
function recordSections(entity, entities, roles, header) {
|
|
68033
|
+
const lifecycle = fieldsWithRole(entity, roles, "lifecycle")[0];
|
|
68034
|
+
const ownSets = fieldsWithRole(entity, roles, "expected_set").filter(
|
|
68035
|
+
(field) => field.type === "select" && field.multi === true
|
|
68036
|
+
);
|
|
68037
|
+
const files = fileFields(entity, roles);
|
|
68038
|
+
const owned = new Set(
|
|
68039
|
+
[
|
|
68040
|
+
...lifecycle === void 0 ? [] : [lifecycle],
|
|
68041
|
+
...ownSets,
|
|
68042
|
+
...files,
|
|
68043
|
+
...header.title === void 0 ? [] : [header.title],
|
|
68044
|
+
...header.figure === void 0 ? [] : [header.figure]
|
|
68045
|
+
].map((field) => field.alias)
|
|
68046
|
+
);
|
|
68047
|
+
const facts = entity.fields.filter((field) => !owned.has(field.alias));
|
|
68048
|
+
const levels = {};
|
|
68049
|
+
for (const field of facts) {
|
|
68050
|
+
const decl = roleOf(roles, entity.alias, field.alias);
|
|
68051
|
+
if (decl?.role !== "measure" || decl.alert === void 0) continue;
|
|
68052
|
+
if (typeof decl.against === "number") {
|
|
68053
|
+
levels[field.alias] = { limit: decl.against, alert: decl.alert };
|
|
68054
|
+
continue;
|
|
68055
|
+
}
|
|
68056
|
+
const limit = entity.fields.find((candidate) => candidate.alias === decl.against);
|
|
68057
|
+
if (limit !== void 0) levels[field.alias] = { limit, alert: decl.alert };
|
|
68058
|
+
}
|
|
68059
|
+
const folded = new Set(
|
|
68060
|
+
Object.values(levels).flatMap((level) => typeof level.limit === "number" ? [] : [level.limit.alias])
|
|
68061
|
+
);
|
|
68062
|
+
const shown = facts.filter((field) => !folded.has(field.alias));
|
|
68063
|
+
const sets = ownSets.map((field) => ({ kind: "expected_set", source: "own", field }));
|
|
68064
|
+
const children = [];
|
|
68065
|
+
for (const child of entities) {
|
|
68066
|
+
const parentField = parentLink(child, entity, roles);
|
|
68067
|
+
if (parentField === void 0) continue;
|
|
68068
|
+
const setField = fieldsWithRole(child, roles, "expected_set")[0];
|
|
68069
|
+
if (setField !== void 0) {
|
|
68070
|
+
sets.push({ kind: "expected_set", source: "child", child, parentField, setField, filesField: fileFields(child, roles)[0] });
|
|
68071
|
+
continue;
|
|
68072
|
+
}
|
|
68073
|
+
const byRole = CHILD_COLUMN_ROLES.flatMap((role) => fieldsWithRole(child, roles, role));
|
|
68074
|
+
children.push({ kind: "children", child, parentField, fields: byRole });
|
|
68075
|
+
}
|
|
68076
|
+
return [
|
|
68077
|
+
...shown.length > 0 ? [{ kind: "facts", fields: shown, levels }] : [],
|
|
68078
|
+
...lifecycle === void 0 ? [] : [{ kind: "progress", field: lifecycle }],
|
|
68079
|
+
...sets,
|
|
68080
|
+
...children,
|
|
68081
|
+
...files.length > 0 ? [{ kind: "files", fields: files }] : []
|
|
68082
|
+
];
|
|
68083
|
+
}
|
|
67991
68084
|
function validateWorkspacePlan(model, roles, apps) {
|
|
67992
68085
|
const findings = [];
|
|
67993
68086
|
for (const dup of findDuplicates(apps.map((app) => app.alias))) {
|
|
@@ -68068,28 +68161,83 @@ function roleCoverage(model, roles, rows) {
|
|
|
68068
68161
|
}
|
|
68069
68162
|
}
|
|
68070
68163
|
}
|
|
68164
|
+
for (const entity of model.entities) {
|
|
68165
|
+
for (const section of recordSections(entity, model.entities, roles, {})) {
|
|
68166
|
+
if (section.kind === "children" && fieldsWithRole(section.child, roles, "identity").length === 0) {
|
|
68167
|
+
notes.push({
|
|
68168
|
+
path: `field_roles.${section.child.alias}`,
|
|
68169
|
+
message: `${section.child.label} hangs under ${entity.label} and declares no identity \u2014 its rows on the record have no name`
|
|
68170
|
+
});
|
|
68171
|
+
}
|
|
68172
|
+
if (section.kind === "expected_set" && section.source === "child" && section.filesField === void 0) {
|
|
68173
|
+
notes.push({
|
|
68174
|
+
path: `field_roles.${section.child.alias}.${section.setField.alias}`,
|
|
68175
|
+
message: `${section.child.label} is the required set of ${entity.label} and carries no files field \u2014 the desk names what is missing with nothing to attach`
|
|
68176
|
+
});
|
|
68177
|
+
}
|
|
68178
|
+
}
|
|
68179
|
+
}
|
|
68071
68180
|
return notes;
|
|
68072
68181
|
}
|
|
68073
68182
|
|
|
68074
68183
|
// src/plan_starter.ts
|
|
68075
|
-
function
|
|
68184
|
+
function sectionChild(section) {
|
|
68185
|
+
if (section.kind === "children") return section.child;
|
|
68186
|
+
if (section.kind === "expected_set" && section.source === "child") return section.child;
|
|
68187
|
+
return void 0;
|
|
68188
|
+
}
|
|
68189
|
+
var DRAWN_ROLES = [
|
|
68190
|
+
"identity",
|
|
68191
|
+
"amount",
|
|
68192
|
+
"measure",
|
|
68193
|
+
"lifecycle",
|
|
68194
|
+
"when",
|
|
68195
|
+
"expected_set",
|
|
68196
|
+
"party",
|
|
68197
|
+
"contact",
|
|
68198
|
+
"verdict"
|
|
68199
|
+
];
|
|
68200
|
+
var columnPriority = (role) => DRAWN_ROLES.indexOf(role) + 1;
|
|
68201
|
+
var CHILD_COLUMNS_SHOWN = 5;
|
|
68202
|
+
function bindTable(at2, entity, wanted, aliased, live, missing) {
|
|
68203
|
+
const tables = aliased.filter((candidate) => candidate.table.name === entity.label);
|
|
68204
|
+
if (tables.length !== 1) {
|
|
68205
|
+
missing.push(
|
|
68206
|
+
tables.length === 0 ? `${at2}: no table is labelled "${entity.label}" (entity "${entity.alias}")` : `${at2}: ${tables.length} tables are labelled "${entity.label}" (${tables.map((candidate) => candidate.table.id).join(", ")}) \u2014 a label binds one`
|
|
68207
|
+
);
|
|
68208
|
+
return void 0;
|
|
68209
|
+
}
|
|
68210
|
+
const table = tables[0];
|
|
68211
|
+
const liveType = new Map(live.find((candidate) => candidate.id === table.table.id)?.fields.map((field) => [field.id, field.type]) ?? []);
|
|
68212
|
+
const fields = /* @__PURE__ */ new Map();
|
|
68213
|
+
const seen = /* @__PURE__ */ new Set();
|
|
68214
|
+
for (const field of wanted) {
|
|
68215
|
+
if (seen.has(field.alias)) continue;
|
|
68216
|
+
seen.add(field.alias);
|
|
68217
|
+
const found = table.fields.filter((candidate) => candidate.field.name === field.label);
|
|
68218
|
+
if (found.length !== 1) {
|
|
68219
|
+
missing.push(
|
|
68220
|
+
found.length === 0 ? `${at2}: table "${table.table.name}" has no field labelled "${field.label}" (field "${field.alias}")` : `${at2}: table "${table.table.name}" has ${found.length} fields labelled "${field.label}" (${found.map((candidate) => candidate.field.id).join(", ")}) \u2014 a label binds one`
|
|
68221
|
+
);
|
|
68222
|
+
continue;
|
|
68223
|
+
}
|
|
68224
|
+
const type = liveType.get(found[0].field.id);
|
|
68225
|
+
if (type !== field.type) {
|
|
68226
|
+
missing.push(`${at2}: field "${field.label}" is a ${type ?? "field of unknown type"} in this workspace and a ${field.type} in the model`);
|
|
68227
|
+
continue;
|
|
68228
|
+
}
|
|
68229
|
+
fields.set(field.alias, { alias: found[0].alias, id: found[0].field.id, type: field.type, label: field.label });
|
|
68230
|
+
}
|
|
68231
|
+
return { table: table.table, tableAlias: table.alias, fields };
|
|
68232
|
+
}
|
|
68233
|
+
function bindScreens(screens, live, roles, entities) {
|
|
68076
68234
|
const aliased = aliasTables([...live]);
|
|
68077
68235
|
const bound = [];
|
|
68078
68236
|
const missing = [];
|
|
68079
68237
|
for (const screen of screens) {
|
|
68080
68238
|
const at2 = `${screen.app.alias}.${screen.screen.alias}`;
|
|
68081
|
-
const tables = aliased.filter((candidate) => candidate.table.name === screen.entity.label);
|
|
68082
|
-
if (tables.length !== 1) {
|
|
68083
|
-
missing.push(
|
|
68084
|
-
tables.length === 0 ? `${at2}: no table is labelled "${screen.entity.label}" (entity "${screen.entity.alias}")` : `${at2}: ${tables.length} tables are labelled "${screen.entity.label}" (${tables.map((candidate) => candidate.table.id).join(", ")}) \u2014 a label binds one`
|
|
68085
|
-
);
|
|
68086
|
-
continue;
|
|
68087
|
-
}
|
|
68088
|
-
const table = tables[0];
|
|
68089
|
-
const liveType = new Map(live.find((candidate) => candidate.id === table.table.id)?.fields.map((field) => [field.id, field.type]) ?? []);
|
|
68090
|
-
const fields = /* @__PURE__ */ new Map();
|
|
68091
|
-
const limits = /* @__PURE__ */ new Map();
|
|
68092
68239
|
const identity = screen.slots.find((slot2) => slot2.role === "identity" && slot2.field !== null)?.field ?? null;
|
|
68240
|
+
const limits = /* @__PURE__ */ new Map();
|
|
68093
68241
|
const wanted = [
|
|
68094
68242
|
...identity === null ? [] : [identity],
|
|
68095
68243
|
...screen.slots.flatMap((slot2) => slot2.field === null || slot2.field === identity ? [] : [slot2.field]),
|
|
@@ -68104,36 +68252,82 @@ function bindScreens(screens, live, roles) {
|
|
|
68104
68252
|
wanted.push(limit);
|
|
68105
68253
|
limits.set(slot2.field.alias, limit.alias);
|
|
68106
68254
|
}
|
|
68107
|
-
|
|
68108
|
-
|
|
68109
|
-
|
|
68110
|
-
|
|
68111
|
-
|
|
68112
|
-
|
|
68113
|
-
|
|
68114
|
-
|
|
68115
|
-
|
|
68116
|
-
|
|
68117
|
-
|
|
68118
|
-
|
|
68255
|
+
wanted.push(...screen.entity.fields);
|
|
68256
|
+
const own = bindTable(at2, screen.entity, wanted, aliased, live, missing);
|
|
68257
|
+
if (own === void 0) continue;
|
|
68258
|
+
const sections = recordSections(screen.entity, entities, roles, recordHeader(screen));
|
|
68259
|
+
const children = /* @__PURE__ */ new Map();
|
|
68260
|
+
const bindChild = (section, child, parentField, drawn, set2, files) => {
|
|
68261
|
+
const found = bindTable(`${at2}.${child.alias}`, child, [parentField, ...drawn], aliased, live, missing);
|
|
68262
|
+
if (found === void 0) return;
|
|
68263
|
+
const parent = found.fields.get(parentField.alias);
|
|
68264
|
+
if (parent === void 0) return;
|
|
68265
|
+
const drawnRoles = /* @__PURE__ */ new Map();
|
|
68266
|
+
const fields = /* @__PURE__ */ new Map();
|
|
68267
|
+
for (const field of drawn) {
|
|
68268
|
+
const boundField = found.fields.get(field.alias);
|
|
68269
|
+
if (boundField === void 0) continue;
|
|
68270
|
+
fields.set(field.alias, boundField);
|
|
68271
|
+
const role = roleOf(roles, child.alias, field.alias)?.role;
|
|
68272
|
+
if (role !== void 0) drawnRoles.set(field.alias, role);
|
|
68273
|
+
}
|
|
68274
|
+
children.set(child.alias, {
|
|
68275
|
+
section,
|
|
68276
|
+
// Keyed by the ENTITY, not the screen: two screens over one entity read
|
|
68277
|
+
// the same rows through the same filter, and a second alias for them is
|
|
68278
|
+
// a second manifest entry, a second `.d.ts` entry and a second cache key
|
|
68279
|
+
// for one query.
|
|
68280
|
+
alias: `${screen.entity.alias}_${child.alias}`,
|
|
68281
|
+
param: `${screen.entity.alias}_id`,
|
|
68282
|
+
entity: child,
|
|
68283
|
+
table: found.table,
|
|
68284
|
+
tableAlias: found.tableAlias,
|
|
68285
|
+
parent,
|
|
68286
|
+
fields,
|
|
68287
|
+
roles: drawnRoles,
|
|
68288
|
+
identity: child.fields.find((field) => roleOf(roles, child.alias, field.alias)?.role === "identity")?.alias,
|
|
68289
|
+
set: set2?.alias,
|
|
68290
|
+
files: files?.alias
|
|
68291
|
+
});
|
|
68292
|
+
};
|
|
68293
|
+
for (const section of sections) {
|
|
68294
|
+
if (section.kind === "children") {
|
|
68295
|
+
const drawn = section.fields.filter((field) => {
|
|
68296
|
+
const role = roleOf(roles, section.child.alias, field.alias)?.role;
|
|
68297
|
+
return role !== void 0 && DRAWN_ROLES.includes(role);
|
|
68298
|
+
});
|
|
68299
|
+
bindChild("children", section.child, section.parentField, drawn);
|
|
68119
68300
|
continue;
|
|
68120
68301
|
}
|
|
68121
|
-
|
|
68302
|
+
if (section.kind !== "expected_set" || section.source !== "child") continue;
|
|
68303
|
+
const name2 = section.child.fields.filter((field) => roleOf(roles, section.child.alias, field.alias)?.role === "identity");
|
|
68304
|
+
bindChild(
|
|
68305
|
+
"expected_set",
|
|
68306
|
+
section.child,
|
|
68307
|
+
section.parentField,
|
|
68308
|
+
[...name2, section.setField, ...section.filesField === void 0 ? [] : [section.filesField]],
|
|
68309
|
+
section.setField,
|
|
68310
|
+
section.filesField
|
|
68311
|
+
);
|
|
68122
68312
|
}
|
|
68123
|
-
bound.push({ screen, table:
|
|
68313
|
+
bound.push({ screen, table: own.table, tableAlias: own.tableAlias, fields: own.fields, limits, sections, children });
|
|
68124
68314
|
}
|
|
68125
68315
|
return { bound, missing };
|
|
68126
68316
|
}
|
|
68127
|
-
function planTableNames(screens) {
|
|
68128
|
-
|
|
68317
|
+
function planTableNames(screens, entities, roles) {
|
|
68318
|
+
const names = [];
|
|
68319
|
+
for (const screen of screens) {
|
|
68320
|
+
names.push(screen.entity.label);
|
|
68321
|
+
for (const section of recordSections(screen.entity, entities, roles, recordHeader(screen))) {
|
|
68322
|
+
const child = sectionChild(section);
|
|
68323
|
+
if (child !== void 0) names.push(child.label);
|
|
68324
|
+
}
|
|
68325
|
+
}
|
|
68326
|
+
return [...new Set(names)];
|
|
68129
68327
|
}
|
|
68130
68328
|
function planQueries(bound) {
|
|
68131
68329
|
const queries = {};
|
|
68132
68330
|
for (const entry of bound) {
|
|
68133
|
-
const columns = [];
|
|
68134
|
-
for (const field of entry.fields.values()) {
|
|
68135
|
-
columns.push(field.type === "files" ? { output: field.id, type: "files", source: field.id, limit: 1 } : field.id);
|
|
68136
|
-
}
|
|
68137
68331
|
const when = entry.screen.slots.find((slot2) => slot2.role === "when" && slot2.field !== null);
|
|
68138
68332
|
const whenId = when?.field == null ? void 0 : entry.fields.get(when.field.alias)?.id;
|
|
68139
68333
|
const sort = whenId === void 0 ? void 0 : [{ field_key: whenId, order: entry.screen.screen.shape === "lifecycle_desk" ? "asc" : "desc" }];
|
|
@@ -68141,10 +68335,32 @@ function planQueries(bound) {
|
|
|
68141
68335
|
ast: {
|
|
68142
68336
|
kind: "project",
|
|
68143
68337
|
from: { kind: "from_table", table_id: entry.table.id, ...sort === void 0 ? {} : { sort } },
|
|
68144
|
-
columns
|
|
68338
|
+
columns: [...entry.fields.values()].map((field) => field.id)
|
|
68145
68339
|
},
|
|
68146
68340
|
description: `${entry.screen.screen.label} \u2014 ${entry.screen.shapeLabel} over ${entry.screen.entity.label}`
|
|
68147
68341
|
};
|
|
68342
|
+
for (const child of entry.children.values()) {
|
|
68343
|
+
if (queries[child.alias] !== void 0) continue;
|
|
68344
|
+
queries[child.alias] = {
|
|
68345
|
+
ast: {
|
|
68346
|
+
kind: "project",
|
|
68347
|
+
from: {
|
|
68348
|
+
kind: "from_table",
|
|
68349
|
+
table_id: child.table.id,
|
|
68350
|
+
filter: {
|
|
68351
|
+
node_type: "condition",
|
|
68352
|
+
type: "select_record_link",
|
|
68353
|
+
field_key: child.parent.id,
|
|
68354
|
+
operator: "has_any_of",
|
|
68355
|
+
value: [`{{params.${child.param}}}`]
|
|
68356
|
+
}
|
|
68357
|
+
},
|
|
68358
|
+
columns: [...child.fields.values()].map((field) => field.id)
|
|
68359
|
+
},
|
|
68360
|
+
params: { [child.param]: { type: "record_link", table_id: entry.table.id } },
|
|
68361
|
+
description: `${child.entity.label} \u2014 the rows under one ${entry.screen.entity.label}`
|
|
68362
|
+
};
|
|
68363
|
+
}
|
|
68148
68364
|
}
|
|
68149
68365
|
return queries;
|
|
68150
68366
|
}
|
|
@@ -68156,10 +68372,36 @@ var SHAPE_MODULE = {
|
|
|
68156
68372
|
monitored_asset_set: { module: "@lotics/ui/monitored_asset_set", component: "MonitoredAssetSet" },
|
|
68157
68373
|
trend_deep_dive: { module: "@lotics/ui/trend_deep_dive", component: "TrendDeepDive" }
|
|
68158
68374
|
};
|
|
68375
|
+
var RECORD_MODULES = {
|
|
68376
|
+
RecordPage: "@lotics/ui/record_page",
|
|
68377
|
+
RecordFacts: "@lotics/ui/record_facts",
|
|
68378
|
+
RecordProgress: "@lotics/ui/record_progress",
|
|
68379
|
+
RecordExpectedSet: "@lotics/ui/record_expected_set",
|
|
68380
|
+
RecordChildren: "@lotics/ui/record_children",
|
|
68381
|
+
RecordFiles: "@lotics/ui/record_files"
|
|
68382
|
+
};
|
|
68383
|
+
var SHAPE_CELLS = ["ContactCell", "DateCell", "IdentityCell", "MoneyCell", "NumberCell", "StageCell", "TextCell"];
|
|
68384
|
+
var SKELETON_ROWS = 5;
|
|
68385
|
+
var SKELETON_BAR = "72%";
|
|
68159
68386
|
var str = (value2) => JSON.stringify(value2);
|
|
68160
68387
|
function pascal(alias) {
|
|
68161
68388
|
return alias.split(/[^a-zA-Z0-9]+/).filter((part) => part !== "").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
|
|
68162
68389
|
}
|
|
68390
|
+
function camel(alias) {
|
|
68391
|
+
const name2 = pascal(alias);
|
|
68392
|
+
return name2 === "" ? "rows" : name2[0].toLowerCase() + name2.slice(1);
|
|
68393
|
+
}
|
|
68394
|
+
function currencyOf(field) {
|
|
68395
|
+
return "currency" in field && typeof field.currency === "string" ? field.currency : void 0;
|
|
68396
|
+
}
|
|
68397
|
+
function isMoney(field) {
|
|
68398
|
+
return currencyOf(field) !== void 0 || "format" in field && field.format === "currency";
|
|
68399
|
+
}
|
|
68400
|
+
function worthFormat(field) {
|
|
68401
|
+
if (!isMoney(field)) return `, format: "number"`;
|
|
68402
|
+
const stated = currencyOf(field);
|
|
68403
|
+
return stated === void 0 ? "" : `, currency: ${str(stated)}`;
|
|
68404
|
+
}
|
|
68163
68405
|
function slotProp(entry, roles, slot2) {
|
|
68164
68406
|
if (slot2.field === null) return null;
|
|
68165
68407
|
const field = entry.fields.get(slot2.field.alias);
|
|
@@ -68182,11 +68424,11 @@ function slotProp(entry, roles, slot2) {
|
|
|
68182
68424
|
const against = typeof decl.against === "number" ? `() => ${decl.against}` : `(r) => amount(r[T.${limit?.alias}])`;
|
|
68183
68425
|
return `${slot2.name}={{ label: ${label}, value: (r) => amount(${ref2}), against: ${against}, alert: ${str(decl.alert)} }}`;
|
|
68184
68426
|
}
|
|
68185
|
-
return `${slot2.name}={{ label: ${label}, value: (r) => amount(${ref2}) }}`;
|
|
68427
|
+
return `${slot2.name}={{ label: ${label}, value: (r) => amount(${ref2})${slot2.name === "worth" ? worthFormat(slot2.field) : ""} }}`;
|
|
68186
68428
|
}
|
|
68187
68429
|
case "amount": {
|
|
68188
|
-
const
|
|
68189
|
-
return `${slot2.name}={{ label: ${label}, value: (r) => amount(${ref2})${currency} }}`;
|
|
68430
|
+
const stated = currencyOf(slot2.field);
|
|
68431
|
+
return `${slot2.name}={{ label: ${label}, value: (r) => amount(${ref2})${stated === void 0 ? "" : `, currency: ${str(stated)}`} }}`;
|
|
68190
68432
|
}
|
|
68191
68433
|
case "when":
|
|
68192
68434
|
return `${slot2.name}={{ label: ${label}, value: (r) => row.text(${ref2}) || null }}`;
|
|
@@ -68204,6 +68446,13 @@ function slotProp(entry, roles, slot2) {
|
|
|
68204
68446
|
}
|
|
68205
68447
|
var CELLS = `import { readFiles, readLinks, readSelect, row } from "@lotics/app-sdk";
|
|
68206
68448
|
import { asColorName } from "@lotics/ui/colors";
|
|
68449
|
+
import { toDisplayFile, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
68450
|
+
import { formatDate } from "@lotics/ui/format_date";
|
|
68451
|
+
import { formatMoney } from "@lotics/ui/format_money";
|
|
68452
|
+
import type { FactValue } from "@lotics/ui/record_facts";
|
|
68453
|
+
|
|
68454
|
+
/** A select field's options, as the query's field-option read hands them over. */
|
|
68455
|
+
type OptionSource = { options: readonly { key: string; label: string; color?: string }[] } | undefined;
|
|
68207
68456
|
|
|
68208
68457
|
/** A number cell, or nothing \u2014 an empty cell is absent, never 0, and a decimal the query kept as text reads as its number. */
|
|
68209
68458
|
export const amount = (v: unknown): number | null =>
|
|
@@ -68215,79 +68464,560 @@ export const picture = (v: unknown): string | null => {
|
|
|
68215
68464
|
return file === undefined ? null : (file.thumbnail_url ?? file.url);
|
|
68216
68465
|
};
|
|
68217
68466
|
|
|
68467
|
+
/** A files cell as the kit draws a file \u2014 the conversion is the kit's, never an app's. */
|
|
68468
|
+
export const files = (v: unknown): DisplayFile[] => readFiles(v).map(toDisplayFile);
|
|
68469
|
+
|
|
68218
68470
|
/** A linked record's display name. */
|
|
68219
68471
|
export const party = (v: unknown): string | null => row.link(v)?.display ?? null;
|
|
68220
68472
|
|
|
68473
|
+
/** A linked record's id \u2014 where the link goes. */
|
|
68474
|
+
export const linked = (v: unknown): string => row.link(v)?.id ?? "";
|
|
68475
|
+
|
|
68221
68476
|
/** The option keys a select cell holds. */
|
|
68222
68477
|
export const keys = (v: unknown): string[] => readSelect(v).map((option) => option.key);
|
|
68223
68478
|
|
|
68224
68479
|
/** A select field's options as the stages a shape's strip or set is made of. */
|
|
68225
|
-
export const stagesOf = (field:
|
|
68480
|
+
export const stagesOf = (field: OptionSource) =>
|
|
68226
68481
|
(field?.options ?? []).map((option) => ({ key: option.key, label: option.label, color: option.color === undefined ? undefined : asColorName(option.color) }));
|
|
68227
68482
|
|
|
68483
|
+
/** The one option a cell holds, in the field's own colour. */
|
|
68484
|
+
export const stageOf = (field: OptionSource, v: unknown) => stagesOf(field).find((option) => option.key === row.opt(v)) ?? null;
|
|
68485
|
+
|
|
68486
|
+
/** The options a cell holds, in the field's own colours. */
|
|
68487
|
+
export const chosen = (field: OptionSource, v: unknown) => {
|
|
68488
|
+
const held = keys(v);
|
|
68489
|
+
return stagesOf(field).filter((option) => held.includes(option.key));
|
|
68490
|
+
};
|
|
68491
|
+
|
|
68492
|
+
/** The required options the record does not hold, by name \u2014 what is holding it where it is. */
|
|
68493
|
+
export const missing = (field: OptionSource, present: readonly string[]): string[] =>
|
|
68494
|
+
stagesOf(field)
|
|
68495
|
+
.filter((option) => !present.includes(option.key))
|
|
68496
|
+
.map((option) => option.label);
|
|
68497
|
+
|
|
68498
|
+
/** The gap in a required set, as a section's issue \u2014 what is MISSING, by name. Nothing missing, no issue. */
|
|
68499
|
+
export const gap = (field: OptionSource, present: readonly string[], say: (names: readonly string[]) => string) => {
|
|
68500
|
+
const absent = missing(field, present);
|
|
68501
|
+
return absent.length === 0 ? undefined : { level: "warning" as const, text: say(absent) };
|
|
68502
|
+
};
|
|
68503
|
+
|
|
68504
|
+
/** The option each child row was filed under \u2014 the present half of a required set. */
|
|
68505
|
+
export const filed = (rows: readonly Record<string, unknown>[], key: string): string[] =>
|
|
68506
|
+
rows.map((child) => row.opt(child[key]) ?? "").filter((option) => option !== "");
|
|
68507
|
+
|
|
68508
|
+
/** An amount as the headline figure, in the reader's locale. */
|
|
68509
|
+
export const money = (v: unknown, locale: string, none: string, currency?: string): string => {
|
|
68510
|
+
const value = amount(v);
|
|
68511
|
+
return value === null ? none : formatMoney(value, { locale, currency });
|
|
68512
|
+
};
|
|
68513
|
+
|
|
68514
|
+
/** A measure as the headline figure. */
|
|
68515
|
+
export const count = (v: unknown, locale: string, none: string): string => {
|
|
68516
|
+
const value = amount(v);
|
|
68517
|
+
return value === null ? none : value.toLocaleString(locale);
|
|
68518
|
+
};
|
|
68519
|
+
|
|
68520
|
+
/** A measure on the side of its limit that needs attention. */
|
|
68521
|
+
export const past = (value: unknown, limit: unknown, alert: "over" | "under"): boolean => {
|
|
68522
|
+
const read = amount(value);
|
|
68523
|
+
const against = amount(limit);
|
|
68524
|
+
if (read === null || against === null) return false;
|
|
68525
|
+
return alert === "over" ? read > against : read < against;
|
|
68526
|
+
};
|
|
68527
|
+
|
|
68528
|
+
/** A measure AND the limit it is judged by, as ONE fact \u2014 the meter the register's own row draws, the gap named under it. A row with no limit to read it against has nothing to be over or under, so it is the plain number. */
|
|
68529
|
+
export const level = (value: unknown, limit: unknown, alert: "over" | "under"): FactValue => {
|
|
68530
|
+
const against = amount(limit);
|
|
68531
|
+
return against === null
|
|
68532
|
+
? { kind: "number", value: amount(value) }
|
|
68533
|
+
: { kind: "level", value: amount(value), limit: against, alert };
|
|
68534
|
+
};
|
|
68535
|
+
|
|
68536
|
+
/** A date cell as the words a record's provenance line carries. */
|
|
68537
|
+
export const when = (v: unknown, locale: string): string => formatDate(row.text(v), { locale });
|
|
68538
|
+
|
|
68228
68539
|
/** Any cell, as the words a record surface shows for it. */
|
|
68229
68540
|
export const shown = (v: unknown): string => {
|
|
68230
68541
|
const options = readSelect(v);
|
|
68231
68542
|
if (options.length > 0) return options.map((option) => option.label).join(", ");
|
|
68232
68543
|
const links = readLinks(v);
|
|
68233
68544
|
if (links.length > 0) return links.map((link) => link.display).join(", ");
|
|
68234
|
-
const
|
|
68235
|
-
if (
|
|
68545
|
+
const attached = readFiles(v);
|
|
68546
|
+
if (attached.length > 0) return attached.map((file) => file.filename).join(", ");
|
|
68236
68547
|
if (typeof v === "boolean") return v ? "\u2713" : "";
|
|
68237
68548
|
return row.text(v);
|
|
68238
68549
|
};
|
|
68239
68550
|
`;
|
|
68240
|
-
|
|
68551
|
+
var CELL_HELPERS = [
|
|
68552
|
+
"amount",
|
|
68553
|
+
"chosen",
|
|
68554
|
+
"count",
|
|
68555
|
+
"filed",
|
|
68556
|
+
"files",
|
|
68557
|
+
"gap",
|
|
68558
|
+
"keys",
|
|
68559
|
+
"level",
|
|
68560
|
+
"linked",
|
|
68561
|
+
"missing",
|
|
68562
|
+
"money",
|
|
68563
|
+
"party",
|
|
68564
|
+
"past",
|
|
68565
|
+
"picture",
|
|
68566
|
+
"shown",
|
|
68567
|
+
"stageOf",
|
|
68568
|
+
"stagesOf",
|
|
68569
|
+
"when"
|
|
68570
|
+
];
|
|
68571
|
+
var SECTION_ICON = {
|
|
68572
|
+
facts: "info",
|
|
68573
|
+
progress: "workflow",
|
|
68574
|
+
/** A required set of PAPERS — one row per document the record owes. */
|
|
68575
|
+
documents: "file-stack",
|
|
68576
|
+
/** A required set of THINGS — services, checks — ticked off, nothing attached. */
|
|
68577
|
+
items: "list-checks",
|
|
68578
|
+
children: "list",
|
|
68579
|
+
files: "image"
|
|
68580
|
+
};
|
|
68581
|
+
function pagesByEntity(bound, app) {
|
|
68582
|
+
const pages = /* @__PURE__ */ new Map();
|
|
68583
|
+
for (const entry of bound) {
|
|
68584
|
+
if (entry.screen.app.alias !== app.alias || entry.screen.record !== "page") continue;
|
|
68585
|
+
if (!pages.has(entry.screen.entity.alias)) pages.set(entry.screen.entity.alias, entry.screen.screen.alias);
|
|
68586
|
+
}
|
|
68587
|
+
return pages;
|
|
68588
|
+
}
|
|
68589
|
+
function factSource(field, bound, role, ref2, options, pages, level) {
|
|
68590
|
+
const flag = `{ kind: "flag", value: row.bool(${ref2}), yes: words.record.yes, no: words.record.no }`;
|
|
68591
|
+
const value2 = (() => {
|
|
68592
|
+
if (role === "contact") return `{ kind: "contact", text: row.text(${ref2}) }`;
|
|
68593
|
+
if (role === "verdict") return flag;
|
|
68594
|
+
if (level !== void 0) return `level(${ref2}, ${level.against}, ${str(level.alert)})`;
|
|
68595
|
+
switch (field.type) {
|
|
68596
|
+
case "boolean":
|
|
68597
|
+
return flag;
|
|
68598
|
+
case "number": {
|
|
68599
|
+
const stated = currencyOf(field);
|
|
68600
|
+
return isMoney(field) ? `{ kind: "money", amount: amount(${ref2})${stated === void 0 ? "" : `, currency: ${str(stated)}`} }` : `{ kind: "number", value: amount(${ref2}) }`;
|
|
68601
|
+
}
|
|
68602
|
+
case "formula":
|
|
68603
|
+
case "rollup":
|
|
68604
|
+
return `{ kind: "number", value: amount(${ref2}) }`;
|
|
68605
|
+
case "date":
|
|
68606
|
+
return `{ kind: "date", date: row.text(${ref2}) || null }`;
|
|
68607
|
+
case "select":
|
|
68608
|
+
return field.multi === true ? `{ kind: "set", options: chosen(${options}, ${ref2}) }` : `{ kind: "stage", stage: stageOf(${options}, ${ref2}) }`;
|
|
68609
|
+
case "select_record_link": {
|
|
68610
|
+
const target = pages.get(field.target_entity);
|
|
68611
|
+
const open = target === void 0 ? "" : `, onOpen: () => navigate(${str(`/${target}/`)} + linked(${ref2}))`;
|
|
68612
|
+
return `{ kind: "link", name: party(${ref2})${open} }`;
|
|
68613
|
+
}
|
|
68614
|
+
case "files":
|
|
68615
|
+
return `{ kind: "files", files: files(${ref2}) }`;
|
|
68616
|
+
case "text":
|
|
68617
|
+
case "autonumber":
|
|
68618
|
+
return `{ kind: "text", text: row.text(${ref2}) }`;
|
|
68619
|
+
default:
|
|
68620
|
+
return `{ kind: "text", text: shown(${ref2}) }`;
|
|
68621
|
+
}
|
|
68622
|
+
})();
|
|
68623
|
+
return `{ label: ${str(bound.label)}, value: ${value2} }`;
|
|
68624
|
+
}
|
|
68625
|
+
function factsBlock(facts, pad) {
|
|
68626
|
+
return `<RecordFacts
|
|
68627
|
+
${pad} facts={[
|
|
68628
|
+
${facts.map((fact) => `${pad} ${fact},`).join("\n")}
|
|
68629
|
+
${pad} ]}
|
|
68630
|
+
${pad}/>`;
|
|
68631
|
+
}
|
|
68632
|
+
function levelRead(entry, level, read) {
|
|
68633
|
+
if (level === void 0) return void 0;
|
|
68634
|
+
if (typeof level.limit === "number") return { against: String(level.limit), alert: level.alert };
|
|
68635
|
+
const bound = entry.fields.get(level.limit.alias);
|
|
68636
|
+
return bound === void 0 ? void 0 : { against: read(bound.alias), alert: level.alert };
|
|
68637
|
+
}
|
|
68638
|
+
function factsSource(entry, roles, section, read, pages, pad) {
|
|
68639
|
+
const facts = section.fields.flatMap((field) => {
|
|
68640
|
+
const bound = entry.fields.get(field.alias);
|
|
68641
|
+
if (bound === void 0) return [];
|
|
68642
|
+
const role = roleOf(roles, entry.screen.entity.alias, field.alias)?.role;
|
|
68643
|
+
const level = levelRead(entry, section.levels[field.alias], read);
|
|
68644
|
+
return [factSource(field, bound, role, read(bound.alias), `fields[T.${bound.alias}]`, pages, level)];
|
|
68645
|
+
});
|
|
68646
|
+
return factsBlock(facts, pad);
|
|
68647
|
+
}
|
|
68648
|
+
function childFactsSource(child, pages, pad) {
|
|
68649
|
+
const rows = camel(child.alias);
|
|
68650
|
+
const facts = [...child.fields.keys()].flatMap((alias) => {
|
|
68651
|
+
const bound = child.fields.get(alias);
|
|
68652
|
+
const field = child.entity.fields.find((candidate) => candidate.alias === alias);
|
|
68653
|
+
if (bound === void 0 || field === void 0) return [];
|
|
68654
|
+
const ref2 = `child[F.${child.tableAlias}.${bound.alias}]`;
|
|
68655
|
+
return [factSource(field, bound, child.roles.get(alias), ref2, `${rows}Fields[F.${child.tableAlias}.${bound.alias}]`, pages)];
|
|
68656
|
+
});
|
|
68657
|
+
return factsBlock(facts, pad);
|
|
68658
|
+
}
|
|
68659
|
+
function roleColumn(spec) {
|
|
68660
|
+
const { role, bind, ref: ref2, options } = spec;
|
|
68661
|
+
const head = `{ key: ${str(spec.key)}, label: ${str(spec.label)}, priority: ${columnPriority(role)}`;
|
|
68662
|
+
switch (role) {
|
|
68663
|
+
case "identity":
|
|
68664
|
+
return `${head}, flex: 2, cell: (${bind}) => <IdentityCell title={${spec.type === "select_record_link" ? `party(${ref2}) ?? ""` : `row.text(${ref2})`}} /> }`;
|
|
68665
|
+
case "party":
|
|
68666
|
+
return `${head}, flex: 1, cell: (${bind}) => <TextCell value={party(${ref2}) ?? ""} /> }`;
|
|
68667
|
+
case "contact":
|
|
68668
|
+
return `${head}, width: 200, cell: (${bind}) => <ContactCell value={row.text(${ref2})} copyLabel={words.copyButton.copy} /> }`;
|
|
68669
|
+
case "when":
|
|
68670
|
+
return `${head}, width: 112, cell: (${bind}) => <DateCell value={row.text(${ref2})} /> }`;
|
|
68671
|
+
// An absent figure is ABSENT — a `?? 0` prints a plausible wrong number, and
|
|
68672
|
+
// the same value reads "—" in the facts beside it.
|
|
68673
|
+
case "measure":
|
|
68674
|
+
return `${head}, width: 96, align: "right", cell: (${bind}) => { const level = amount(${ref2}); return level === null ? null : <NumberCell value={level} />; } }`;
|
|
68675
|
+
case "amount":
|
|
68676
|
+
return `${head}, width: 128, align: "right", cell: (${bind}) => { const sum = amount(${ref2}); return sum === null ? null : <MoneyCell value={sum} />; } }`;
|
|
68677
|
+
case "lifecycle":
|
|
68678
|
+
return `${head}, width: 120, cell: (${bind}) => { const held = stageOf(${options}, ${ref2}); return held === null ? null : <StageCell stage={held} />; } }`;
|
|
68679
|
+
case "verdict":
|
|
68680
|
+
return `${head}, width: 120, cell: (${bind}) => { const answer = row.bool(${ref2}); return answer === null ? null : <TextCell value={answer ? words.record.yes : words.record.no} />; } }`;
|
|
68681
|
+
default:
|
|
68682
|
+
return `${head}, width: 120, cell: (${bind}) => <TextCell value={shown(${ref2})} /> }`;
|
|
68683
|
+
}
|
|
68684
|
+
}
|
|
68685
|
+
function childColumn(child, alias) {
|
|
68686
|
+
const field = child.fields.get(alias);
|
|
68687
|
+
const role = child.roles.get(alias);
|
|
68688
|
+
if (field === void 0 || role === void 0) return null;
|
|
68689
|
+
return roleColumn({
|
|
68690
|
+
role,
|
|
68691
|
+
key: field.alias,
|
|
68692
|
+
label: field.label,
|
|
68693
|
+
type: field.type,
|
|
68694
|
+
bind: "child",
|
|
68695
|
+
ref: `child[F.${child.tableAlias}.${field.alias}]`,
|
|
68696
|
+
options: `${camel(child.alias)}Fields[F.${child.tableAlias}.${field.alias}]`
|
|
68697
|
+
});
|
|
68698
|
+
}
|
|
68699
|
+
function childrenSource(child, pages, pad) {
|
|
68700
|
+
const rows = camel(child.alias);
|
|
68701
|
+
const drawn = [...child.fields.keys()].filter((alias) => child.roles.get(alias) !== void 0);
|
|
68702
|
+
const shown = new Set(
|
|
68703
|
+
[...drawn].sort((a, b) => columnPriority(child.roles.get(a) ?? "identity") - columnPriority(child.roles.get(b) ?? "identity")).slice(0, CHILD_COLUMNS_SHOWN)
|
|
68704
|
+
);
|
|
68705
|
+
const columns = drawn.flatMap((alias) => {
|
|
68706
|
+
const column = shown.has(alias) ? childColumn(child, alias) : null;
|
|
68707
|
+
return column === null ? [] : [column];
|
|
68708
|
+
});
|
|
68709
|
+
const name2 = child.identity === void 0 ? void 0 : child.fields.get(child.identity);
|
|
68710
|
+
const identity = name2 === void 0 ? `() => ""` : `(child) => ${name2.type === "select_record_link" ? `party(child[F.${child.tableAlias}.${name2.alias}]) ?? ""` : `row.text(child[F.${child.tableAlias}.${name2.alias}])`}`;
|
|
68711
|
+
return `<RecordChildren
|
|
68712
|
+
${pad} rows={${rows}.rows}
|
|
68713
|
+
${pad} rowKey={(child) => child.__source_record_id ?? ""}
|
|
68714
|
+
${pad} identity={${identity}}
|
|
68715
|
+
${pad} columns={[
|
|
68716
|
+
${columns.map((column) => `${pad} ${column},`).join("\n")}
|
|
68717
|
+
${pad} ]}
|
|
68718
|
+
${pad} record={{ render: (child) => (
|
|
68719
|
+
${pad} ${childFactsSource(child, pages, `${pad} `)}
|
|
68720
|
+
${pad} ) }}
|
|
68721
|
+
${pad} loading={${rows}.loading}
|
|
68722
|
+
${pad} error={${rows}.error === null ? undefined : { message: ${rows}.error, onRetry: ${rows}.refetch }}
|
|
68723
|
+
${pad}/>`;
|
|
68724
|
+
}
|
|
68725
|
+
function deskSource(child, pages, pad) {
|
|
68726
|
+
const rows = camel(child.alias);
|
|
68727
|
+
const set2 = child.set === void 0 ? void 0 : child.fields.get(child.set);
|
|
68728
|
+
if (set2 === void 0) return "";
|
|
68729
|
+
const key = `F.${child.tableAlias}.${set2.alias}`;
|
|
68730
|
+
const name2 = child.identity === void 0 ? void 0 : child.fields.get(child.identity);
|
|
68731
|
+
const attached = child.files === void 0 ? void 0 : child.fields.get(child.files);
|
|
68732
|
+
const target = pages.get(child.entity.alias);
|
|
68733
|
+
const named = name2 === void 0 ? `stageOf(${rows}Fields[${key}], child[${key}])?.label ?? ""` : name2.type === "select_record_link" ? `party(child[F.${child.tableAlias}.${name2.alias}]) ?? ""` : `row.text(child[F.${child.tableAlias}.${name2.alias}])`;
|
|
68734
|
+
const item = [
|
|
68735
|
+
`key: row.opt(child[${key}]) ?? ""`,
|
|
68736
|
+
`label: ${named}`,
|
|
68737
|
+
...attached === void 0 ? [] : [`files: files(child[F.${child.tableAlias}.${attached.alias}])`],
|
|
68738
|
+
...target === void 0 ? [] : [`onOpen: () => navigate(${str(`/${target}/`)} + (child.__source_record_id ?? ""))`]
|
|
68739
|
+
].join(", ");
|
|
68740
|
+
return `<RecordExpectedSet
|
|
68741
|
+
${pad} required={stagesOf(${rows}Fields[${key}])}
|
|
68742
|
+
${pad} present={${rows}.rows.map((child) => ({ ${item} }))}
|
|
68743
|
+
${pad} kind=${str(attached === void 0 ? "items" : "files")}${attached === void 0 ? "" : `
|
|
68744
|
+
${pad} onOpenFile={(file) => void openExternal(file.url)}`}
|
|
68745
|
+
${pad}/>`;
|
|
68746
|
+
}
|
|
68747
|
+
function recordSource(entry, roles, read, pages, pad, led) {
|
|
68748
|
+
const blockers = entry.sections.flatMap((section) => section.kind === "expected_set" && section.source === "own" ? [entry.fields.get(section.field.alias)] : []).flatMap((field) => field === void 0 ? [] : [`...missing(fields[T.${field.alias}], keys(${read(field.alias)}))`]).join(", ");
|
|
68749
|
+
let facts = null;
|
|
68750
|
+
const sections = [];
|
|
68751
|
+
for (const section of entry.sections) {
|
|
68752
|
+
switch (section.kind) {
|
|
68753
|
+
case "facts": {
|
|
68754
|
+
const { fields, levels } = section;
|
|
68755
|
+
facts = (at2) => factsSource(entry, roles, { fields, levels }, read, pages, at2);
|
|
68756
|
+
break;
|
|
68757
|
+
}
|
|
68758
|
+
case "progress": {
|
|
68759
|
+
const field = entry.fields.get(section.field.alias);
|
|
68760
|
+
if (field === void 0) break;
|
|
68761
|
+
sections.push({
|
|
68762
|
+
key: field.alias,
|
|
68763
|
+
label: field.label,
|
|
68764
|
+
icon: SECTION_ICON.progress,
|
|
68765
|
+
body: `<RecordProgress
|
|
68766
|
+
${pad} options={stagesOf(fields[T.${field.alias}])}
|
|
68767
|
+
${pad} value={row.opt(${read(field.alias)})}${blockers === "" ? "" : `
|
|
68768
|
+
${pad} blockers={[${blockers}]}`}
|
|
68769
|
+
${pad}/>`
|
|
68770
|
+
});
|
|
68771
|
+
break;
|
|
68772
|
+
}
|
|
68773
|
+
case "expected_set": {
|
|
68774
|
+
if (section.source === "own") {
|
|
68775
|
+
const field = entry.fields.get(section.field.alias);
|
|
68776
|
+
if (field === void 0) break;
|
|
68777
|
+
sections.push({
|
|
68778
|
+
key: field.alias,
|
|
68779
|
+
label: field.label,
|
|
68780
|
+
icon: SECTION_ICON.items,
|
|
68781
|
+
issue: `gap(fields[T.${field.alias}], keys(${read(field.alias)}), words.record.gap)`,
|
|
68782
|
+
// The entity's OWN multi-select: the chosen options are things the
|
|
68783
|
+
// record was sold or owes, not documents — there is nothing to
|
|
68784
|
+
// attach and nothing to show a face of.
|
|
68785
|
+
body: `<RecordExpectedSet
|
|
68786
|
+
${pad} required={stagesOf(fields[T.${field.alias}])}
|
|
68787
|
+
${pad} present={chosen(fields[T.${field.alias}], ${read(field.alias)}).map((option) => ({ key: option.key, label: option.label }))}
|
|
68788
|
+
${pad} kind="items"
|
|
68789
|
+
${pad}/>`
|
|
68790
|
+
});
|
|
68791
|
+
break;
|
|
68792
|
+
}
|
|
68793
|
+
const child = entry.children.get(section.child.alias);
|
|
68794
|
+
if (child === void 0 || child.set === void 0) break;
|
|
68795
|
+
const rows = camel(child.alias);
|
|
68796
|
+
const key = `F.${child.tableAlias}.${child.fields.get(child.set)?.alias ?? child.set}`;
|
|
68797
|
+
sections.push({
|
|
68798
|
+
key: child.entity.alias,
|
|
68799
|
+
label: child.entity.label,
|
|
68800
|
+
// A desk that attaches files is the DOCUMENTS it holds; one that
|
|
68801
|
+
// attaches nothing is a list of things ticked off.
|
|
68802
|
+
icon: child.files === void 0 ? SECTION_ICON.items : SECTION_ICON.documents,
|
|
68803
|
+
// The band says WHICH entry is missing, by name — a fraction is the
|
|
68804
|
+
// meter's caption, and the meter is already inside the section.
|
|
68805
|
+
issue: `gap(${rows}Fields[${key}], filed(${rows}.rows, ${key}), words.record.gap)`,
|
|
68806
|
+
body: deskSource(child, pages, pad)
|
|
68807
|
+
});
|
|
68808
|
+
break;
|
|
68809
|
+
}
|
|
68810
|
+
case "children": {
|
|
68811
|
+
const child = entry.children.get(section.child.alias);
|
|
68812
|
+
if (child === void 0) break;
|
|
68813
|
+
sections.push({
|
|
68814
|
+
key: child.entity.alias,
|
|
68815
|
+
label: child.entity.label,
|
|
68816
|
+
icon: SECTION_ICON.children,
|
|
68817
|
+
body: childrenSource(child, pages, pad)
|
|
68818
|
+
});
|
|
68819
|
+
break;
|
|
68820
|
+
}
|
|
68821
|
+
// ONE PILE, PICTURES FIRST: every files field the entity carries, the mark
|
|
68822
|
+
// leading, read into one section. A record's files are what it HOLDS, and
|
|
68823
|
+
// splitting them by the field they were filed in makes a reader open three
|
|
68824
|
+
// headings to see three photographs.
|
|
68825
|
+
//
|
|
68826
|
+
// EXCEPT THE ONE THE HEADER ALREADY DREW. A page leads with the mark's
|
|
68827
|
+
// picture, so a section over that same field restates it a hundred pixels
|
|
68828
|
+
// lower — and a section over what is LEFT of it reads "nothing here" on
|
|
68829
|
+
// the ordinary record whose likeness is one photograph, contradicting the
|
|
68830
|
+
// photograph above it. The mark is the header's; the section is every
|
|
68831
|
+
// OTHER files field, and a record with none has no files section.
|
|
68832
|
+
case "files": {
|
|
68833
|
+
const held = section.fields.flatMap((field) => {
|
|
68834
|
+
if (field.alias === led) return [];
|
|
68835
|
+
const bound = entry.fields.get(field.alias);
|
|
68836
|
+
return bound === void 0 ? [] : [{ field, bound }];
|
|
68837
|
+
});
|
|
68838
|
+
if (held.length === 0) break;
|
|
68839
|
+
const pile = held.length === 1 ? `files(${read(held[0].bound.alias)})` : `[${held.map(({ bound }) => `...files(${read(bound.alias)})`).join(", ")}]`;
|
|
68840
|
+
const marked = held.length === 1 && roleOf(roles, entry.screen.entity.alias, held[0].field.alias)?.role === "mark";
|
|
68841
|
+
sections.push({
|
|
68842
|
+
key: held[0].bound.alias,
|
|
68843
|
+
label: held.map(({ bound }) => bound.label).join(" \xB7 "),
|
|
68844
|
+
icon: SECTION_ICON.files,
|
|
68845
|
+
body: `<RecordFiles files={${pile}}${marked ? ` lead="photos"` : ""} />`
|
|
68846
|
+
});
|
|
68847
|
+
break;
|
|
68848
|
+
}
|
|
68849
|
+
}
|
|
68850
|
+
}
|
|
68851
|
+
return { facts, sections };
|
|
68852
|
+
}
|
|
68853
|
+
function recordPreamble(entry, text, source, scoped) {
|
|
68854
|
+
const lines = [];
|
|
68855
|
+
if (!scoped.navigate && text.includes("navigate(")) lines.push(` const navigate = useNavigate();`);
|
|
68856
|
+
if (text.includes("words.")) lines.push(` const words = useLoticsLocale();`);
|
|
68857
|
+
if (/\btag\b/.test(text)) lines.push(` const tag = useLocaleTag();`);
|
|
68858
|
+
if (!scoped.options && text.includes("fields[T.")) {
|
|
68859
|
+
lines.push(` const options = useFieldOptions(${str(entry.screen.screen.alias)});`, ` const { fields } = options;`);
|
|
68860
|
+
}
|
|
68861
|
+
if (entry.children.size > 0) lines.push(` const recordId = ${source};`);
|
|
68862
|
+
for (const child of entry.children.values()) {
|
|
68863
|
+
const rows = camel(child.alias);
|
|
68864
|
+
lines.push(` const ${rows} = useQuery(${str(child.alias)}, { ${child.param}: recordId }, { enabled: recordId !== "" });`);
|
|
68865
|
+
if (text.includes(`${rows}Fields[`)) lines.push(` const ${rows}Fields = useFieldOptions(${str(child.alias)}).fields;`);
|
|
68866
|
+
}
|
|
68867
|
+
return lines;
|
|
68868
|
+
}
|
|
68869
|
+
function drawerSection(section) {
|
|
68870
|
+
return ` <Section>
|
|
68871
|
+
<SectionHeading>
|
|
68872
|
+
<SectionHeadingTitle icon=${str(section.icon)}>${section.label}</SectionHeadingTitle>
|
|
68873
|
+
</SectionHeading>
|
|
68874
|
+
${section.body}
|
|
68875
|
+
</Section>`;
|
|
68876
|
+
}
|
|
68877
|
+
function pageSection(section) {
|
|
68878
|
+
const issue2 = section.issue === void 0 ? "" : `
|
|
68879
|
+
issue: ${section.issue},`;
|
|
68880
|
+
return ` {
|
|
68881
|
+
key: ${str(section.key)},
|
|
68882
|
+
label: ${str(section.label)},
|
|
68883
|
+
icon: ${str(section.icon)},${issue2}
|
|
68884
|
+
children: (
|
|
68885
|
+
${section.body}
|
|
68886
|
+
),
|
|
68887
|
+
}`;
|
|
68888
|
+
}
|
|
68889
|
+
function screenSource(entry, roles, pages) {
|
|
68241
68890
|
const { screen } = entry;
|
|
68242
68891
|
const name2 = pascal(screen.screen.alias);
|
|
68243
68892
|
const alias = screen.screen.alias;
|
|
68893
|
+
const shapeName = screen.screen.shape;
|
|
68894
|
+
const custom2 = shapeName === CUSTOM_SHAPE;
|
|
68895
|
+
const page = screen.record === "page";
|
|
68244
68896
|
const fieldsInOrder = [...entry.fields.values()];
|
|
68245
|
-
const recordRows = fieldsInOrder.map((field) => ` <DetailRow label=${str(field.label)}>
|
|
68246
|
-
<Text size="sm">{shown(r[T.${field.alias}])}</Text>
|
|
68247
|
-
</DetailRow>`).join("\n");
|
|
68248
68897
|
const identity = screen.slots.find((slot2) => slot2.role === "identity" && slot2.field !== null);
|
|
68249
68898
|
const identityField = identity?.field == null ? void 0 : entry.fields.get(identity.field.alias);
|
|
68250
|
-
const
|
|
68251
|
-
const
|
|
68252
|
-
const
|
|
68253
|
-
const
|
|
68254
|
-
|
|
68899
|
+
const read = (fieldAlias) => `${page ? "r?." : "r"}[T.${fieldAlias}]`;
|
|
68900
|
+
const named = (ref2) => identityField === void 0 ? void 0 : identityField.type === "select_record_link" ? `party(${ref2(identityField.alias)}) ?? ""` : `row.text(${ref2(identityField.alias)})`;
|
|
68901
|
+
const rowRead = (fieldAlias) => `r[T.${fieldAlias}]`;
|
|
68902
|
+
const identityRead = named(rowRead);
|
|
68903
|
+
const fallback = fieldsInOrder.length === 0 ? `""` : fieldsInOrder[0].alias;
|
|
68904
|
+
const rowTitle = identityRead ?? (fallback === `""` ? `""` : `shown(r[T.${fallback}])`);
|
|
68905
|
+
const pageTitle = named(read) ?? (fallback === `""` ? `""` : `shown(${read(fallback)})`);
|
|
68906
|
+
const markField = screen.entity.fields.find((field) => roleOf(roles, screen.entity.alias, field.alias)?.role === "mark");
|
|
68907
|
+
const markBound = markField === void 0 ? void 0 : entry.fields.get(markField.alias);
|
|
68908
|
+
const mark = markBound === void 0 ? "" : `
|
|
68909
|
+
mark={{ kind: ${str(shapeName === "party_register" ? "group" : "thing")}, name: ${pageTitle}, image: picture(${read(markBound.alias)}) }}`;
|
|
68910
|
+
const ledOnPage = markBound === void 0 ? void 0 : markField?.alias;
|
|
68911
|
+
const inDrawer = recordSource(entry, roles, read, pages, " ", void 0);
|
|
68912
|
+
const drawerBody = (() => {
|
|
68913
|
+
const parts = [
|
|
68914
|
+
...inDrawer.facts === null ? [] : [` ${inDrawer.facts(" ")}`],
|
|
68915
|
+
...inDrawer.sections.length === 0 ? [] : [` <SectionStack>
|
|
68916
|
+
${inDrawer.sections.map(drawerSection).join("\n")}
|
|
68917
|
+
</SectionStack>`]
|
|
68918
|
+
];
|
|
68919
|
+
return ` <Stack gap={24}>
|
|
68920
|
+
${parts.join("\n")}
|
|
68921
|
+
</Stack>`;
|
|
68922
|
+
})();
|
|
68923
|
+
const drawerPreamble = recordPreamble(entry, drawerBody, `r.__source_record_id ?? ""`, { navigate: false, options: false });
|
|
68924
|
+
const drawer = page || custom2 ? "" : `
|
|
68925
|
+
/** The record behind a row \u2014 the facts the plan left over, and the work its roles name. */
|
|
68255
68926
|
function ${name2}Record({ r }: { r: QueryRow }) {
|
|
68256
|
-
return (
|
|
68257
|
-
|
|
68258
|
-
${recordRows}
|
|
68259
|
-
</DetailTable>
|
|
68927
|
+
${drawerPreamble.join("\n")}${drawerPreamble.length === 0 ? "" : "\n"} return (
|
|
68928
|
+
${drawerBody}
|
|
68260
68929
|
);
|
|
68261
68930
|
}
|
|
68262
68931
|
`;
|
|
68932
|
+
const inPage = recordSource(entry, roles, read, pages, " ", ledOnPage);
|
|
68933
|
+
const pageSections = inPage.sections.length > 0 ? inPage.sections : [
|
|
68934
|
+
{
|
|
68935
|
+
key: "facts",
|
|
68936
|
+
label: screen.entity.label,
|
|
68937
|
+
icon: SECTION_ICON.facts,
|
|
68938
|
+
body: (inPage.facts ?? ((at2) => factsBlock([], at2)))(" ")
|
|
68939
|
+
}
|
|
68940
|
+
];
|
|
68941
|
+
const pageFacts = inPage.facts === null || inPage.sections.length === 0 ? "" : `
|
|
68942
|
+
facts={
|
|
68943
|
+
${inPage.facts(" ")}
|
|
68944
|
+
}`;
|
|
68945
|
+
const amountSlot = screen.slots.find((slot2) => slot2.role === "amount" && slot2.field !== null);
|
|
68946
|
+
const measureSlot = screen.slots.find((slot2) => slot2.role === "measure" && slot2.field !== null);
|
|
68947
|
+
const figure = (() => {
|
|
68948
|
+
if (amountSlot?.field != null) {
|
|
68949
|
+
const field2 = entry.fields.get(amountSlot.field.alias);
|
|
68950
|
+
if (field2 !== void 0) {
|
|
68951
|
+
const stated2 = currencyOf(amountSlot.field);
|
|
68952
|
+
return `
|
|
68953
|
+
figure={{ label: ${str(field2.label)}, value: money(${read(field2.alias)}, tag, words.record.none${stated2 === void 0 ? "" : `, ${str(stated2)}`}) }}`;
|
|
68954
|
+
}
|
|
68955
|
+
}
|
|
68956
|
+
if (measureSlot?.field == null) return "";
|
|
68957
|
+
const field = entry.fields.get(measureSlot.field.alias);
|
|
68958
|
+
if (field === void 0) return "";
|
|
68959
|
+
const decl = roleOf(roles, screen.entity.alias, measureSlot.field.alias);
|
|
68960
|
+
const limitAlias = entry.limits.get(measureSlot.field.alias);
|
|
68961
|
+
const limit = limitAlias === void 0 ? void 0 : entry.fields.get(limitAlias);
|
|
68962
|
+
const against = typeof decl?.against === "number" ? String(decl.against) : limit === void 0 ? void 0 : read(limit.alias);
|
|
68963
|
+
const tone = decl?.alert === void 0 || against === void 0 ? "" : `, tone: past(${read(field.alias)}, ${against}, ${str(decl.alert)}) ? "danger" : undefined`;
|
|
68964
|
+
const stated = currencyOf(measureSlot.field);
|
|
68965
|
+
const value2 = isMoney(measureSlot.field) ? `money(${read(field.alias)}, tag, words.record.none${stated === void 0 ? "" : `, ${str(stated)}`})` : `count(${read(field.alias)}, tag, words.record.none)`;
|
|
68966
|
+
return `
|
|
68967
|
+
figure={{ label: ${str(field.label)}, value: ${value2}${tone} }}`;
|
|
68968
|
+
})();
|
|
68969
|
+
const whenSlot = screen.slots.find((slot2) => slot2.role === "when" && slot2.field !== null);
|
|
68970
|
+
const whenField = whenSlot?.field == null ? void 0 : entry.fields.get(whenSlot.field.alias);
|
|
68971
|
+
const pageProps = ` title={${pageTitle}}${mark}${whenField === void 0 ? "" : `
|
|
68972
|
+
subtitle={when(${read(whenField.alias)}, tag) || undefined}`}${figure}
|
|
68973
|
+
back={{ label: ${str(screen.screen.label)}, onPress: () => navigate(-1) }}`;
|
|
68974
|
+
const pageReads = pageProps + pageFacts + pageSections.map(pageSection).join("");
|
|
68975
|
+
const pageState = pageReads.includes("fields[T.") ? ` loading={loading || options.loading}
|
|
68976
|
+
error={error === null ? (options.error === null ? undefined : { message: options.error, onRetry: options.refetch }) : { message: error, onRetry: refetch }}` : ` loading={loading}
|
|
68977
|
+
error={error === null ? undefined : { message: error, onRetry: refetch }}`;
|
|
68978
|
+
const pageBody = ` <RecordPage
|
|
68979
|
+
${pageProps}
|
|
68980
|
+
${pageState}
|
|
68981
|
+
missing={error === null && !loading && r === undefined}${pageFacts}
|
|
68982
|
+
sections={[
|
|
68983
|
+
${pageSections.map(pageSection).join(",\n")},
|
|
68984
|
+
]}
|
|
68985
|
+
/>`;
|
|
68986
|
+
const pagePreamble = recordPreamble(entry, pageBody, `r?.__source_record_id ?? ""`, { navigate: true, options: false });
|
|
68263
68987
|
const recordScreen = page ? `
|
|
68264
68988
|
/** The page a row opens: arrived at cold, so it stands without the list. */
|
|
68265
68989
|
export function ${name2}RecordScreen() {
|
|
68266
68990
|
const { id } = useParams();
|
|
68267
68991
|
const navigate = useNavigate();
|
|
68268
|
-
const
|
|
68269
|
-
const { rows } = useQuery(${str(alias)});
|
|
68992
|
+
const { rows, loading, error, refetch } = useQuery(${str(alias)});
|
|
68270
68993
|
const r = rows.find((candidate) => candidate.__source_record_id === id);
|
|
68271
|
-
return (
|
|
68272
|
-
|
|
68273
|
-
<BackButton onPress={() => navigate(-1)} />
|
|
68274
|
-
{r === undefined ? (
|
|
68275
|
-
<RegionState state="empty" message={words.empty} />
|
|
68276
|
-
) : (
|
|
68277
|
-
<>
|
|
68278
|
-
<RecordSummary title={${pageTitle}} />
|
|
68279
|
-
<${name2}Record r={r} />
|
|
68280
|
-
</>
|
|
68281
|
-
)}
|
|
68282
|
-
</Stack>
|
|
68994
|
+
${pagePreamble.join("\n")}${pagePreamble.length === 0 ? "" : "\n"} return (
|
|
68995
|
+
${pageBody}
|
|
68283
68996
|
);
|
|
68284
68997
|
}
|
|
68285
68998
|
` : "";
|
|
68286
68999
|
let body;
|
|
68287
69000
|
let shapeImport = "";
|
|
68288
|
-
const shapeName = screen.screen.shape;
|
|
68289
69001
|
if (shapeName === CUSTOM_SHAPE) {
|
|
68290
69002
|
const slots = screen.slots.map((slot2) => `${slot2.name} (${slot2.role}${slot2.field === null ? "" : `: ${slot2.field.label}`})`).join(", ");
|
|
69003
|
+
const drawn = screen.slots.flatMap((slot2) => {
|
|
69004
|
+
if (slot2.field === null || !DRAWN_ROLES.includes(slot2.role)) return [];
|
|
69005
|
+
const field = entry.fields.get(slot2.field.alias);
|
|
69006
|
+
if (field === void 0) return [];
|
|
69007
|
+
return [
|
|
69008
|
+
roleColumn({
|
|
69009
|
+
role: slot2.role,
|
|
69010
|
+
key: field.alias,
|
|
69011
|
+
label: field.label,
|
|
69012
|
+
type: field.type,
|
|
69013
|
+
bind: "r",
|
|
69014
|
+
ref: `r[T.${field.alias}]`,
|
|
69015
|
+
options: `fields[T.${field.alias}]`
|
|
69016
|
+
})
|
|
69017
|
+
];
|
|
69018
|
+
});
|
|
69019
|
+
const columns = drawn.length > 0 ? drawn : [`{ key: "identity", label: ${str(fieldsInOrder[0]?.label ?? "")}, flex: 2, cell: (r) => <IdentityCell title={${rowTitle}} /> }`];
|
|
69020
|
+
const usesFields = columns.some((column) => column.includes("fields["));
|
|
68291
69021
|
body = `
|
|
68292
69022
|
const T = F.${entry.tableAlias};
|
|
68293
69023
|
|
|
@@ -68295,27 +69025,54 @@ const T = F.${entry.tableAlias};
|
|
|
68295
69025
|
* ${screen.screen.label} \u2014 a CUSTOM screen: no registry shape fits it, so the plan
|
|
68296
69026
|
* declared its slots as roles and the kit is composed here by hand.
|
|
68297
69027
|
* Slots: ${slots}.
|
|
68298
|
-
*
|
|
68299
|
-
*
|
|
68300
|
-
*
|
|
69028
|
+
* Each column below is the cell that slot's ROLE names \u2014 the device a registry
|
|
69029
|
+
* shape draws that value with. Grow the screen from here
|
|
69030
|
+
* (\`node_modules/@lotics/ui/llms.txt\`), then file \`lotics report\` naming the
|
|
69031
|
+
* shape you wished existed.
|
|
68301
69032
|
*/
|
|
68302
69033
|
export function ${name2}Screen() {
|
|
68303
|
-
const { rows, loading, error, refetch } = useQuery(${str(alias)})
|
|
68304
|
-
const
|
|
68305
|
-
|
|
68306
|
-
|
|
68307
|
-
|
|
69034
|
+
const { rows, loading, error, refetch } = useQuery(${str(alias)});${usesFields ? `
|
|
69035
|
+
const options = useFieldOptions(${str(alias)});
|
|
69036
|
+
const { fields } = options;` : ""}
|
|
69037
|
+
const words = useLoticsLocale();${page ? "\n const navigate = useNavigate();" : ""}
|
|
69038
|
+
const columns: ShapeColumn<QueryRow>[] = [
|
|
69039
|
+
${columns.map((column) => ` ${column},`).join("\n")}
|
|
69040
|
+
];
|
|
69041
|
+
if (error !== null) return <RegionState state="error" message={error} onRetry={refetch} />;${usesFields ? `
|
|
69042
|
+
if (options.error !== null) return <RegionState state="error" message={options.error} onRetry={options.refetch} />;` : ""}
|
|
69043
|
+
// A READ IN FLIGHT WAITS AS THE REGISTER IT IS BECOMING, never a spinner: the
|
|
69044
|
+
// columns above are already known, so the rows land into this grid without
|
|
69045
|
+
// moving an edge. Five rows \u2014 enough for the grid to read as a register, too
|
|
69046
|
+
// few to claim how long the band will be \u2014 and each bar stops short of its
|
|
69047
|
+
// column so the row reads as cells rather than one slab.
|
|
69048
|
+
if (loading${usesFields ? " || options.loading" : ""})
|
|
69049
|
+
return (
|
|
69050
|
+
<Table columns={columns}>
|
|
69051
|
+
{Array.from({ length: ${SKELETON_ROWS} }, (_unused, index) => (
|
|
69052
|
+
<TableRow key={index}>
|
|
69053
|
+
{columns.map((column) => (
|
|
69054
|
+
<TableCell key={column.key}>
|
|
69055
|
+
<Skeleton width=${str(SKELETON_BAR)} />
|
|
69056
|
+
</TableCell>
|
|
69057
|
+
))}
|
|
69058
|
+
</TableRow>
|
|
69059
|
+
))}
|
|
69060
|
+
</Table>
|
|
69061
|
+
);
|
|
69062
|
+
if (rows.length === 0) return <RegionState state="empty" message={words.shape.empty} />;
|
|
68308
69063
|
return (
|
|
68309
|
-
<
|
|
69064
|
+
<Table columns={columns}>
|
|
68310
69065
|
{rows.map((r) => (
|
|
68311
|
-
|
|
68312
|
-
|
|
68313
|
-
|
|
69066
|
+
<TableRow key={r.__source_record_id}${page ? ` accessibilityLabel={${rowTitle}} onPress={() => navigate(${str(`/${alias}/`)} + (r.__source_record_id ?? ""))}` : ""}>
|
|
69067
|
+
{columns.map((column) => (
|
|
69068
|
+
<TableCell key={column.key}>{column.cell(r)}</TableCell>
|
|
69069
|
+
))}
|
|
69070
|
+
</TableRow>
|
|
68314
69071
|
))}
|
|
68315
|
-
</
|
|
69072
|
+
</Table>
|
|
68316
69073
|
);
|
|
68317
69074
|
}
|
|
68318
|
-
${
|
|
69075
|
+
${recordScreen}`;
|
|
68319
69076
|
} else {
|
|
68320
69077
|
const shape = SHAPE_MODULE[shapeName];
|
|
68321
69078
|
shapeImport = `import { ${shape.component} } from ${str(shape.module)};`;
|
|
@@ -68353,23 +69110,35 @@ ${state}
|
|
|
68353
69110
|
/>
|
|
68354
69111
|
);
|
|
68355
69112
|
}
|
|
68356
|
-
${
|
|
68357
|
-
}
|
|
68358
|
-
const sdk = [
|
|
68359
|
-
|
|
68360
|
-
|
|
69113
|
+
${drawer}${recordScreen}`;
|
|
69114
|
+
}
|
|
69115
|
+
const sdk = [
|
|
69116
|
+
body.includes("useFieldOptions(") ? "useFieldOptions" : "",
|
|
69117
|
+
body.includes("openExternal(") ? "openExternal" : "",
|
|
69118
|
+
body.includes("row.") ? "row" : "",
|
|
69119
|
+
"useQuery",
|
|
69120
|
+
// A screen whose record opens as a PAGE reads its row out of the query and
|
|
69121
|
+
// never types one of its own, so the row type is not always reached for.
|
|
69122
|
+
/\bQueryRow\b/.test(body) ? "type QueryRow" : ""
|
|
69123
|
+
].filter((each) => each !== "");
|
|
69124
|
+
const cells = CELL_HELPERS.filter((helper) => body.includes(`${helper}(`));
|
|
69125
|
+
const kit = (component, module2) => body.includes(`<${component}`) ? `import { ${component} } from ${str(module2)};` : "";
|
|
69126
|
+
const router = ["useNavigate", "useParams"].filter((hook) => body.includes(`${hook}()`));
|
|
69127
|
+
const sheet = [...SHAPE_CELLS.filter((cell) => body.includes(`<${cell} `)), ...body.includes("ShapeColumn<") ? ["type ShapeColumn"] : []];
|
|
69128
|
+
const locale = ["useLocaleTag", "useLoticsLocale"].filter((hook) => body.includes(`${hook}(`));
|
|
68361
69129
|
const imports = [
|
|
68362
69130
|
`import { ${sdk.join(", ")} } from "@lotics/app-sdk";`,
|
|
68363
|
-
|
|
68364
|
-
kit("BackButton", "@lotics/ui/back_button"),
|
|
68365
|
-
`import { DetailRow, DetailTable } from "@lotics/ui/detail_row";`,
|
|
69131
|
+
router.length === 0 ? "" : `import { ${router.join(", ")} } from "react-router";`,
|
|
68366
69132
|
shapeImport,
|
|
68367
|
-
|
|
68368
|
-
kit(
|
|
68369
|
-
kit("RecordSummary", "@lotics/ui/record_summary"),
|
|
69133
|
+
locale.length === 0 ? "" : `import { ${locale.join(", ")} } from "@lotics/ui/locale";`,
|
|
69134
|
+
...Object.entries(RECORD_MODULES).map(([component, module2]) => kit(component, module2)),
|
|
68370
69135
|
kit("RegionState", "@lotics/ui/region_state"),
|
|
69136
|
+
body.includes("<Section>") ? `import { Section, SectionHeading, SectionHeadingTitle } from "@lotics/ui/section_heading";` : "",
|
|
69137
|
+
kit("SectionStack", "@lotics/ui/section_stack"),
|
|
69138
|
+
sheet.length === 0 ? "" : `import { ${sheet.join(", ")} } from "@lotics/ui/shape_frame";`,
|
|
69139
|
+
kit("Skeleton", "@lotics/ui/skeleton"),
|
|
68371
69140
|
kit("Stack", "@lotics/ui/stack"),
|
|
68372
|
-
`import {
|
|
69141
|
+
body.includes("<Table ") ? `import { Table, TableCell, TableRow } from "@lotics/ui/table";` : "",
|
|
68373
69142
|
`import { F } from "../../.lotics/app_fields";`,
|
|
68374
69143
|
cells.length === 0 ? "" : `import { ${cells.join(", ")} } from "./cells";`
|
|
68375
69144
|
].filter((line) => line !== "");
|
|
@@ -68433,10 +69202,11 @@ export default function App() {
|
|
|
68433
69202
|
`;
|
|
68434
69203
|
}
|
|
68435
69204
|
function buildPlanFiles(app, bound, roles) {
|
|
69205
|
+
const pages = pagesByEntity(bound, app);
|
|
68436
69206
|
return [
|
|
68437
69207
|
{ path: "src/App.tsx", content: appSource(app, bound) },
|
|
68438
69208
|
{ path: "src/screens/cells.ts", content: CELLS },
|
|
68439
|
-
...bound.map((entry) => ({ path: `src/screens/${entry.screen.screen.alias}.tsx`, content: screenSource(entry, roles) }))
|
|
69209
|
+
...bound.map((entry) => ({ path: `src/screens/${entry.screen.screen.alias}.tsx`, content: screenSource(entry, roles, pages) }))
|
|
68440
69210
|
];
|
|
68441
69211
|
}
|
|
68442
69212
|
|
|
@@ -69327,7 +70097,7 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
|
|
|
69327
70097
|
}
|
|
69328
70098
|
|
|
69329
70099
|
// src/model_reference.md
|
|
69330
|
-
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renames and deletions go through\n `lotics run update_table` / `lotics run delete_table`, never through the file.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ]\n}\n```\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused. Declare\none side only (with no `paired_field_alias`) for a link with no back-reference.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `autonumber`, `select_record_link` | names the row \u2014 the register\'s first column; a link where the row is "the product, at this branch". One per entity |\n| `mark` | `files` | the row\'s picture. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. One per entity |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert` |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing |\n| `amount` | `number`, `formula`, `rollup` | THE signed money of a ledger row. One per entity |\n| `when` | `date` | the ledger or timeline date. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity |\n| `contact` | `text` | the one way to reach a party. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. One per entity |\n\nA bare role name is the common form. A `measure` takes the object form to name\nits limit: `against` \u2014 a `number` field on the same entity, by alias, or a\nconstant \u2014 and `alert`, which side of it needs attention, `over` a capacity or\n`under` a minimum. The two come together.\n\n```jsonc\n"field_roles": {\n "san_pham": { "ten": "identity", "anh": "mark" },\n "ton_kho": { "ton": { "role": "measure", "against": "ton_toi_thieu", "alert": "under" },\n "hieu_suat": { "role": "measure", "against": 80, "alert": "under" } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, and each of its screens as\na SHAPE over an ENTITY. Nothing here is built by the scaffold \u2014 the plan is what\n`lotics scaffold check` prints back, screen by screen with the field in every\nslot, so it is read and corrected before a screen exists.\n\n```jsonc\n"apps": [\n {\n "alias": "kinh_doanh", "name": "Kinh doanh",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screens": [\n { "alias": "khach_hang", "label": "Kh\xE1ch h\xE0ng", "shape": "party_register", "entity": "customer" },\n { "alias": "don_hang", "label": "\u0110\u01A1n h\xE0ng", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 a select on the entity, or null; absent, the shape decides\n "slots": { "identity": "code" } } // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n ]\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `party`, `amount`, `when` | drawer | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `party`, `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `lifecycle` | drawer | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\n`"shape": "custom"` is a screen of its own shape: it declares its slots under\n`roles` (slot \u2192 role) and they bind the same way; the scaffold gives it the\nrows and the slot list, and the screen is composed from the kit by hand.\n\n```jsonc\n{ "alias": "bang_do", "label": "B\u1EA3ng \u0111o", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`apps`** and **`apply`** mean exactly what\n they mean in the full form \u2014 `"rows"` are this business\'s real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "sales",\n "name": "Sales",\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n ]\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 1 app, 2 screens`, then\nthe plan:\n\n```\nSales\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none) \xB7 contact (none) \xB7 worth (none) \xB7 risk (none)\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount \xB7 party Customer \xB7 document (none)\n```\n\nEvery `(none)` is a slot no field fills \u2014 the picture a register has none of,\nthe contact nobody declared. Read it as the screen a person will see.\n';
|
|
70100
|
+
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renames and deletions go through\n `lotics run update_table` / `lotics run delete_table`, never through the file.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ]\n}\n```\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused. Declare\none side only (with no `paired_field_alias`) for a link with no back-reference.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `autonumber`, `select_record_link` | names the row \u2014 the register\'s first column; a link where the row is "the product, at this branch". One per entity |\n| `mark` | `files` | the row\'s picture. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. One per entity |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert` |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing |\n| `amount` | `number`, `formula`, `rollup` | THE signed money of a ledger row. One per entity |\n| `when` | `date` | the ledger or timeline date. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity |\n| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\n| `contact` | `text` | the one way to reach a party. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. One per entity |\n\nA bare role name is the common form. A `measure` takes the object form to name\nits limit: `against` \u2014 a `number` field on the same entity, by alias, or a\nconstant \u2014 and `alert`, which side of it needs attention, `over` a capacity or\n`under` a minimum. The two come together.\n\n```jsonc\n"field_roles": {\n "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "role": "measure", "against": 80, "alert": "under" } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, and each of its screens as\na SHAPE over an ENTITY. Nothing here is built by the scaffold \u2014 the plan is what\n`lotics scaffold check` prints back, screen by screen with the field in every\nslot, so it is read and corrected before a screen exists.\n\n```jsonc\n"apps": [\n {\n "alias": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 a select on the entity, or null; absent, the shape decides\n "slots": { "identity": "code" } } // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n ]\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `mark`, `party`, `amount`, `when` | drawer | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `party`, `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `mark`, `lifecycle` | drawer | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the `identity` as\nthe record\'s name, and on a `page` also the `when` under it and ONE headline\nfigure (the `amount` the screen reads, else its `measure`); the `lifecycle` is\nnot badged there, because the progress section is the rung it stands\non. Then its sections, in order: the **facts** (every field\nneither the header nor another section owns, the row\'s own `parent` among them),\nthe **progress** (the `lifecycle`\'s stages, and what moves the record on), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field, the `mark` first). A child is\nan entity whose `parent` links here. The name and the figure are the header\'s\nalone \u2014 it states both in full, so a fact for either would be the same sentence\ntwice; a `drawer` has only the name. `check` prints the record under each\nscreen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 drawer \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 when Due\n record: header (Order no.) \xB7 facts (Customer \xB7 Total \xB7 Due) \xB7 progress: Stage (5 stages) \xB7 documents: Papers (Kind: 4 required) \xB7 lines: Order lines (Product \xB7 Quantity \xB7 Line total) \xB7 files: Photo\n```\n\nIn that line `documents:` is a `RecordExpectedSet` and `lines:` a `RecordChildren`; a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\n\n`"shape": "custom"` is a screen of its own shape: it declares its slots under\n`roles` (slot \u2192 role) and they bind the same way; the scaffold gives it the\nrows and the slot list, and the screen is composed from the kit by hand.\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`apps`** and **`apply`** mean exactly what\n they mean in the full form \u2014 `"rows"` are this business\'s real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "sales",\n "name": "Sales",\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n ]\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 1 app, 2 screens`, then\nthe plan:\n\n```\nSales\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none) \xB7 contact (none) \xB7 worth (none) \xB7 risk (none)\n record: header (Name) \xB7 facts (Tier \xB7 Orders \xB7 Total ordered)\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount \xB7 party Customer \xB7 document (none)\n record: facts (Order no. \xB7 Placed on \xB7 Amount \xB7 Total with VAT \xB7 Customer)\n```\n\nEvery `(none)` is a slot no field fills \u2014 the picture a register has none of,\nthe contact nobody declared. Read it as the screen a person will see. Each\nrecord is a header and facts here: neither entity carries a stage, a required\nset, files, or rows of its own. The Customers page states its name in the header\nand nowhere else; the Orders drawer has no `identity` to state, so every field\nit reads is a fact.\n';
|
|
69331
70101
|
|
|
69332
70102
|
// src/scaffold_commands.ts
|
|
69333
70103
|
function printModelReference() {
|
|
@@ -69458,6 +70228,39 @@ function resolvePlan(model) {
|
|
|
69458
70228
|
})
|
|
69459
70229
|
);
|
|
69460
70230
|
}
|
|
70231
|
+
function describeRecord(model, resolved) {
|
|
70232
|
+
const labels = (fields) => fields.map((field) => field.label).join(" \xB7 ");
|
|
70233
|
+
const header = recordHeader(resolved);
|
|
70234
|
+
const stated = [header.title, header.subtitle, header.figure].filter(
|
|
70235
|
+
(field) => field !== void 0
|
|
70236
|
+
);
|
|
70237
|
+
const sections = recordSections(resolved.entity, model.contract.entities, model.field_roles, header).map((section) => {
|
|
70238
|
+
switch (section.kind) {
|
|
70239
|
+
case "facts": {
|
|
70240
|
+
const fact = (field) => {
|
|
70241
|
+
const level = section.levels[field.alias];
|
|
70242
|
+
if (level === void 0) return field.label;
|
|
70243
|
+
return `${field.label} against ${typeof level.limit === "number" ? level.limit : level.limit.label}`;
|
|
70244
|
+
};
|
|
70245
|
+
return `facts (${section.fields.map(fact).join(" \xB7 ")})`;
|
|
70246
|
+
}
|
|
70247
|
+
case "progress":
|
|
70248
|
+
return section.field.type === "select" ? `progress: ${section.field.label} (${count(section.field.options.length, "stage")})` : `progress: ${section.field.label}`;
|
|
70249
|
+
case "expected_set": {
|
|
70250
|
+
const required2 = section.source === "own" ? section.field : section.setField;
|
|
70251
|
+
const name2 = section.source === "own" ? section.field.label : section.child.label;
|
|
70252
|
+
if (required2.type !== "select") return `documents: ${name2}`;
|
|
70253
|
+
const set2 = `${required2.options.length} required`;
|
|
70254
|
+
return section.source === "own" ? `documents: ${name2} (${set2})` : `documents: ${name2} (${required2.label}: ${set2})`;
|
|
70255
|
+
}
|
|
70256
|
+
case "children":
|
|
70257
|
+
return `lines: ${section.child.label} (${section.fields.length === 0 ? "none" : labels(section.fields)})`;
|
|
70258
|
+
case "files":
|
|
70259
|
+
return `files: ${labels(section.fields)}`;
|
|
70260
|
+
}
|
|
70261
|
+
});
|
|
70262
|
+
return [...stated.length === 0 ? [] : [`header (${labels(stated)})`], ...sections].join(" \xB7 ");
|
|
70263
|
+
}
|
|
69461
70264
|
function describePlan(model) {
|
|
69462
70265
|
const lines = [];
|
|
69463
70266
|
let lastApp;
|
|
@@ -69474,6 +70277,7 @@ function describePlan(model) {
|
|
|
69474
70277
|
lines.push(
|
|
69475
70278
|
` ${resolved.slots.map((entry) => `${entry.name} ${entry.field === null ? "(none)" : entry.field.label}`).join(" \xB7 ")}`
|
|
69476
70279
|
);
|
|
70280
|
+
lines.push(` record: ${describeRecord(model, resolved)}`);
|
|
69477
70281
|
}
|
|
69478
70282
|
return lines;
|
|
69479
70283
|
}
|
|
@@ -77539,7 +78343,7 @@ async function resolvePlanForCreate(client, from) {
|
|
|
77539
78343
|
if (typeof table_id !== "string" || typeof table_name !== "string") continue;
|
|
77540
78344
|
idsByName.set(table_name, [...idsByName.get(table_name) ?? [], table_id]);
|
|
77541
78345
|
}
|
|
77542
|
-
const names = planTableNames(screens);
|
|
78346
|
+
const names = planTableNames(screens, model.contract.entities, model.field_roles);
|
|
77543
78347
|
const absent = names.filter((name2) => (idsByName.get(name2) ?? []).length === 0);
|
|
77544
78348
|
const twice = names.filter((name2) => (idsByName.get(name2) ?? []).length > 1);
|
|
77545
78349
|
if (absent.length > 0) {
|
|
@@ -77556,7 +78360,7 @@ async function resolvePlanForCreate(client, from) {
|
|
|
77556
78360
|
const live = await client.getWorkspaceSchema(ids);
|
|
77557
78361
|
const unreadable = ids.filter((id) => !live.some((table) => table.id === id));
|
|
77558
78362
|
if (unreadable.length > 0) throw new Error(`could not read table(s) ${unreadable.join(", ")} \u2014 check this key's access.`);
|
|
77559
|
-
const { bound, missing } = bindScreens(screens, live, model.field_roles);
|
|
78363
|
+
const { bound, missing } = bindScreens(screens, live, model.field_roles, model.contract.entities);
|
|
77560
78364
|
if (missing.length > 0) {
|
|
77561
78365
|
throw new Error(
|
|
77562
78366
|
`the plan does not fit this workspace:
|