@unson/brainbase-mcp 0.37.0 → 0.38.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/graph-web-http.d.ts +1 -1
- package/dist/graph-web-http.js +7 -2
- package/dist/graph-web.d.ts +38 -0
- package/dist/graph-web.js +45 -0
- package/dist/local-web-host.js +3 -1
- package/package.json +4 -2
- package/ui/local-web-shell.css +13 -0
- package/ui/local-web-shell.js +59 -9
- package/ui/people-and-companies-view.css +93 -0
- package/ui/people-and-companies-view.js +370 -0
package/dist/graph-web-http.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type GraphWebHttpHandler = (request: IncomingMessage, response: ServerRes
|
|
|
16
16
|
/**
|
|
17
17
|
* Node-style handler for the local Graph read and correction API:
|
|
18
18
|
*
|
|
19
|
-
* - `GET {base}/status`, `GET {base}/projects`, `GET {base}/projects/:id`
|
|
19
|
+
* - `GET {base}/status`, `GET {base}/projects`, `GET {base}/projects/:id`, `GET {base}/people-and-companies`
|
|
20
20
|
* - `GET {base}/search?q=&type=&as_of=&limit=`, `GET {base}/entities/:id`, `GET {base}/ontology`
|
|
21
21
|
* - `POST {base}/corrections`
|
|
22
22
|
*
|
package/dist/graph-web-http.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { applyGraphCorrection, GraphCorrectionError } from './graph-corrections.js';
|
|
2
|
-
import { GraphWebError, listGraphProjects, readGraphEntity, readGraphOntology, readGraphProject, readGraphWebStatus, searchGraphEntities } from './graph-web.js';
|
|
2
|
+
import { GraphWebError, listGraphProjects, readGraphEntity, readGraphOntology, readGraphPeopleAndCompanies, readGraphProject, readGraphWebStatus, searchGraphEntities } from './graph-web.js';
|
|
3
3
|
export const GRAPH_WEB_HTTP_VERSION = 'graph-web-http.v1';
|
|
4
4
|
const DEFAULT_BASE_PATH = '/api/graph';
|
|
5
5
|
// A 256KiB decoded image becomes roughly 350KiB as base64 in JSON. Keep a
|
|
@@ -21,7 +21,7 @@ class HttpError extends Error {
|
|
|
21
21
|
/**
|
|
22
22
|
* Node-style handler for the local Graph read and correction API:
|
|
23
23
|
*
|
|
24
|
-
* - `GET {base}/status`, `GET {base}/projects`, `GET {base}/projects/:id`
|
|
24
|
+
* - `GET {base}/status`, `GET {base}/projects`, `GET {base}/projects/:id`, `GET {base}/people-and-companies`
|
|
25
25
|
* - `GET {base}/search?q=&type=&as_of=&limit=`, `GET {base}/entities/:id`, `GET {base}/ontology`
|
|
26
26
|
* - `POST {base}/corrections`
|
|
27
27
|
*
|
|
@@ -74,6 +74,9 @@ export function createGraphWebHttpHandler(options) {
|
|
|
74
74
|
case 'project':
|
|
75
75
|
writeJson(response, 200, await readGraphProject(dataDir, route.id, readOptions));
|
|
76
76
|
break;
|
|
77
|
+
case 'people':
|
|
78
|
+
writeJson(response, 200, await readGraphPeopleAndCompanies(dataDir, readOptions));
|
|
79
|
+
break;
|
|
77
80
|
case 'search':
|
|
78
81
|
writeJson(response, 200, await searchGraphEntities(dataDir, {
|
|
79
82
|
q: queryValue(url, 'q'),
|
|
@@ -105,6 +108,8 @@ function matchRoute(pathname, basePath) {
|
|
|
105
108
|
return { name: 'status' };
|
|
106
109
|
if (rest === '/projects')
|
|
107
110
|
return { name: 'projects' };
|
|
111
|
+
if (rest === '/people-and-companies')
|
|
112
|
+
return { name: 'people' };
|
|
108
113
|
if (rest === '/search')
|
|
109
114
|
return { name: 'search' };
|
|
110
115
|
if (rest === '/ontology')
|
package/dist/graph-web.d.ts
CHANGED
|
@@ -335,6 +335,44 @@ export interface GraphWebReadOptions {
|
|
|
335
335
|
}
|
|
336
336
|
export declare function readGraphWebStatus(dataDir: string, options?: GraphWebReadOptions): Promise<GraphWebStatus | GraphWebUnavailable>;
|
|
337
337
|
export declare function listGraphProjects(dataDir: string, options?: GraphWebReadOptions): Promise<GraphProjectList | GraphWebUnavailable>;
|
|
338
|
+
export type GraphOwnCompanyReason = 'resolved' | 'owner_not_found' | 'owner_has_no_company' | 'owner_has_several_companies';
|
|
339
|
+
export interface GraphPersonSummaryView extends GraphEntityView {
|
|
340
|
+
type: 'person';
|
|
341
|
+
/** The owner of this Graph (the person whose companies decide the own company). */
|
|
342
|
+
isOwner: boolean;
|
|
343
|
+
/** Active companies of the person (active member_of to an active org), by company name. */
|
|
344
|
+
companyIds: string[];
|
|
345
|
+
/** Distinct active projects with an active participates_in or accountable_for relation. */
|
|
346
|
+
projectCount: number;
|
|
347
|
+
accountableProjectCount: number;
|
|
348
|
+
}
|
|
349
|
+
export interface GraphCompanySummaryView extends GraphEntityView {
|
|
350
|
+
type: 'org';
|
|
351
|
+
/** Active people with an active member_of to the company, by name. */
|
|
352
|
+
memberIds: string[];
|
|
353
|
+
/** Active projects with an active owned_by to the company, by name. */
|
|
354
|
+
projectIds: string[];
|
|
355
|
+
}
|
|
356
|
+
export interface GraphPeopleAndCompanies<S extends GraphWebAnySource = GraphWebSource> {
|
|
357
|
+
status: 'ok';
|
|
358
|
+
source: S;
|
|
359
|
+
asOf: string;
|
|
360
|
+
/**
|
|
361
|
+
* The company the owner belongs to. Null with the reason when the owner has
|
|
362
|
+
* no active company, several, or no record: the screen then does not tell
|
|
363
|
+
* internal from external people. No internal / external mark is stored.
|
|
364
|
+
*/
|
|
365
|
+
ownCompany: {
|
|
366
|
+
id: string | null;
|
|
367
|
+
reason: GraphOwnCompanyReason;
|
|
368
|
+
candidateIds: string[];
|
|
369
|
+
};
|
|
370
|
+
people: GraphPersonSummaryView[];
|
|
371
|
+
companies: GraphCompanySummaryView[];
|
|
372
|
+
/** True only when the readable Graph v2 holds no person and no company. */
|
|
373
|
+
absenceConfirmed: boolean;
|
|
374
|
+
}
|
|
375
|
+
export declare function readGraphPeopleAndCompanies(dataDir: string, options?: GraphWebReadOptions): Promise<GraphPeopleAndCompanies | GraphWebUnavailable>;
|
|
338
376
|
export declare function readGraphProject(dataDir: string, projectId: string, options?: GraphWebReadOptions): Promise<GraphProjectDetail | GraphWebUnavailable>;
|
|
339
377
|
export declare function searchGraphEntities(dataDir: string, input?: GraphEntitySearchInput): Promise<GraphEntitySearchResult | GraphWebUnavailable>;
|
|
340
378
|
export declare function readGraphEntity(dataDir: string, entityId: string, options?: GraphWebReadOptions): Promise<GraphEntityDetail | GraphWebUnavailable>;
|
package/dist/graph-web.js
CHANGED
|
@@ -93,6 +93,51 @@ function projectListOf(graph, source, asOf) {
|
|
|
93
93
|
}).sort(byActiveThenName);
|
|
94
94
|
return { status: 'ok', source, asOf, projects, absenceConfirmed: projects.length === 0 };
|
|
95
95
|
}
|
|
96
|
+
export async function readGraphPeopleAndCompanies(dataDir, options = {}) {
|
|
97
|
+
const loaded = await loadForRead(dataDir);
|
|
98
|
+
if ('status' in loaded)
|
|
99
|
+
return loaded;
|
|
100
|
+
return peopleAndCompaniesOf(loaded.graph, loaded.source, resolveAsOf(options));
|
|
101
|
+
}
|
|
102
|
+
function peopleAndCompaniesOf(graph, source, asOf) {
|
|
103
|
+
const entities = entityIndex(graph);
|
|
104
|
+
const name = (id) => entities.get(id)?.name ?? id;
|
|
105
|
+
const byName = (left, right) => name(left).localeCompare(name(right), 'ja') || left.localeCompare(right, 'en');
|
|
106
|
+
const live = graph.edges.filter((edge) => isActiveAt(edge, asOf)
|
|
107
|
+
&& isEntityActive(entities.get(edge.fromId), asOf) && isEntityActive(entities.get(edge.toId), asOf));
|
|
108
|
+
const membership = live.filter((edge) => edge.relation === 'member_of' && entities.get(edge.toId)?.type === 'org');
|
|
109
|
+
const companiesOf = (personId) => [...new Set(membership.filter((edge) => edge.fromId === personId).map((edge) => edge.toId))].sort(byName);
|
|
110
|
+
const ownerId = graph.owner?.id ?? 'self';
|
|
111
|
+
const owner = entities.get(ownerId);
|
|
112
|
+
const ownCandidates = owner?.type === 'person' && isActiveAt(owner, asOf) ? companiesOf(ownerId) : [];
|
|
113
|
+
const ownCompany = owner?.type !== 'person' || !isActiveAt(owner, asOf)
|
|
114
|
+
? { id: null, reason: 'owner_not_found', candidateIds: [] }
|
|
115
|
+
: ownCandidates.length === 1
|
|
116
|
+
? { id: ownCandidates[0], reason: 'resolved', candidateIds: ownCandidates }
|
|
117
|
+
: { id: null, reason: ownCandidates.length === 0 ? 'owner_has_no_company' : 'owner_has_several_companies', candidateIds: ownCandidates };
|
|
118
|
+
const people = graph.entities.filter((entity) => entity.type === 'person').map((person) => {
|
|
119
|
+
const work = live.filter((edge) => edge.fromId === person.id && PARTICIPATION.has(edge.relation) && entities.get(edge.toId)?.type === 'project');
|
|
120
|
+
return {
|
|
121
|
+
...entityView(person, asOf),
|
|
122
|
+
type: 'person',
|
|
123
|
+
isOwner: person.id === ownerId,
|
|
124
|
+
companyIds: companiesOf(person.id),
|
|
125
|
+
projectCount: new Set(work.map((edge) => edge.toId)).size,
|
|
126
|
+
accountableProjectCount: new Set(work.filter((edge) => edge.relation === 'accountable_for').map((edge) => edge.toId)).size
|
|
127
|
+
};
|
|
128
|
+
}).sort((left, right) => Number(right.active) - Number(left.active) || Number(right.isOwner) - Number(left.isOwner) || byActiveThenName(left, right));
|
|
129
|
+
const companies = graph.entities.filter((entity) => entity.type === 'org').map((company) => ({
|
|
130
|
+
...entityView(company, asOf),
|
|
131
|
+
type: 'org',
|
|
132
|
+
memberIds: [...new Set(membership.filter((edge) => edge.toId === company.id).map((edge) => edge.fromId))]
|
|
133
|
+
.sort((left, right) => Number(left !== ownerId) - Number(right !== ownerId) || byName(left, right)),
|
|
134
|
+
projectIds: [...new Set(live.filter((edge) => edge.relation === 'owned_by' && edge.toId === company.id
|
|
135
|
+
&& entities.get(edge.fromId)?.type === 'project').map((edge) => edge.fromId))].sort(byName)
|
|
136
|
+
})).sort((left, right) => Number(right.active) - Number(left.active)
|
|
137
|
+
|| Number(right.id === ownCompany.id) - Number(left.id === ownCompany.id)
|
|
138
|
+
|| byActiveThenName(left, right));
|
|
139
|
+
return { status: 'ok', source, asOf, ownCompany, people, companies, absenceConfirmed: people.length === 0 && companies.length === 0 };
|
|
140
|
+
}
|
|
96
141
|
export async function readGraphProject(dataDir, projectId, options = {}) {
|
|
97
142
|
const loaded = await loadForRead(dataDir);
|
|
98
143
|
if ('status' in loaded)
|
package/dist/local-web-host.js
CHANGED
|
@@ -882,7 +882,9 @@ export function createGraphWebModule(context) {
|
|
|
882
882
|
'project-graph-entry.js',
|
|
883
883
|
'project-graph-vendor.js',
|
|
884
884
|
'graph-registry-view.js',
|
|
885
|
-
'graph-registry-view.css'
|
|
885
|
+
'graph-registry-view.css',
|
|
886
|
+
'people-and-companies-view.js',
|
|
887
|
+
'people-and-companies-view.css'
|
|
886
888
|
],
|
|
887
889
|
handle: handler
|
|
888
890
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unson/brainbase-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -326,6 +326,8 @@
|
|
|
326
326
|
"./ui/objective-editor-http-port": "./ui/objective-editor-http-port.js",
|
|
327
327
|
"./ui/world-model-view": "./ui/world-model-view.js",
|
|
328
328
|
"./ui/world-model-view.css": "./ui/world-model-view.css",
|
|
329
|
+
"./ui/people-and-companies-view": "./ui/people-and-companies-view.js",
|
|
330
|
+
"./ui/people-and-companies-view.css": "./ui/people-and-companies-view.css",
|
|
329
331
|
"./ui/local-web-shell": "./ui/local-web-shell.js",
|
|
330
332
|
"./ui/local-web-shell.css": "./ui/local-web-shell.css",
|
|
331
333
|
"./ui/workspace-kit": "./ui/workspace-kit.js",
|
|
@@ -479,5 +481,5 @@
|
|
|
479
481
|
"engines": {
|
|
480
482
|
"node": ">=20"
|
|
481
483
|
},
|
|
482
|
-
"gitHead": "
|
|
484
|
+
"gitHead": "dfc88ee2c7c48aa99ae8f4157addbac69e92c7af"
|
|
483
485
|
}
|
package/ui/local-web-shell.css
CHANGED
|
@@ -70,6 +70,19 @@ body {
|
|
|
70
70
|
gap: 2px;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
.bb-shell-nav-group {
|
|
74
|
+
display: grid;
|
|
75
|
+
gap: 2px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.bb-shell-nav-group-head {
|
|
79
|
+
margin: 14px 10px 4px;
|
|
80
|
+
color: var(--bb-color-rail-muted);
|
|
81
|
+
font-size: 11px;
|
|
82
|
+
font-weight: 700;
|
|
83
|
+
letter-spacing: .04em;
|
|
84
|
+
}
|
|
85
|
+
|
|
73
86
|
.bb-shell-nav-link {
|
|
74
87
|
min-height: 38px;
|
|
75
88
|
display: flex;
|
package/ui/local-web-shell.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { createGraphProjectsView } from './graph-projects-view.js';
|
|
19
19
|
import { createGraphRegistryView } from './graph-registry-view.js';
|
|
20
|
+
import { createPeopleAndCompaniesView } from './people-and-companies-view.js';
|
|
20
21
|
import { createObjectiveEditorController } from './objective-editor.js';
|
|
21
22
|
import { createObjectiveEditorHttpPort } from './objective-editor-http-port.js';
|
|
22
23
|
import { createJudgmentHistoryUI } from './judgment-history.js';
|
|
@@ -42,18 +43,52 @@ const UNREADABLE_LABELS = Object.freeze({
|
|
|
42
43
|
|
|
43
44
|
// Stroke icons from the organization edition's sprite (apps/web/public/index.html).
|
|
44
45
|
const NAV_ICON_PATHS = Object.freeze({
|
|
45
|
-
'value-proofs': ['
|
|
46
|
+
'value-proofs': ['M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', 'M3 3v5h5', 'M12 7v5l4 2'],
|
|
46
47
|
objectives: ['M12 4 3 20h18z', 'M12 9v5m0 3h.01'],
|
|
47
48
|
projects: ['M3 6.5h7l2 2h9v10H3z', 'M3 6.5v-2h7l2 2'],
|
|
48
49
|
graph: ['M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.2 1.2', 'M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.2-1.2'],
|
|
49
50
|
minutes: ['M6 3.5h9l3 3V20.5H6z', 'M15 3.5v4h3', 'M9 12h6m-6 3h6'],
|
|
51
|
+
// Two people (the organization edition's 人と会社 uses the same shape).
|
|
52
|
+
people: ['M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z', 'M2.5 20a6.5 6.5 0 0 1 13 0', 'M16 4.5a3.5 3.5 0 0 1 0 6.5', 'M18 14a6.5 6.5 0 0 1 3.5 6'],
|
|
50
53
|
// World view (extension id `world`, ledger P17).
|
|
51
54
|
world: ['M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z', 'M3 12h18', 'M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z'],
|
|
52
55
|
});
|
|
56
|
+
// Screens without their own icon (for example a private product's extension).
|
|
57
|
+
const DEFAULT_NAV_ICON_PATHS = Object.freeze(['M5 20V10m7 10V4m7 16v-7']);
|
|
53
58
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
54
59
|
|
|
60
|
+
// The menu's areas (brainbase-project ledger D10): the same order and headings
|
|
61
|
+
// as the organization edition. 目的と現状 is the only item of its area, so it
|
|
62
|
+
// has no heading. Inside an area, `order` lists the screens first in that order.
|
|
63
|
+
const NAV_AREAS = Object.freeze([
|
|
64
|
+
Object.freeze({ id: 'purpose', label: null, order: ['objectives'] }),
|
|
65
|
+
Object.freeze({ id: 'work', label: '仕事', order: ['projects', 'minutes'] }),
|
|
66
|
+
Object.freeze({ id: 'outcomes', label: '成果', order: ['value-proofs'] }),
|
|
67
|
+
Object.freeze({ id: 'information', label: '情報と関係', order: ['graph', 'people'] }),
|
|
68
|
+
Object.freeze({ id: 'settings', label: '設定', order: [] }),
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
// Menu order only: the screens' own order (and so the first screen) stays as given.
|
|
72
|
+
// Screens without an area keep their place — those before the first area-bound
|
|
73
|
+
// screen come first (a leading extension such as the world), the rest come last.
|
|
74
|
+
function navLayout(screens) {
|
|
75
|
+
const firstInArea = screens.findIndex((screen) => NAV_AREAS.some((area) => area.id === screen.area));
|
|
76
|
+
if (firstInArea === -1) return { leading: [...screens], areas: [], trailing: [] };
|
|
77
|
+
const leading = screens.slice(0, firstInArea);
|
|
78
|
+
const trailing = screens.slice(firstInArea).filter((screen) => !NAV_AREAS.some((area) => area.id === screen.area));
|
|
79
|
+
const areas = NAV_AREAS.map((area) => {
|
|
80
|
+
const members = screens.filter((screen) => screen.area === area.id);
|
|
81
|
+
const rank = (screen) => {
|
|
82
|
+
const index = area.order.indexOf(screen.id);
|
|
83
|
+
return index === -1 ? area.order.length + members.indexOf(screen) : index;
|
|
84
|
+
};
|
|
85
|
+
return { area, screens: [...members].sort((a, b) => rank(a) - rank(b)) };
|
|
86
|
+
}).filter((entry) => entry.screens.length > 0);
|
|
87
|
+
return { leading, areas, trailing };
|
|
88
|
+
}
|
|
89
|
+
|
|
55
90
|
function makeIcon(doc, id) {
|
|
56
|
-
const paths = NAV_ICON_PATHS[id] ??
|
|
91
|
+
const paths = NAV_ICON_PATHS[id] ?? DEFAULT_NAV_ICON_PATHS;
|
|
57
92
|
const create = (tag) => (typeof doc.createElementNS === 'function' ? doc.createElementNS(SVG_NS, tag) : doc.createElement(tag));
|
|
58
93
|
const svg = create('svg');
|
|
59
94
|
svg.setAttribute('class', 'bb-shell-icon');
|
|
@@ -252,11 +287,12 @@ function mountGraphScreen(screenId, createView) {
|
|
|
252
287
|
}
|
|
253
288
|
|
|
254
289
|
export const LOCAL_WEB_SCREENS = Object.freeze([
|
|
255
|
-
Object.freeze({ id: 'value-proofs', label: '判断の履歴', usesGraph: false, rail: true, source: '判断の記録', mount: mountToday }),
|
|
256
|
-
Object.freeze({ id: 'minutes', label: '議事録', usesGraph: false, rail: true, source: '正本の議事録', mount: mountMeetingMinutes }),
|
|
257
|
-
Object.freeze({ id: 'objectives', label: '目的と現状', usesGraph: true, rail: true, source: '手元のGraph', mount: mountObjectives }),
|
|
258
|
-
Object.freeze({ id: 'projects', label: 'プロジェクトと関係者', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('projects', createGraphProjectsView) }),
|
|
259
|
-
Object.freeze({ id: 'graph', label: '情報と関係', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('graph', createGraphRegistryView) }),
|
|
290
|
+
Object.freeze({ id: 'value-proofs', label: '判断の履歴', area: 'outcomes', usesGraph: false, rail: true, source: '判断の記録', mount: mountToday }),
|
|
291
|
+
Object.freeze({ id: 'minutes', label: '議事録', area: 'work', usesGraph: false, rail: true, source: '正本の議事録', mount: mountMeetingMinutes }),
|
|
292
|
+
Object.freeze({ id: 'objectives', label: '目的と現状', area: 'purpose', usesGraph: true, rail: true, source: '手元のGraph', mount: mountObjectives }),
|
|
293
|
+
Object.freeze({ id: 'projects', label: 'プロジェクトと関係者', area: 'work', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('projects', createGraphProjectsView) }),
|
|
294
|
+
Object.freeze({ id: 'graph', label: '情報と関係', area: 'information', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('graph', createGraphRegistryView) }),
|
|
295
|
+
Object.freeze({ id: 'people', label: '人と会社', area: 'information', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('people', createPeopleAndCompaniesView) }),
|
|
260
296
|
]);
|
|
261
297
|
|
|
262
298
|
function renderGraphGate(doc, slot, status, onRecheck, label) {
|
|
@@ -373,7 +409,7 @@ export function createLocalWebShell({
|
|
|
373
409
|
};
|
|
374
410
|
menuButton.addEventListener('click', () => setMenuOpen(!sidebar.className.includes('is-open')));
|
|
375
411
|
backdrop.addEventListener('click', () => setMenuOpen(false));
|
|
376
|
-
|
|
412
|
+
const navLink = (screen) => {
|
|
377
413
|
const link = makeElement(doc, 'a', { className: 'bb-shell-nav-link', text: screen.label, attrs: { href: `#${screen.id}` } });
|
|
378
414
|
// The label stays the link's own text; the icon goes in front where the DOM allows it.
|
|
379
415
|
if (typeof link.prepend === 'function') link.prepend(makeIcon(doc, screen.id));
|
|
@@ -382,8 +418,22 @@ export function createLocalWebShell({
|
|
|
382
418
|
controller.show(screen.id);
|
|
383
419
|
});
|
|
384
420
|
links.set(screen.id, link);
|
|
385
|
-
|
|
421
|
+
return link;
|
|
422
|
+
};
|
|
423
|
+
const layout = navLayout(screens);
|
|
424
|
+
for (const screen of layout.leading) nav.append(navLink(screen));
|
|
425
|
+
for (const { area, screens: members } of layout.areas) {
|
|
426
|
+
if (!area.label) {
|
|
427
|
+
for (const screen of members) nav.append(navLink(screen));
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const headId = `bb-shell-nav-area-${area.id}`;
|
|
431
|
+
const group = makeElement(doc, 'div', { className: 'bb-shell-nav-group', attrs: { role: 'group', 'aria-labelledby': headId } });
|
|
432
|
+
group.append(makeElement(doc, 'div', { className: 'bb-shell-nav-group-head', text: area.label, attrs: { id: headId } }));
|
|
433
|
+
for (const screen of members) group.append(navLink(screen));
|
|
434
|
+
nav.append(group);
|
|
386
435
|
}
|
|
436
|
+
for (const screen of layout.trailing) nav.append(navLink(screen));
|
|
387
437
|
const source = makeElement(doc, 'section', { className: 'bb-shell-source', attrs: { 'aria-label': '出典' } });
|
|
388
438
|
sidebar.append(brand, nav, source);
|
|
389
439
|
const workspace = makeElement(doc, 'div', { className: 'bb-shell-workspace' });
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 「人と会社」. Load after workspace-kit.css and graph-view-shared.css.
|
|
3
|
+
* Column templates of the people and company ledgers, the group titles and
|
|
4
|
+
* the relations in the right rail, with a 760px variant.
|
|
5
|
+
* Uses only the shared tokens in brainbase-tokens.css.
|
|
6
|
+
*/
|
|
7
|
+
.bb-pc-people,
|
|
8
|
+
.bb-pc-companies {
|
|
9
|
+
margin-bottom: 30px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.bb-graph .bb-pc-group-title {
|
|
13
|
+
margin: 22px 0 10px;
|
|
14
|
+
color: var(--bb-color-ink);
|
|
15
|
+
font-size: 13px;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.bb-graph .bb-pc-company-title {
|
|
19
|
+
margin: 14px 0 8px;
|
|
20
|
+
color: var(--bb-color-muted);
|
|
21
|
+
font-size: 12px;
|
|
22
|
+
font-weight: 650;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.bb-graph .bb-pc-empty,
|
|
26
|
+
.bb-graph .bb-pc-ended,
|
|
27
|
+
.bb-graph-rail .bb-pc-empty {
|
|
28
|
+
margin: 0 0 10px;
|
|
29
|
+
color: var(--bb-color-muted);
|
|
30
|
+
font-size: 12px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.bb-pc-people-ledger .bb-ws-ledger-row {
|
|
34
|
+
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.bb-pc-company-ledger .bb-ws-ledger-row {
|
|
38
|
+
grid-template-columns: minmax(0, 1.4fr) 96px 96px minmax(0, 1fr);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.bb-pc-relation-list {
|
|
42
|
+
margin: 0;
|
|
43
|
+
padding: 0;
|
|
44
|
+
list-style: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.bb-pc-relation {
|
|
48
|
+
display: grid;
|
|
49
|
+
gap: 4px;
|
|
50
|
+
padding: 8px 0;
|
|
51
|
+
border-bottom: 1px solid var(--bb-color-border);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.bb-pc-relation.is-ended {
|
|
55
|
+
opacity: .62;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.bb-pc-relation-head {
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: 8px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.bb-pc-relation-meta {
|
|
65
|
+
color: var(--bb-color-muted);
|
|
66
|
+
font-size: 12px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.bb-graph-rail .bb-pc-fix {
|
|
70
|
+
display: grid;
|
|
71
|
+
gap: 6px;
|
|
72
|
+
margin: 16px 0 0;
|
|
73
|
+
color: var(--bb-color-muted);
|
|
74
|
+
font-size: 12px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@media (max-width: 760px) {
|
|
78
|
+
.bb-pc-people-ledger .bb-ws-ledger-row {
|
|
79
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 84px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.bb-pc-people-ledger .bb-ws-ledger-row > :nth-child(4) {
|
|
83
|
+
display: none;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.bb-pc-company-ledger .bb-ws-ledger-row {
|
|
87
|
+
grid-template-columns: minmax(0, 1fr) 64px 64px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.bb-pc-company-ledger .bb-ws-ledger-row > :nth-child(4) {
|
|
91
|
+
display: none;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
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: there is no correction here; the rail
|
|
8
|
+
* points to 「情報と関係」, where the same record is corrected.
|
|
9
|
+
*
|
|
10
|
+
* Internal and external are not stored anywhere. They come from the
|
|
11
|
+
* company memberships (`member_of`) and the own company the host answers.
|
|
12
|
+
* When the own company is not known, every person is shown as not yet known
|
|
13
|
+
* instead of being guessed. Ended records stay out of the ledgers and are
|
|
14
|
+
* only counted.
|
|
15
|
+
*
|
|
16
|
+
* The host injects the fetcher, base path, right rail and page context; this
|
|
17
|
+
* module keeps no global state.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
badge,
|
|
22
|
+
createGraphClient,
|
|
23
|
+
createGraphLayout,
|
|
24
|
+
getDocument,
|
|
25
|
+
graphHostEmptyNotice,
|
|
26
|
+
graphPageContext,
|
|
27
|
+
graphStateNotice,
|
|
28
|
+
isEdgeView,
|
|
29
|
+
isEntityView,
|
|
30
|
+
isRecord,
|
|
31
|
+
makeElement,
|
|
32
|
+
provenanceSummary,
|
|
33
|
+
relationLabel,
|
|
34
|
+
textOrNull,
|
|
35
|
+
validityLabel,
|
|
36
|
+
} from './graph-view-shared.js';
|
|
37
|
+
import {
|
|
38
|
+
workspaceDefinition,
|
|
39
|
+
workspaceDetailEmpty,
|
|
40
|
+
workspaceHostNotice,
|
|
41
|
+
workspaceLedger,
|
|
42
|
+
workspaceNotice,
|
|
43
|
+
workspacePageHeader,
|
|
44
|
+
workspaceRailBlock,
|
|
45
|
+
workspaceRailHead,
|
|
46
|
+
workspaceSectionTitle,
|
|
47
|
+
} from './workspace-kit.js';
|
|
48
|
+
|
|
49
|
+
export const PEOPLE_AND_COMPANIES_VIEW_CONTRACT_VERSION = 'brainbase.people-and-companies-view.v1';
|
|
50
|
+
export const PEOPLE_LEDGER_COLUMNS = Object.freeze(['名前', '会社', '関わるプロジェクト', '有効期間']);
|
|
51
|
+
export const COMPANY_LEDGER_COLUMNS = Object.freeze(['会社', '所属する人', 'プロジェクト', '有効期間']);
|
|
52
|
+
|
|
53
|
+
const INVALID = Object.freeze({ state: 'invalid', reason: '応答の形式が不正です' });
|
|
54
|
+
|
|
55
|
+
const OWN_COMPANY_REASONS = Object.freeze({
|
|
56
|
+
owner_has_no_company: 'あなた(本人)の所属する会社がGraphにありません。',
|
|
57
|
+
owner_has_several_companies: 'あなた(本人)が複数の会社に所属しているため、自社を1つに決められません。',
|
|
58
|
+
owner_not_found: 'Graphに、あなた(本人)の記録がありません。',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function isIdList(value) {
|
|
62
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The read is `ok` only when every person and company is well formed. */
|
|
66
|
+
export function normalizePeopleAndCompanies(result) {
|
|
67
|
+
if (result?.state !== 'ok') return result ?? INVALID;
|
|
68
|
+
const payload = result.payload;
|
|
69
|
+
const own = payload.ownCompany;
|
|
70
|
+
if (!isRecord(own) || !(own.id === null || typeof own.id === 'string') || typeof own.reason !== 'string' || !isIdList(own.candidateIds)) return INVALID;
|
|
71
|
+
if (!Array.isArray(payload.people) || !payload.people.every((person) => isEntityView(person)
|
|
72
|
+
&& isIdList(person.companyIds) && Number.isInteger(person.projectCount) && Number.isInteger(person.accountableProjectCount))) return INVALID;
|
|
73
|
+
if (!Array.isArray(payload.companies) || !payload.companies.every((company) => isEntityView(company)
|
|
74
|
+
&& isIdList(company.memberIds) && isIdList(company.projectIds))) return INVALID;
|
|
75
|
+
if (payload.people.length === 0 && payload.companies.length === 0 && payload.absenceConfirmed !== true) return { state: 'unknown' };
|
|
76
|
+
return { state: 'ok', payload };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Splits the active people into internal, external by company and not yet
|
|
81
|
+
* known. Without an own company every active person is not yet known.
|
|
82
|
+
*/
|
|
83
|
+
export function groupPeople(payload) {
|
|
84
|
+
const ownId = payload.ownCompany?.id ?? null;
|
|
85
|
+
const companies = new Map(payload.companies.map((company) => [company.id, company]));
|
|
86
|
+
const active = payload.people.filter((person) => person.active);
|
|
87
|
+
const groups = { ownCompanyId: ownId, internal: [], external: [], unknown: [] };
|
|
88
|
+
if (!ownId) {
|
|
89
|
+
groups.unknown = active;
|
|
90
|
+
return groups;
|
|
91
|
+
}
|
|
92
|
+
const byCompany = new Map();
|
|
93
|
+
for (const person of active) {
|
|
94
|
+
if (person.companyIds.includes(ownId)) {
|
|
95
|
+
groups.internal.push(person);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const companyId = person.companyIds.find((id) => companies.has(id));
|
|
99
|
+
if (!companyId) {
|
|
100
|
+
groups.unknown.push(person);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!byCompany.has(companyId)) byCompany.set(companyId, []);
|
|
104
|
+
byCompany.get(companyId).push(person);
|
|
105
|
+
}
|
|
106
|
+
groups.external = [...byCompany.entries()]
|
|
107
|
+
.map(([companyId, people]) => ({ company: companies.get(companyId), people }))
|
|
108
|
+
.sort((left, right) => left.company.name.localeCompare(right.company.name, 'ja') || left.company.id.localeCompare(right.company.id, 'en'));
|
|
109
|
+
return groups;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether a person is internal, external or not yet known, in plain Japanese. */
|
|
113
|
+
export function personStanding(person, ownCompanyId) {
|
|
114
|
+
if (!ownCompanyId) return '所属未確認(自社が未設定)';
|
|
115
|
+
if (person.companyIds.includes(ownCompanyId)) return '社内';
|
|
116
|
+
return person.companyIds.length > 0 ? '社外' : '所属未確認';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createPeopleAndCompaniesView({
|
|
120
|
+
root,
|
|
121
|
+
rail,
|
|
122
|
+
page,
|
|
123
|
+
document: explicitDocument,
|
|
124
|
+
fetcher,
|
|
125
|
+
basePath = '/api/graph',
|
|
126
|
+
autoLoad = true,
|
|
127
|
+
initialEntityId,
|
|
128
|
+
now = () => new Date(),
|
|
129
|
+
sourceNotice,
|
|
130
|
+
emptyNotice,
|
|
131
|
+
correctionHref = (id) => `#graph?entity_id=${encodeURIComponent(id)}`,
|
|
132
|
+
} = {}) {
|
|
133
|
+
if (!root) throw new TypeError('root is required');
|
|
134
|
+
const doc = getDocument(explicitDocument);
|
|
135
|
+
const client = createGraphClient({ fetcher, basePath });
|
|
136
|
+
const context = graphPageContext(page);
|
|
137
|
+
const layout = createGraphLayout(doc, {
|
|
138
|
+
root,
|
|
139
|
+
rail,
|
|
140
|
+
label: '人と会社',
|
|
141
|
+
className: 'bb-pc',
|
|
142
|
+
contractVersion: PEOPLE_AND_COMPANIES_VIEW_CONTRACT_VERSION,
|
|
143
|
+
});
|
|
144
|
+
const state = { list: { state: 'loading' }, detail: null };
|
|
145
|
+
|
|
146
|
+
const nameOf = (id) => {
|
|
147
|
+
if (state.list.state !== 'ok') return id;
|
|
148
|
+
const { people, companies } = state.list.payload;
|
|
149
|
+
return (people.find((item) => item.id === id) ?? companies.find((item) => item.id === id))?.name ?? id;
|
|
150
|
+
};
|
|
151
|
+
const asOf = () => (state.list.state === 'ok' ? state.list.payload.asOf : now().toISOString());
|
|
152
|
+
|
|
153
|
+
// -------------------------------------------------------------------------
|
|
154
|
+
// Workspace
|
|
155
|
+
|
|
156
|
+
function personRows(people) {
|
|
157
|
+
const ownId = state.list.payload.ownCompany.id;
|
|
158
|
+
return people.map((person) => ({
|
|
159
|
+
key: person.id,
|
|
160
|
+
selected: person.id === state.detail?.id,
|
|
161
|
+
onSelect: (id) => void controller.openEntity(id),
|
|
162
|
+
cells: [
|
|
163
|
+
{ primary: person.isOwner ? `${person.name}(あなた)` : person.name, secondary: person.id },
|
|
164
|
+
person.companyIds.length > 0 ? person.companyIds.map(nameOf).join('、') : '所属の記録なし',
|
|
165
|
+
person.accountableProjectCount > 0
|
|
166
|
+
? `${person.projectCount}件(うち責任 ${person.accountableProjectCount}件)`
|
|
167
|
+
: `${person.projectCount}件`,
|
|
168
|
+
validityLabel(person, asOf()),
|
|
169
|
+
],
|
|
170
|
+
label: `${person.name}、${personStanding(person, ownId)}`,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function peopleLedger(label, people, empty) {
|
|
175
|
+
return workspaceLedger(doc, {
|
|
176
|
+
className: 'bb-pc-people-ledger',
|
|
177
|
+
ariaLabel: label,
|
|
178
|
+
columns: PEOPLE_LEDGER_COLUMNS,
|
|
179
|
+
empty,
|
|
180
|
+
rows: personRows(people),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function peopleSection(payload) {
|
|
185
|
+
const section = makeElement(doc, 'section', { className: 'bb-pc-people', attrs: { 'aria-label': '人' } });
|
|
186
|
+
section.append(workspaceSectionTitle(doc, { title: '人', lead: '社内か社外かは、所属(会社への所属の関係)と自社から決めています。印は別に持っていません。' }));
|
|
187
|
+
const groups = groupPeople(payload);
|
|
188
|
+
if (!groups.ownCompanyId) {
|
|
189
|
+
const reason = OWN_COMPANY_REASONS[payload.ownCompany.reason] ?? '自社を決められません。';
|
|
190
|
+
section.append(workspaceNotice(doc, {
|
|
191
|
+
label: '自社が未設定',
|
|
192
|
+
tone: 'warning',
|
|
193
|
+
text: `${reason}推測で社内・社外を分けず、全員を所属未確認に置いています。あなたの記録に会社への所属を加えると、社内と社外に分かれます。`,
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
if (groups.ownCompanyId) {
|
|
197
|
+
section.append(makeElement(doc, 'h3', { className: 'bb-pc-group-title', text: `社内(${nameOf(groups.ownCompanyId)})` }));
|
|
198
|
+
section.append(peopleLedger('社内の人', groups.internal, '自社に所属する人の記録はありません。'));
|
|
199
|
+
section.append(makeElement(doc, 'h3', { className: 'bb-pc-group-title', text: '社外' }));
|
|
200
|
+
if (groups.external.length === 0) section.append(makeElement(doc, 'p', { className: 'bb-pc-empty', text: 'ほかの会社に所属する人の記録はありません。' }));
|
|
201
|
+
for (const group of groups.external) {
|
|
202
|
+
section.append(makeElement(doc, 'h4', { className: 'bb-pc-company-title', text: group.company.name }));
|
|
203
|
+
section.append(peopleLedger(`${group.company.name}の人`, group.people));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
section.append(makeElement(doc, 'h3', { className: 'bb-pc-group-title', text: '所属未確認' }));
|
|
207
|
+
section.append(peopleLedger('所属未確認の人', groups.unknown, '所属が分からない人はいません。'));
|
|
208
|
+
return section;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function companySection(payload) {
|
|
212
|
+
const section = makeElement(doc, 'section', { className: 'bb-pc-companies', attrs: { 'aria-label': '会社' } });
|
|
213
|
+
section.append(workspaceSectionTitle(doc, { title: '会社', lead: '自社を先頭に、Graphに登録された会社を並べています。' }));
|
|
214
|
+
const active = payload.companies.filter((company) => company.active);
|
|
215
|
+
section.append(workspaceLedger(doc, {
|
|
216
|
+
className: 'bb-pc-company-ledger',
|
|
217
|
+
ariaLabel: '会社の一覧',
|
|
218
|
+
columns: COMPANY_LEDGER_COLUMNS,
|
|
219
|
+
empty: '会社の記録はありません。',
|
|
220
|
+
rows: active.map((company) => ({
|
|
221
|
+
key: company.id,
|
|
222
|
+
selected: company.id === state.detail?.id,
|
|
223
|
+
onSelect: (id) => void controller.openEntity(id),
|
|
224
|
+
cells: [
|
|
225
|
+
{ primary: company.id === payload.ownCompany.id ? `${company.name}(自社)` : company.name, secondary: company.id },
|
|
226
|
+
`${company.memberIds.length}人`,
|
|
227
|
+
`${company.projectIds.length}件`,
|
|
228
|
+
validityLabel(company, payload.asOf),
|
|
229
|
+
],
|
|
230
|
+
})),
|
|
231
|
+
}));
|
|
232
|
+
return section;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function endedNote(payload) {
|
|
236
|
+
const people = payload.people.filter((person) => !person.active).length;
|
|
237
|
+
const companies = payload.companies.filter((company) => !company.active).length;
|
|
238
|
+
if (people === 0 && companies === 0) return null;
|
|
239
|
+
return makeElement(doc, 'p', {
|
|
240
|
+
className: 'bb-pc-ended',
|
|
241
|
+
text: `終わった記録(人 ${people}件、会社 ${companies}件)は一覧に出していません。「情報と関係」で見られます。`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function workspace() {
|
|
246
|
+
const children = [workspacePageHeader(doc, {
|
|
247
|
+
crumbs: context.crumbs,
|
|
248
|
+
title: '人と会社',
|
|
249
|
+
lead: 'Graphに登録された人が、どの会社の人か、社内か社外か所属が分からないかを確かめます。',
|
|
250
|
+
source: context.source,
|
|
251
|
+
})];
|
|
252
|
+
const hostNotice = sourceNotice ? workspaceHostNotice(doc, sourceNotice) : null;
|
|
253
|
+
if (hostNotice) children.push(hostNotice);
|
|
254
|
+
const notice = graphStateNotice(doc, state.list, { onRetry: () => void controller.load() });
|
|
255
|
+
if (notice) return [...children, notice];
|
|
256
|
+
const payload = state.list.payload;
|
|
257
|
+
if (payload.absenceConfirmed) {
|
|
258
|
+
return [...children, graphHostEmptyNotice(doc, emptyNotice)
|
|
259
|
+
?? workspaceNotice(doc, { label: '未登録', tone: 'info', role: 'status', text: 'Graphに人と会社の記録はまだありません。' })];
|
|
260
|
+
}
|
|
261
|
+
return [...children, peopleSection(payload), companySection(payload), endedNote(payload)];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// -------------------------------------------------------------------------
|
|
265
|
+
// Rail
|
|
266
|
+
|
|
267
|
+
function edgeList(edges, empty) {
|
|
268
|
+
if (edges.length === 0) return makeElement(doc, 'p', { className: 'bb-pc-empty', text: empty });
|
|
269
|
+
const list = makeElement(doc, 'ul', { className: 'bb-pc-relation-list' });
|
|
270
|
+
for (const edge of edges) {
|
|
271
|
+
const item = makeElement(doc, 'li', { className: `bb-pc-relation${edge.active ? '' : ' is-ended'}` });
|
|
272
|
+
const head = makeElement(doc, 'div', { className: 'bb-pc-relation-head' });
|
|
273
|
+
head.append(makeElement(doc, 'strong', { text: edge.counterpart.name }), badge(doc, relationLabel(edge.relation), 'muted'));
|
|
274
|
+
const source = provenanceSummary(edge.provenance);
|
|
275
|
+
item.append(
|
|
276
|
+
head,
|
|
277
|
+
makeElement(doc, 'span', { className: 'bb-pc-relation-meta', text: [
|
|
278
|
+
validityLabel(edge, asOf()),
|
|
279
|
+
textOrNull(edge.role) ? `役割: ${edge.role}` : null,
|
|
280
|
+
`出典: ${source.kind}${source.reference ? `(${source.reference})` : ''}`,
|
|
281
|
+
].filter(Boolean).join(' ') }),
|
|
282
|
+
);
|
|
283
|
+
list.append(item);
|
|
284
|
+
}
|
|
285
|
+
return list;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function railContent() {
|
|
289
|
+
const detail = state.detail;
|
|
290
|
+
if (!detail) {
|
|
291
|
+
return [workspaceDetailEmpty(doc, { mark: '人', title: '人か会社を選んでください', text: '所属と関わるプロジェクトを、有効期間と出典つきで確かめられます。' })];
|
|
292
|
+
}
|
|
293
|
+
const notice = graphStateNotice(doc, detail, { onRetry: () => void controller.openEntity(detail.id) });
|
|
294
|
+
if (notice) return [notice];
|
|
295
|
+
const { entity, outgoing, incoming } = detail.payload;
|
|
296
|
+
const listed = state.list.state === 'ok'
|
|
297
|
+
? state.list.payload.people.find((person) => person.id === entity.id)
|
|
298
|
+
: null;
|
|
299
|
+
const ownId = state.list.state === 'ok' ? state.list.payload.ownCompany.id : null;
|
|
300
|
+
const blocks = [workspaceRailHead(doc, {
|
|
301
|
+
kicker: entity.type === 'org' ? '会社' : '人',
|
|
302
|
+
title: entity.name,
|
|
303
|
+
sub: entity.id,
|
|
304
|
+
lead: textOrNull(entity.summary) ?? undefined,
|
|
305
|
+
})];
|
|
306
|
+
blocks.push(workspaceDefinition(doc, [
|
|
307
|
+
...(entity.type === 'person' && listed ? [['区分', personStanding(listed, ownId)]] : []),
|
|
308
|
+
...(entity.type === 'org' && entity.id === ownId ? [['区分', '自社']] : []),
|
|
309
|
+
['有効期間', validityLabel(entity, asOf())],
|
|
310
|
+
]));
|
|
311
|
+
if (entity.type === 'org') {
|
|
312
|
+
blocks.push(workspaceRailBlock(doc, { title: '所属する人', content: edgeList(incoming.filter((edge) => edge.relation === 'member_of'), '所属する人の記録はありません。') }));
|
|
313
|
+
blocks.push(workspaceRailBlock(doc, { title: 'プロジェクト', content: edgeList(incoming.filter((edge) => edge.relation === 'owned_by'), 'この会社のプロジェクトの記録はありません。') }));
|
|
314
|
+
} else {
|
|
315
|
+
blocks.push(workspaceRailBlock(doc, { title: '所属', content: edgeList(outgoing.filter((edge) => edge.relation === 'member_of'), '会社への所属の記録はありません。') }));
|
|
316
|
+
blocks.push(workspaceRailBlock(doc, {
|
|
317
|
+
title: '関わるプロジェクト',
|
|
318
|
+
content: edgeList(outgoing.filter((edge) => edge.relation === 'participates_in' || edge.relation === 'accountable_for'), '関わるプロジェクトの記録はありません。'),
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
const fix = makeElement(doc, 'p', { className: 'bb-pc-fix' });
|
|
322
|
+
fix.append(
|
|
323
|
+
makeElement(doc, 'span', { text: '間違いは「情報と関係」で、この記録を開いて直します。' }),
|
|
324
|
+
makeElement(doc, 'a', { text: '情報と関係で開く', attrs: { href: correctionHref(entity.id) } }),
|
|
325
|
+
);
|
|
326
|
+
blocks.push(fix);
|
|
327
|
+
return blocks;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// -------------------------------------------------------------------------
|
|
331
|
+
// Controller
|
|
332
|
+
|
|
333
|
+
const controller = Object.freeze({
|
|
334
|
+
get state() {
|
|
335
|
+
return state;
|
|
336
|
+
},
|
|
337
|
+
render() {
|
|
338
|
+
layout.render(workspace(), railContent());
|
|
339
|
+
},
|
|
340
|
+
async load() {
|
|
341
|
+
state.list = { state: 'loading' };
|
|
342
|
+
controller.render();
|
|
343
|
+
state.list = normalizePeopleAndCompanies(await client.read('/people-and-companies'));
|
|
344
|
+
controller.render();
|
|
345
|
+
if (initialEntityId && !state.detail) await controller.openEntity(initialEntityId);
|
|
346
|
+
},
|
|
347
|
+
async openEntity(id) {
|
|
348
|
+
state.detail = { id, state: 'loading' };
|
|
349
|
+
controller.render();
|
|
350
|
+
const result = await client.read(`/entities/${encodeURIComponent(id)}`);
|
|
351
|
+
if (state.detail?.id !== id) return;
|
|
352
|
+
state.detail = { id, ...normalizeEntityDetail(result) };
|
|
353
|
+
controller.render();
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (autoLoad) void controller.load();
|
|
358
|
+
else controller.render();
|
|
359
|
+
return controller;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function normalizeEntityDetail(result) {
|
|
363
|
+
if (result?.state !== 'ok') return result ?? INVALID;
|
|
364
|
+
const payload = result.payload;
|
|
365
|
+
if (!isEntityView(payload.entity)) return INVALID;
|
|
366
|
+
for (const key of ['outgoing', 'incoming']) {
|
|
367
|
+
if (!Array.isArray(payload[key]) || !payload[key].every(isEdgeView)) return INVALID;
|
|
368
|
+
}
|
|
369
|
+
return { state: 'ok', payload };
|
|
370
|
+
}
|