@unson/brainbase-mcp 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,596 @@
1
+ /*
2
+ * 「人と会社」: who the people in the Graph are, which company each belongs
3
+ * to, and whether they are internal, external or not yet known.
4
+ *
5
+ * Reads `GET {base}/people-and-companies` for the ledgers and
6
+ * `GET {base}/entities/:id` for the selected record in the right rail (see
7
+ * `graph-view-shared.js`). Read only unless the host says it can write
8
+ * (`canCorrect: true`): then it creates people and companies
9
+ * (`create_entity`), adds a company membership (`create_edge` of
10
+ * `member_of`) and ends one with an end date (`update_edge`), all through
11
+ * `POST {base}/corrections`. Nothing is deleted. After a save the ledgers
12
+ * and the rail are read again from the host. Other corrections stay in
13
+ * 「情報と関係」, which the rail links to.
14
+ *
15
+ * The company text recorded on a person (`orgNote`) is shown only for
16
+ * people whose company is not yet known, and only as a note: it never
17
+ * decides a membership, the own company or internal / external.
18
+ *
19
+ * A company's kind (顧客・協業先・提案中) is what the host derives from the
20
+ * company attributes (`orgType`, `lifecycle_stage`), the roles of its
21
+ * `related_to` relations and its recorded deals; a company without such a
22
+ * record shows no kind. Its projects are split into its own (`owned_by`)
23
+ * and the ones it is involved in (`related_to`, with the role).
24
+ *
25
+ * Internal and external are not stored anywhere. They come from the
26
+ * company memberships (`member_of`) and the own company the host answers.
27
+ * When the own company is not known, every person is shown as not yet known
28
+ * instead of being guessed. Ended records stay out of the ledgers and are
29
+ * only counted.
30
+ *
31
+ * The host injects the fetcher, base path, right rail and page context; this
32
+ * module keeps no global state.
33
+ */
34
+
35
+ import {
36
+ badge,
37
+ button,
38
+ createGraphClient,
39
+ createGraphCorrection,
40
+ createGraphLayout,
41
+ getDocument,
42
+ graphHostEmptyNotice,
43
+ graphPageContext,
44
+ graphStateNotice,
45
+ isEdgeView,
46
+ isEntityView,
47
+ isRecord,
48
+ makeElement,
49
+ provenanceSummary,
50
+ readOnlyGraphClient,
51
+ graphCorrectionScope,
52
+ relationLabel,
53
+ textOrNull,
54
+ validityLabel,
55
+ } from './graph-view-shared.js';
56
+ import {
57
+ workspaceDefinition,
58
+ workspaceDetailEmpty,
59
+ workspaceHostNotice,
60
+ workspaceLedger,
61
+ workspaceNotice,
62
+ workspacePageHeader,
63
+ workspaceRailBlock,
64
+ workspaceRailHead,
65
+ workspaceSectionTitle,
66
+ } from './workspace-kit.js';
67
+
68
+ export const PEOPLE_AND_COMPANIES_VIEW_CONTRACT_VERSION = 'brainbase.people-and-companies-view.v1';
69
+ export const PEOPLE_LEDGER_COLUMNS = Object.freeze(['名前', '会社', '関わるプロジェクト', '有効期間']);
70
+ export const COMPANY_LEDGER_COLUMNS = Object.freeze(['会社', '種類', '所属する人', 'プロジェクト', '有効期間']);
71
+
72
+ /** Plain labels of the company kinds the host derives, and of where each came from. */
73
+ export const COMPANY_KIND_LABELS = Object.freeze({ customer: '顧客', partner: '協業先', prospect: '提案中' });
74
+ const COMPANY_KIND_SOURCE_LABELS = Object.freeze({
75
+ orgType: '会社の属性',
76
+ lifecycle_stage: '会社の段階',
77
+ related_to: 'プロジェクトとの関係の役割',
78
+ deals: '提案中の案件の記録',
79
+ });
80
+ /** Plain labels of the recorded related_to roles; another role shows as recorded. */
81
+ const RELATED_ROLE_LABELS = Object.freeze({ customer: '顧客', partner: '協業先' });
82
+
83
+ const INVALID = Object.freeze({ state: 'invalid', reason: '応答の形式が不正です' });
84
+
85
+ const OWN_COMPANY_REASONS = Object.freeze({
86
+ owner_has_no_company: 'あなた(本人)の所属する会社がGraphにありません。',
87
+ owner_has_several_companies: 'あなた(本人)が複数の会社に所属しているため、自社を1つに決められません。',
88
+ owner_not_found: 'Graphに、あなた(本人)の記録がありません。',
89
+ });
90
+
91
+ function isIdList(value) {
92
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
93
+ }
94
+
95
+ const isTextOrNull = (value) => value === null || typeof value === 'string';
96
+
97
+ function isCompanySummary(company) {
98
+ return isEntityView(company) && isIdList(company.memberIds) && isIdList(company.projectIds)
99
+ && Array.isArray(company.relatedProjects) && company.relatedProjects.every((item) => isRecord(item) && typeof item.projectId === 'string' && isTextOrNull(item.role))
100
+ && isTextOrNull(company.orgType) && isTextOrNull(company.lifecycleStage)
101
+ && Array.isArray(company.deals) && company.deals.every((deal) => isRecord(deal) && isTextOrNull(deal.name) && isTextOrNull(deal.stage))
102
+ && Array.isArray(company.kinds) && company.kinds.every((item) => isRecord(item) && typeof item.kind === 'string' && isIdList(item.sources));
103
+ }
104
+
105
+ /** The company's kinds in plain Japanese (e.g. 顧客・協業先), or '' when none is recorded. */
106
+ export function companyKindLabel(company) {
107
+ return (company?.kinds ?? []).map((item) => ownKey(COMPANY_KIND_LABELS, item.kind) ?? item.kind).join('・');
108
+ }
109
+
110
+ /** A related_to role in plain Japanese; an unknown role shows as recorded. */
111
+ export function relatedRoleLabel(role) {
112
+ const text = textOrNull(role);
113
+ return text ? ownKey(RELATED_ROLE_LABELS, text.toLowerCase()) ?? text : null;
114
+ }
115
+
116
+ function ownKey(table, key) {
117
+ return typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : undefined;
118
+ }
119
+
120
+ /** The read is `ok` only when every person and company is well formed. */
121
+ export function normalizePeopleAndCompanies(result) {
122
+ if (result?.state !== 'ok') return result ?? INVALID;
123
+ const payload = result.payload;
124
+ const own = payload.ownCompany;
125
+ if (!isRecord(own) || !(own.id === null || typeof own.id === 'string') || typeof own.reason !== 'string' || !isIdList(own.candidateIds)) return INVALID;
126
+ if (!Array.isArray(payload.people) || !payload.people.every((person) => isEntityView(person)
127
+ && isIdList(person.companyIds) && Number.isInteger(person.projectCount) && Number.isInteger(person.accountableProjectCount)
128
+ && (person.orgNote === undefined || isTextOrNull(person.orgNote)))) return INVALID;
129
+ if (!Array.isArray(payload.companies) || !payload.companies.every(isCompanySummary)) return INVALID;
130
+ if (payload.people.length === 0 && payload.companies.length === 0 && payload.absenceConfirmed !== true) return { state: 'unknown' };
131
+ return { state: 'ok', payload };
132
+ }
133
+
134
+ /**
135
+ * Splits the active people into internal, external by company and not yet
136
+ * known. Without an own company every active person is not yet known.
137
+ */
138
+ export function groupPeople(payload) {
139
+ const ownId = payload.ownCompany?.id ?? null;
140
+ const companies = new Map(payload.companies.map((company) => [company.id, company]));
141
+ const active = payload.people.filter((person) => person.active);
142
+ const groups = { ownCompanyId: ownId, internal: [], external: [], unknown: [] };
143
+ if (!ownId) {
144
+ groups.unknown = active;
145
+ return groups;
146
+ }
147
+ const byCompany = new Map();
148
+ for (const person of active) {
149
+ if (person.companyIds.includes(ownId)) {
150
+ groups.internal.push(person);
151
+ continue;
152
+ }
153
+ const companyId = person.companyIds.find((id) => companies.has(id));
154
+ if (!companyId) {
155
+ groups.unknown.push(person);
156
+ continue;
157
+ }
158
+ if (!byCompany.has(companyId)) byCompany.set(companyId, []);
159
+ byCompany.get(companyId).push(person);
160
+ }
161
+ groups.external = [...byCompany.entries()]
162
+ .map(([companyId, people]) => ({ company: companies.get(companyId), people }))
163
+ .sort((left, right) => left.company.name.localeCompare(right.company.name, 'ja') || left.company.id.localeCompare(right.company.id, 'en'));
164
+ return groups;
165
+ }
166
+
167
+ /** Whether a person is internal, external or not yet known, in plain Japanese. */
168
+ export function personStanding(person, ownCompanyId) {
169
+ if (!ownCompanyId) return '所属未確認(自社が未設定)';
170
+ if (person.companyIds.includes(ownCompanyId)) return '社内';
171
+ return person.companyIds.length > 0 ? '社外' : '所属未確認';
172
+ }
173
+
174
+ /** The company text recorded on a person whose company is not yet known, or null. */
175
+ export function personOrgNote(person, ownCompanyId) {
176
+ const note = textOrNull(person?.orgNote);
177
+ if (!note) return null;
178
+ const standing = personStanding(person, ownCompanyId);
179
+ return standing === '社内' || standing === '社外' ? null : note;
180
+ }
181
+
182
+ /**
183
+ * @param {object} options
184
+ * @param {boolean} [options.canCorrect=false] Whether the host lets this viewer write. Only then
185
+ * 人を加える, 会社を加える, 所属を付ける, 所属する人を加える and 所属を終える are drawn; otherwise the
186
+ * client refuses every correction before sending it. The host decides it (for example from the
187
+ * viewer's role); this part holds no role name.
188
+ * @param {{ entityTypes?: string[], edges?: boolean, createEdges?: boolean, createEntities?: boolean }} [options.correctionScope]
189
+ * With writes allowed, only these (see `graphCorrectionScope`): `createEntities: false` withholds
190
+ * 人を加える・会社を加える, `createEdges: false` withholds 所属を付ける・所属する人を加える, `edges: false`
191
+ * withholds 所属を終える, and `entityTypes` limits the kinds that may be created.
192
+ * @param {string} [options.token] Launch token sent with a correction.
193
+ * @param {string} [options.tokenHeader] Header name for the token.
194
+ */
195
+ export function createPeopleAndCompaniesView({
196
+ root,
197
+ rail,
198
+ page,
199
+ document: explicitDocument,
200
+ fetcher,
201
+ basePath = '/api/graph',
202
+ token,
203
+ tokenHeader,
204
+ autoLoad = true,
205
+ initialEntityId,
206
+ now = () => new Date(),
207
+ canCorrect = false,
208
+ correctionScope,
209
+ sourceNotice,
210
+ emptyNotice,
211
+ correctionHref = (id) => `#graph?entity_id=${encodeURIComponent(id)}`,
212
+ } = {}) {
213
+ if (!root) throw new TypeError('root is required');
214
+ const doc = getDocument(explicitDocument);
215
+ const graphClient = createGraphClient({ fetcher, basePath, token, ...(tokenHeader ? { tokenHeader } : {}) });
216
+ const writable = canCorrect === true;
217
+ const client = writable ? graphClient : readOnlyGraphClient(graphClient);
218
+ const scope = graphCorrectionScope(correctionScope);
219
+ const can = Object.freeze({
220
+ createPerson: writable && scope.createEntities && scope.entity('person'),
221
+ createCompany: writable && scope.createEntities && scope.entity('org'),
222
+ addMembership: writable && scope.createEdges,
223
+ endMembership: writable && scope.edges,
224
+ });
225
+ const context = graphPageContext(page);
226
+ const layout = createGraphLayout(doc, {
227
+ root,
228
+ rail,
229
+ label: '人と会社',
230
+ className: 'bb-pc',
231
+ contractVersion: PEOPLE_AND_COMPANIES_VIEW_CONTRACT_VERSION,
232
+ });
233
+ const state = { list: { state: 'loading' }, detail: null };
234
+ const correction = createGraphCorrection({
235
+ client,
236
+ now,
237
+ scope: correctionScope,
238
+ rerender: () => controller.render(),
239
+ onSaved: async (payload, form) => {
240
+ // Read the saved state back from the host, never from the form.
241
+ const id = form.kind === 'create_entity' ? payload.record?.id : state.detail?.id;
242
+ await controller.load({ keepDetail: true });
243
+ if (id) await controller.openEntity(id, { keepCorrection: true });
244
+ },
245
+ onConflict: async () => {
246
+ if (state.detail) await controller.openEntity(state.detail.id, { keepCorrection: true });
247
+ },
248
+ });
249
+
250
+ const nameOf = (id) => {
251
+ if (state.list.state !== 'ok') return id;
252
+ const { people, companies } = state.list.payload;
253
+ return (people.find((item) => item.id === id) ?? companies.find((item) => item.id === id))?.name ?? id;
254
+ };
255
+ const asOf = () => (state.list.state === 'ok' ? state.list.payload.asOf : now().toISOString());
256
+
257
+ // -------------------------------------------------------------------------
258
+ // Workspace
259
+
260
+ function personRows(people) {
261
+ const ownId = state.list.payload.ownCompany.id;
262
+ return people.map((person) => ({
263
+ key: person.id,
264
+ selected: person.id === state.detail?.id,
265
+ onSelect: (id) => void controller.openEntity(id),
266
+ cells: [
267
+ { primary: person.isOwner ? `${person.name}(あなた)` : person.name, secondary: person.id },
268
+ person.companyIds.length > 0 ? person.companyIds.map(nameOf).join('、') : companyCell(person, ownId),
269
+ person.accountableProjectCount > 0
270
+ ? `${person.projectCount}件(うち責任 ${person.accountableProjectCount}件)`
271
+ : `${person.projectCount}件`,
272
+ validityLabel(person, asOf()),
273
+ ],
274
+ label: `${person.name}、${personStanding(person, ownId)}`,
275
+ }));
276
+ }
277
+
278
+ function orgNoteText(note) {
279
+ return `記録上のメモ: ${note}(所属ではありません)`;
280
+ }
281
+
282
+ function companyCell(person, ownId) {
283
+ const note = personOrgNote(person, ownId);
284
+ if (!note) return '所属の記録なし';
285
+ const cell = makeElement(doc, 'span', { className: 'bb-pc-company-cell' });
286
+ cell.append(
287
+ makeElement(doc, 'span', { text: '所属の記録なし' }),
288
+ makeElement(doc, 'small', { className: 'bb-pc-org-note', text: orgNoteText(note) }),
289
+ );
290
+ return cell;
291
+ }
292
+
293
+ function sectionActions(label, onClick) {
294
+ const actions = makeElement(doc, 'div', { className: 'bb-pc-actions' });
295
+ actions.append(button(doc, label, onClick));
296
+ return actions;
297
+ }
298
+
299
+ function openCreateEntity(type) {
300
+ correction.openCreateEntity({
301
+ types: [type],
302
+ typeNames: { person: '人', org: '会社' },
303
+ title: type === 'org' ? '会社を加える' : '人を加える',
304
+ subject: type === 'org' ? 'Graphに会社の記録を作ります。' : 'Graphに人の記録を作ります。',
305
+ });
306
+ }
307
+
308
+ function peopleLedger(label, people, empty) {
309
+ return workspaceLedger(doc, {
310
+ className: 'bb-pc-people-ledger',
311
+ ariaLabel: label,
312
+ columns: PEOPLE_LEDGER_COLUMNS,
313
+ empty,
314
+ rows: personRows(people),
315
+ });
316
+ }
317
+
318
+ function peopleSection(payload) {
319
+ const section = makeElement(doc, 'section', { className: 'bb-pc-people', attrs: { 'aria-label': '人' } });
320
+ section.append(workspaceSectionTitle(doc, { title: '人', lead: '社内か社外かは、所属(会社への所属の関係)と自社から決めています。印は別に持っていません。' }));
321
+ if (can.createPerson) section.append(sectionActions('人を加える', () => openCreateEntity('person')));
322
+ const groups = groupPeople(payload);
323
+ if (!groups.ownCompanyId) {
324
+ const reason = OWN_COMPANY_REASONS[payload.ownCompany.reason] ?? '自社を決められません。';
325
+ section.append(workspaceNotice(doc, {
326
+ label: '自社が未設定',
327
+ tone: 'warning',
328
+ text: `${reason}推測で社内・社外を分けず、全員を所属未確認に置いています。あなたの記録に会社への所属を加えると、社内と社外に分かれます。`,
329
+ }));
330
+ }
331
+ if (groups.ownCompanyId) {
332
+ section.append(makeElement(doc, 'h3', { className: 'bb-pc-group-title', text: `社内(${nameOf(groups.ownCompanyId)})` }));
333
+ section.append(peopleLedger('社内の人', groups.internal, '自社に所属する人の記録はありません。'));
334
+ section.append(makeElement(doc, 'h3', { className: 'bb-pc-group-title', text: '社外' }));
335
+ if (groups.external.length === 0) section.append(makeElement(doc, 'p', { className: 'bb-pc-empty', text: 'ほかの会社に所属する人の記録はありません。' }));
336
+ for (const group of groups.external) {
337
+ section.append(makeElement(doc, 'h4', { className: 'bb-pc-company-title', text: group.company.name }));
338
+ section.append(peopleLedger(`${group.company.name}の人`, group.people));
339
+ }
340
+ }
341
+ section.append(makeElement(doc, 'h3', { className: 'bb-pc-group-title', text: '所属未確認' }));
342
+ section.append(peopleLedger('所属未確認の人', groups.unknown, '所属が分からない人はいません。'));
343
+ return section;
344
+ }
345
+
346
+ function companySection(payload) {
347
+ const section = makeElement(doc, 'section', { className: 'bb-pc-companies', attrs: { 'aria-label': '会社' } });
348
+ section.append(workspaceSectionTitle(doc, {
349
+ title: '会社',
350
+ lead: '自社を先頭に、顧客・協業先など関わる会社を並べています。種類は、会社の属性、プロジェクトとの関係の役割、提案中の案件の記録から出しています。記録が無い会社には種類を出しません。',
351
+ }));
352
+ if (can.createCompany) section.append(sectionActions('会社を加える', () => openCreateEntity('org')));
353
+ const active = payload.companies.filter((company) => company.active);
354
+ section.append(workspaceLedger(doc, {
355
+ className: 'bb-pc-company-ledger',
356
+ ariaLabel: '会社の一覧',
357
+ columns: COMPANY_LEDGER_COLUMNS,
358
+ empty: '会社の記録はありません。',
359
+ rows: active.map((company) => ({
360
+ key: company.id,
361
+ selected: company.id === state.detail?.id,
362
+ onSelect: (id) => void controller.openEntity(id),
363
+ cells: [
364
+ { primary: company.id === payload.ownCompany.id ? `${company.name}(自社)` : company.name, secondary: company.id },
365
+ companyKindLabel(company),
366
+ `${company.memberIds.length}人`,
367
+ company.relatedProjects.length > 0
368
+ ? `${company.projectIds.length}件(関わる ${company.relatedProjects.length}件)`
369
+ : `${company.projectIds.length}件`,
370
+ validityLabel(company, payload.asOf),
371
+ ],
372
+ })),
373
+ }));
374
+ return section;
375
+ }
376
+
377
+ function endedNote(payload) {
378
+ const people = payload.people.filter((person) => !person.active).length;
379
+ const companies = payload.companies.filter((company) => !company.active).length;
380
+ if (people === 0 && companies === 0) return null;
381
+ return makeElement(doc, 'p', {
382
+ className: 'bb-pc-ended',
383
+ text: `終わった記録(人 ${people}件、会社 ${companies}件)は一覧に出していません。「情報と関係」で見られます。`,
384
+ });
385
+ }
386
+
387
+ function workspace() {
388
+ const children = [workspacePageHeader(doc, {
389
+ crumbs: context.crumbs,
390
+ title: '人と会社',
391
+ lead: 'Graphに登録された人が、どの会社の人か、社内か社外か所属が分からないかを確かめます。',
392
+ source: context.source,
393
+ })];
394
+ const hostNotice = sourceNotice ? workspaceHostNotice(doc, sourceNotice) : null;
395
+ if (hostNotice) children.push(hostNotice);
396
+ const notice = graphStateNotice(doc, state.list, { onRetry: () => void controller.load() });
397
+ if (notice) return [...children, notice];
398
+ const payload = state.list.payload;
399
+ if (payload.absenceConfirmed) {
400
+ return [...children, graphHostEmptyNotice(doc, emptyNotice)
401
+ ?? workspaceNotice(doc, { label: '未登録', tone: 'info', role: 'status', text: 'Graphに人と会社の記録はまだありません。' })];
402
+ }
403
+ return [...children, peopleSection(payload), companySection(payload), endedNote(payload)];
404
+ }
405
+
406
+ // -------------------------------------------------------------------------
407
+ // Rail
408
+
409
+ function edgeList(edges, empty, { roleLabel = textOrNull, actions } = {}) {
410
+ if (edges.length === 0) return makeElement(doc, 'p', { className: 'bb-pc-empty', text: empty });
411
+ const list = makeElement(doc, 'ul', { className: 'bb-pc-relation-list' });
412
+ for (const edge of edges) {
413
+ const item = makeElement(doc, 'li', { className: `bb-pc-relation${edge.active ? '' : ' is-ended'}` });
414
+ const head = makeElement(doc, 'div', { className: 'bb-pc-relation-head' });
415
+ head.append(makeElement(doc, 'strong', { text: edge.counterpart.name }), badge(doc, relationLabel(edge.relation), 'muted'));
416
+ const source = provenanceSummary(edge.provenance);
417
+ item.append(
418
+ head,
419
+ makeElement(doc, 'span', { className: 'bb-pc-relation-meta', text: [
420
+ validityLabel(edge, asOf()),
421
+ roleLabel(edge.role) ? `役割: ${roleLabel(edge.role)}` : null,
422
+ `出典: ${source.kind}${source.reference ? `(${source.reference})` : ''}`,
423
+ ].filter(Boolean).join(' ') }),
424
+ );
425
+ const controls = actions?.(edge) ?? [];
426
+ if (controls.length > 0) {
427
+ const row = makeElement(doc, 'div', { className: 'bb-pc-relation-actions' });
428
+ row.append(...controls);
429
+ item.append(row);
430
+ }
431
+ list.append(item);
432
+ }
433
+ return list;
434
+ }
435
+
436
+ function dealList(deals) {
437
+ const list = makeElement(doc, 'ul', { className: 'bb-pc-relation-list' });
438
+ for (const deal of deals) {
439
+ const item = makeElement(doc, 'li', { className: 'bb-pc-relation' });
440
+ item.append(makeElement(doc, 'strong', { text: deal.name ?? '名前の記録が無い案件' }));
441
+ if (deal.stage) item.append(makeElement(doc, 'span', { className: 'bb-pc-relation-meta', text: `段階: ${deal.stage}` }));
442
+ list.append(item);
443
+ }
444
+ return list;
445
+ }
446
+
447
+ function membershipActions(edge, entity) {
448
+ if (!can.endMembership || !edge.active) return [];
449
+ const person = entity.type === 'org' ? edge.counterpart.name : entity.name;
450
+ const company = entity.type === 'org' ? entity.name : edge.counterpart.name;
451
+ return [button(doc, '所属を終える', () => correction.openEdge(edge, {
452
+ mode: 'end',
453
+ title: '所属を終える',
454
+ subject: `${person}の「${company}」への所属に終了日を入れます。記録は消さずに残ります。`,
455
+ }))];
456
+ }
457
+
458
+ function addMembershipButton(entity) {
459
+ if (!can.addMembership) return null;
460
+ const label = entity.type === 'org' ? '所属する人を加える' : '所属を付ける';
461
+ return sectionActions(label, () => correction.openCreate(
462
+ { id: entity.id, type: entity.type, name: entity.name },
463
+ {
464
+ relations: ['member_of'],
465
+ relationNames: { member_of: '所属' },
466
+ relationLabel: '関わり方',
467
+ title: label,
468
+ subject: entity.type === 'org' ? `「${entity.name}」に所属する人を選びます。` : `${entity.name}が所属する会社を選びます。`,
469
+ },
470
+ ));
471
+ }
472
+
473
+ function railContent() {
474
+ const panel = correction.render(doc);
475
+ const blocks = railBlocks();
476
+ if (!panel) return blocks;
477
+ // A new record's form shows first; a membership form right after the record's head.
478
+ if (correction.form?.kind === 'create_entity' || !state.detail || state.detail.state !== 'ok') return [panel, ...blocks];
479
+ return [blocks[0], panel, ...blocks.slice(1)];
480
+ }
481
+
482
+ function railBlocks() {
483
+ const detail = state.detail;
484
+ if (!detail) {
485
+ return [workspaceDetailEmpty(doc, { mark: '人', title: '人か会社を選んでください', text: '所属と関わるプロジェクトを、有効期間と出典つきで確かめられます。' })];
486
+ }
487
+ const notice = graphStateNotice(doc, detail, { onRetry: () => void controller.openEntity(detail.id) });
488
+ if (notice) return [notice];
489
+ const { entity, outgoing, incoming } = detail.payload;
490
+ const listed = state.list.state === 'ok'
491
+ ? state.list.payload.people.find((person) => person.id === entity.id)
492
+ : null;
493
+ const ownId = state.list.state === 'ok' ? state.list.payload.ownCompany.id : null;
494
+ const company = state.list.state === 'ok' && entity.type === 'org'
495
+ ? state.list.payload.companies.find((item) => item.id === entity.id)
496
+ : null;
497
+ const blocks = [workspaceRailHead(doc, {
498
+ kicker: entity.type === 'org' ? '会社' : '人',
499
+ title: entity.name,
500
+ sub: entity.id,
501
+ lead: textOrNull(entity.summary) ?? undefined,
502
+ })];
503
+ blocks.push(workspaceDefinition(doc, [
504
+ ...(entity.type === 'person' && listed ? [['区分', personStanding(listed, ownId)]] : []),
505
+ ...(entity.type === 'person' && listed && personOrgNote(listed, ownId)
506
+ ? [['記録上のメモ', `${personOrgNote(listed, ownId)}(人物の記録に文字で残っている会社名です。所属の記録ではありません)`]]
507
+ : []),
508
+ ...(entity.type === 'org' && entity.id === ownId ? [['区分', '自社']] : []),
509
+ ...(company && company.kinds.length > 0 ? [['種類', company.kinds.map((item) => {
510
+ const sources = item.sources.map((source) => ownKey(COMPANY_KIND_SOURCE_LABELS, source) ?? source).join('・');
511
+ return `${ownKey(COMPANY_KIND_LABELS, item.kind) ?? item.kind}(${sources}から)`;
512
+ }).join('、')]] : []),
513
+ ...(company?.lifecycleStage ? [['記録上の段階', company.lifecycleStage]] : []),
514
+ ['有効期間', validityLabel(entity, asOf())],
515
+ ]));
516
+ if (entity.type === 'org') {
517
+ blocks.push(workspaceRailBlock(doc, { title: '所属する人', content: [
518
+ edgeList(incoming.filter((edge) => edge.relation === 'member_of'), '所属する人の記録はありません。', { actions: (edge) => membershipActions(edge, entity) }),
519
+ addMembershipButton(entity),
520
+ ] }));
521
+ blocks.push(workspaceRailBlock(doc, { title: 'この会社のプロジェクト', content: edgeList(incoming.filter((edge) => edge.relation === 'owned_by'), 'この会社のプロジェクトの記録はありません。') }));
522
+ blocks.push(workspaceRailBlock(doc, {
523
+ title: '顧客・協業先として関わるプロジェクト',
524
+ content: edgeList(outgoing.filter((edge) => edge.relation === 'related_to'), '顧客・協業先として関わるプロジェクトの記録はありません。', { roleLabel: relatedRoleLabel }),
525
+ }));
526
+ if (company && company.deals.length > 0) blocks.push(workspaceRailBlock(doc, { title: '提案中の案件', content: dealList(company.deals) }));
527
+ } else {
528
+ blocks.push(workspaceRailBlock(doc, { title: '所属', content: [
529
+ edgeList(outgoing.filter((edge) => edge.relation === 'member_of'), '会社への所属の記録はありません。', { actions: (edge) => membershipActions(edge, entity) }),
530
+ addMembershipButton(entity),
531
+ ] }));
532
+ blocks.push(workspaceRailBlock(doc, {
533
+ title: '関わるプロジェクト',
534
+ content: edgeList(outgoing.filter((edge) => edge.relation === 'participates_in' || edge.relation === 'accountable_for'), '関わるプロジェクトの記録はありません。'),
535
+ }));
536
+ }
537
+ const fix = makeElement(doc, 'p', { className: 'bb-pc-fix' });
538
+ fix.append(
539
+ makeElement(doc, 'span', { text: writable
540
+ ? '名前・別名・要約などの間違いは「情報と関係」で、この記録を開いて直します。'
541
+ : '間違いは「情報と関係」で、この記録を開いて直します。' }),
542
+ makeElement(doc, 'a', { text: '情報と関係で開く', attrs: { href: correctionHref(entity.id) } }),
543
+ );
544
+ blocks.push(fix);
545
+ return blocks;
546
+ }
547
+
548
+ // -------------------------------------------------------------------------
549
+ // Controller
550
+
551
+ const controller = Object.freeze({
552
+ get state() {
553
+ return state;
554
+ },
555
+ get correction() {
556
+ return correction;
557
+ },
558
+ render() {
559
+ layout.render(workspace(), railContent());
560
+ },
561
+ /** Reads the ledgers. `keepDetail` keeps the open record (and the shown ledgers) while reading. */
562
+ async load({ keepDetail = false } = {}) {
563
+ if (!keepDetail || state.list.state !== 'ok') {
564
+ state.list = { state: 'loading' };
565
+ controller.render();
566
+ }
567
+ state.list = normalizePeopleAndCompanies(await client.read('/people-and-companies'));
568
+ controller.render();
569
+ if (initialEntityId && !state.detail) await controller.openEntity(initialEntityId);
570
+ },
571
+ /** Opens a record in the rail. Choosing another record closes an open form unless `keepCorrection`. */
572
+ async openEntity(id, { keepCorrection = false } = {}) {
573
+ if (!keepCorrection && correction.form && state.detail?.id !== id) correction.close();
574
+ state.detail = { id, state: 'loading' };
575
+ controller.render();
576
+ const result = await client.read(`/entities/${encodeURIComponent(id)}`);
577
+ if (state.detail?.id !== id) return;
578
+ state.detail = { id, ...normalizeEntityDetail(result) };
579
+ controller.render();
580
+ },
581
+ });
582
+
583
+ if (autoLoad) void controller.load();
584
+ else controller.render();
585
+ return controller;
586
+ }
587
+
588
+ function normalizeEntityDetail(result) {
589
+ if (result?.state !== 'ok') return result ?? INVALID;
590
+ const payload = result.payload;
591
+ if (!isEntityView(payload.entity)) return INVALID;
592
+ for (const key of ['outgoing', 'incoming']) {
593
+ if (!Array.isArray(payload[key]) || !payload[key].every(isEdgeView)) return INVALID;
594
+ }
595
+ return { state: 'ok', payload };
596
+ }