@unson/brainbase-mcp 0.38.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.
- package/dist/graph-corrections.d.ts +16 -1
- package/dist/graph-corrections.js +81 -2
- package/dist/graph-maintenance-engine.js +20 -3
- package/dist/graph-revision-reader.js +1 -0
- package/dist/graph-web.d.ts +35 -0
- package/dist/graph-web.js +76 -11
- package/dist/relation-registry.js +8 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +2 -2
- package/dist/templates.js +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +2 -2
- package/ui/graph-view-shared.js +87 -7
- package/ui/local-web-shell.js +3 -2
- package/ui/people-and-companies-view.css +33 -5
- package/ui/people-and-companies-view.js +246 -20
|
@@ -14,12 +14,16 @@ export declare const GRAPH_CORRECTION_ENTITY_FIELDS: readonly ["name", "aliases"
|
|
|
14
14
|
export declare const GRAPH_CORRECTION_PROJECT_FIELDS: readonly ["goal", "status", "icon"];
|
|
15
15
|
export declare const GRAPH_CORRECTION_EDGE_FIELDS: readonly ["role", "context", "validTo"];
|
|
16
16
|
export declare const GRAPH_CORRECTION_NEW_EDGE_RELATIONS: readonly ["participates_in", "accountable_for", "member_of"];
|
|
17
|
+
/** Kinds a correction may create (people and companies only). */
|
|
18
|
+
export declare const GRAPH_CORRECTION_NEW_ENTITY_TYPES: readonly ["person", "org"];
|
|
19
|
+
export declare const GRAPH_CORRECTION_NEW_ENTITY_FIELDS: readonly ["type", "name", "aliases", "summary", "validFrom"];
|
|
17
20
|
/** MCP tools that read the same graph.json on every call and therefore see a saved correction next time. */
|
|
18
21
|
export declare const GRAPH_CORRECTION_APPLIES_TO: readonly ["search", "get_context", "resolve_entity"];
|
|
19
22
|
/** Decoded bytes, shared with the browser-side project-icon helper. */
|
|
20
23
|
export declare const PROJECT_ICON_MAX_BYTES: number;
|
|
21
|
-
export type GraphCorrectionKind = 'update_entity' | 'update_edge' | 'create_edge';
|
|
24
|
+
export type GraphCorrectionKind = 'update_entity' | 'update_edge' | 'create_edge' | 'create_entity';
|
|
22
25
|
export type GraphNewEdgeRelation = typeof GRAPH_CORRECTION_NEW_EDGE_RELATIONS[number];
|
|
26
|
+
export type GraphNewEntityType = typeof GRAPH_CORRECTION_NEW_ENTITY_TYPES[number];
|
|
23
27
|
export interface GraphEntityCorrectionChanges {
|
|
24
28
|
name?: string;
|
|
25
29
|
aliases?: string[];
|
|
@@ -47,6 +51,13 @@ export interface GraphEdgeCreation {
|
|
|
47
51
|
context?: string;
|
|
48
52
|
validFrom?: string;
|
|
49
53
|
}
|
|
54
|
+
export interface GraphEntityCreation {
|
|
55
|
+
type: GraphNewEntityType;
|
|
56
|
+
name: string;
|
|
57
|
+
aliases?: string[];
|
|
58
|
+
summary?: string;
|
|
59
|
+
validFrom?: string;
|
|
60
|
+
}
|
|
50
61
|
export type GraphCorrectionInput = {
|
|
51
62
|
kind: 'update_entity';
|
|
52
63
|
entityId: string;
|
|
@@ -63,6 +74,10 @@ export type GraphCorrectionInput = {
|
|
|
63
74
|
kind: 'create_edge';
|
|
64
75
|
reason: string;
|
|
65
76
|
edge: GraphEdgeCreation;
|
|
77
|
+
} | {
|
|
78
|
+
kind: 'create_entity';
|
|
79
|
+
reason: string;
|
|
80
|
+
entity: GraphEntityCreation;
|
|
66
81
|
};
|
|
67
82
|
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
68
83
|
[key: string]: JsonValue;
|
|
@@ -21,6 +21,9 @@ export const GRAPH_CORRECTION_ENTITY_FIELDS = ['name', 'aliases', 'summary', 'va
|
|
|
21
21
|
export const GRAPH_CORRECTION_PROJECT_FIELDS = ['goal', 'status', 'icon'];
|
|
22
22
|
export const GRAPH_CORRECTION_EDGE_FIELDS = ['role', 'context', 'validTo'];
|
|
23
23
|
export const GRAPH_CORRECTION_NEW_EDGE_RELATIONS = ['participates_in', 'accountable_for', 'member_of'];
|
|
24
|
+
/** Kinds a correction may create (people and companies only). */
|
|
25
|
+
export const GRAPH_CORRECTION_NEW_ENTITY_TYPES = ['person', 'org'];
|
|
26
|
+
export const GRAPH_CORRECTION_NEW_ENTITY_FIELDS = ['type', 'name', 'aliases', 'summary', 'validFrom'];
|
|
24
27
|
/** MCP tools that read the same graph.json on every call and therefore see a saved correction next time. */
|
|
25
28
|
export const GRAPH_CORRECTION_APPLIES_TO = ['search', 'get_context', 'resolve_entity'];
|
|
26
29
|
const MAX_REASON_LENGTH = 500;
|
|
@@ -83,7 +86,11 @@ export function parseGraphCorrectionInput(value) {
|
|
|
83
86
|
assertOnlyKeys(value, ['kind', 'reason', 'edge'], '');
|
|
84
87
|
return { kind, reason: requireReason(value.reason), edge: parseEdgeCreation(value.edge) };
|
|
85
88
|
}
|
|
86
|
-
|
|
89
|
+
if (kind === 'create_entity') {
|
|
90
|
+
assertOnlyKeys(value, ['kind', 'reason', 'entity'], '');
|
|
91
|
+
return { kind, reason: requireReason(value.reason), entity: parseEntityCreation(value.entity) };
|
|
92
|
+
}
|
|
93
|
+
invalid('correction_kind_unsupported', 'kind must be update_entity, update_edge, create_edge or create_entity');
|
|
87
94
|
}
|
|
88
95
|
/**
|
|
89
96
|
* Applies one owner correction to the canonical Graph v2 in `dataDir`.
|
|
@@ -190,7 +197,7 @@ function isGraphCorrectionRecord(value) {
|
|
|
190
197
|
const target = value.target;
|
|
191
198
|
return typeof value.id === 'string'
|
|
192
199
|
&& typeof value.at === 'string'
|
|
193
|
-
&& (value.kind === 'update_entity' || value.kind === 'update_edge' || value.kind === 'create_edge')
|
|
200
|
+
&& (value.kind === 'update_entity' || value.kind === 'update_edge' || value.kind === 'create_edge' || value.kind === 'create_entity')
|
|
194
201
|
&& isRecord(target) && (target.recordType === 'entity' || target.recordType === 'edge') && typeof target.id === 'string'
|
|
195
202
|
&& typeof value.reason === 'string'
|
|
196
203
|
&& Array.isArray(value.changedFields) && value.changedFields.every((field) => typeof field === 'string')
|
|
@@ -204,8 +211,51 @@ function planCorrection(graph, relationships, correction, at) {
|
|
|
204
211
|
return planEntityUpdate(graph, relationships, correction, at);
|
|
205
212
|
if (correction.kind === 'update_edge')
|
|
206
213
|
return planEdgeUpdate(graph, relationships, correction, at);
|
|
214
|
+
if (correction.kind === 'create_entity')
|
|
215
|
+
return planEntityCreation(graph, relationships, correction, at);
|
|
207
216
|
return planEdgeCreation(graph, relationships, correction, at);
|
|
208
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Creates a person or a company. A record of the same kind whose name or
|
|
220
|
+
* alias already reads the same (ignoring spacing, case and full/half width) is
|
|
221
|
+
* returned as a conflict instead of creating a duplicate. The new record
|
|
222
|
+
* keeps where it came from (`metadata.source`, `metadata.sourceId`).
|
|
223
|
+
*/
|
|
224
|
+
function planEntityCreation(graph, relationships, correction, at) {
|
|
225
|
+
const input = correction.entity;
|
|
226
|
+
const names = new Set([input.name, ...(input.aliases ?? [])].map(comparableName));
|
|
227
|
+
const existing = graph.entities.find((entity) => entity.type === input.type
|
|
228
|
+
&& [entity.name, ...(entity.aliases ?? [])].some((label) => names.has(comparableName(label))));
|
|
229
|
+
if (existing) {
|
|
230
|
+
throw new GraphCorrectionError('conflict', 'entity_already_exists', 'A record with the same name already exists; open it (and clear validTo if it has ended) instead of creating another', { recordType: 'entity', record: existing, digest: graphRecordDigest(existing) });
|
|
231
|
+
}
|
|
232
|
+
const base = `${input.type}-${stableHash(input.name)}`;
|
|
233
|
+
const correctionId = graphCorrectionId(at, 'create_entity', base, null, correction.reason);
|
|
234
|
+
const id = graph.entities.some((entity) => entity.id === base) ? `${base}-${correctionId.slice(-8)}` : base;
|
|
235
|
+
if (graph.entities.some((entity) => entity.id === id)) {
|
|
236
|
+
invalid('entity_id_ambiguous', `Entity id ${id} is already used; repair graph.json with the CLI first`);
|
|
237
|
+
}
|
|
238
|
+
const entity = compact({
|
|
239
|
+
id,
|
|
240
|
+
type: input.type,
|
|
241
|
+
name: input.name,
|
|
242
|
+
aliases: input.aliases && input.aliases.length > 0 ? input.aliases : undefined,
|
|
243
|
+
summary: input.summary,
|
|
244
|
+
validFrom: input.validFrom,
|
|
245
|
+
metadata: { source: 'user_approved', sourceId: correctionId }
|
|
246
|
+
});
|
|
247
|
+
const nextGraph = validatedGraph({ ...graph, entities: [...graph.entities, entity] });
|
|
248
|
+
const diff = {};
|
|
249
|
+
for (const [key, value] of Object.entries(entity)) {
|
|
250
|
+
if (key === 'id')
|
|
251
|
+
continue;
|
|
252
|
+
diff[key] = { before: null, after: value };
|
|
253
|
+
}
|
|
254
|
+
return finalize('create_entity', 'entity', null, entity, diff, correction.reason, at, nextGraph, { relationships, changes: [] }, correctionId);
|
|
255
|
+
}
|
|
256
|
+
function comparableName(value) {
|
|
257
|
+
return value.normalize('NFKC').replace(/\s+/gu, '').toLowerCase();
|
|
258
|
+
}
|
|
209
259
|
function planEntityUpdate(graph, relationships, correction, at) {
|
|
210
260
|
const matches = graph.entities.filter((entity) => entity.id === correction.entityId);
|
|
211
261
|
if (matches.length === 0)
|
|
@@ -539,6 +589,35 @@ function parseEdgeCreation(value) {
|
|
|
539
589
|
validFrom: validFrom ?? undefined
|
|
540
590
|
});
|
|
541
591
|
}
|
|
592
|
+
function parseEntityCreation(value) {
|
|
593
|
+
if (!isRecord(value))
|
|
594
|
+
invalid('correction_invalid', 'entity must be an object');
|
|
595
|
+
const type = value.type;
|
|
596
|
+
if (typeof type !== 'string' || !GRAPH_CORRECTION_NEW_ENTITY_TYPES.includes(type)) {
|
|
597
|
+
invalid('correction_entity_type_not_allowed', `entity.type must be one of ${GRAPH_CORRECTION_NEW_ENTITY_TYPES.join(', ')}`);
|
|
598
|
+
}
|
|
599
|
+
const allowed = GRAPH_CORRECTION_NEW_ENTITY_FIELDS.filter((key) => key !== 'type');
|
|
600
|
+
for (const key of Object.keys(value)) {
|
|
601
|
+
if (key === 'type' || allowed.includes(key))
|
|
602
|
+
continue;
|
|
603
|
+
if (key === 'id')
|
|
604
|
+
invalid('correction_id_change_not_allowed', 'entity.id is assigned by Brainbase');
|
|
605
|
+
invalid('correction_field_not_allowed', `entity.${key} cannot be set when creating a record`);
|
|
606
|
+
}
|
|
607
|
+
const name = value.name === undefined ? null : optionalText(value.name, 'entity.name', MAX_NAME_LENGTH);
|
|
608
|
+
if (name === null)
|
|
609
|
+
invalid('correction_name_required', 'entity.name must be a non-empty string');
|
|
610
|
+
const aliases = value.aliases === undefined ? [] : parseAliases(value.aliases).filter((alias) => alias !== name);
|
|
611
|
+
const summary = value.summary === undefined ? null : optionalText(value.summary, 'entity.summary', MAX_TEXT_LENGTH);
|
|
612
|
+
const validFrom = value.validFrom === undefined ? null : optionalDateTime(value.validFrom, 'entity.validFrom');
|
|
613
|
+
return compact({
|
|
614
|
+
type: type,
|
|
615
|
+
name,
|
|
616
|
+
aliases: aliases.length > 0 ? aliases : undefined,
|
|
617
|
+
summary: summary ?? undefined,
|
|
618
|
+
validFrom: validFrom ?? undefined
|
|
619
|
+
});
|
|
620
|
+
}
|
|
542
621
|
function assertOnlyKeys(value, allowed, prefix) {
|
|
543
622
|
for (const key of Object.keys(value)) {
|
|
544
623
|
if (allowed.includes(key))
|
|
@@ -425,8 +425,20 @@ export function applyGraphOperations(snapshot, operations, { projectCode, humanG
|
|
|
425
425
|
&& !externalIds.has(toEntity.id)
|
|
426
426
|
&& toEntity.entity_type === 'app'
|
|
427
427
|
&& toEntity.lifecycle_status === 'active';
|
|
428
|
-
|
|
428
|
+
// A person's membership of a company: both active, each either in this
|
|
429
|
+
// snapshot or a same-organization reference. Cross-organization
|
|
430
|
+
// references are never a membership endpoint.
|
|
431
|
+
const membershipFrom = localFrom || externalFrom;
|
|
432
|
+
const sameOrganizationPersonMembership = operation.rel_type === 'member_of'
|
|
433
|
+
&& membershipFrom?.entity_type === 'person'
|
|
434
|
+
&& membershipFrom.lifecycle_status === 'active'
|
|
435
|
+
&& (Boolean(localFrom) || (externalIds.has(membershipFrom.id) && membershipFrom.reference_scope === 'same_organization'))
|
|
436
|
+
&& toEntity.entity_type === 'org'
|
|
437
|
+
&& toEntity.lifecycle_status === 'active'
|
|
438
|
+
&& (!externalIds.has(toEntity.id) || toEntity.reference_scope === 'same_organization');
|
|
439
|
+
if (!localFrom && !sameOrganizationToolAccountable && !sameOrganizationPersonMembership) {
|
|
429
440
|
throw new Error(`Unknown entity: ${operation.from_id}`);
|
|
441
|
+
}
|
|
430
442
|
const fromEntity = (localFrom || externalFrom);
|
|
431
443
|
// A project of this scope using a same-organization tool of another project.
|
|
432
444
|
const sameOrganizationProjectUsesApp = operation.rel_type === 'uses_app'
|
|
@@ -455,7 +467,7 @@ export function applyGraphOperations(snapshot, operations, { projectCode, humanG
|
|
|
455
467
|
throw new Error('decision_id must match the Decision owner source');
|
|
456
468
|
}
|
|
457
469
|
if (fromEntity.project_code !== toEntity.project_code && !sameOrganizationAppOwner && !sameOrganizationDecisionOwner
|
|
458
|
-
&& !sameOrganizationToolAccountable && !sameOrganizationProjectUsesApp) {
|
|
470
|
+
&& !sameOrganizationToolAccountable && !sameOrganizationProjectUsesApp && !sameOrganizationPersonMembership) {
|
|
459
471
|
throw new Error('edge endpoint project scope mismatch');
|
|
460
472
|
}
|
|
461
473
|
if (!String(operation.rel_type || '').trim())
|
|
@@ -491,7 +503,12 @@ export function applyGraphOperations(snapshot, operations, { projectCode, humanG
|
|
|
491
503
|
id: operation.edge_id,
|
|
492
504
|
from_id: operation.from_id, to_id: operation.to_id, rel_type: operation.rel_type,
|
|
493
505
|
// The accountable person lives in another project; the edge belongs to the tool's project.
|
|
494
|
-
|
|
506
|
+
// A membership belongs to the company's project when the company is
|
|
507
|
+
// in this snapshot, else to the person's, else to the plan's.
|
|
508
|
+
project_code: sameOrganizationToolAccountable ? toEntity.project_code
|
|
509
|
+
: sameOrganizationPersonMembership
|
|
510
|
+
? (!externalIds.has(toEntity.id) ? toEntity.project_code : localFrom ? fromEntity.project_code : state.project_code)
|
|
511
|
+
: fromEntity.project_code,
|
|
495
512
|
payload: operation.payload || {}, role_min: roleMin,
|
|
496
513
|
sensitivity, lifecycle_status: 'active', version: 1
|
|
497
514
|
});
|
package/dist/graph-web.d.ts
CHANGED
|
@@ -320,6 +320,8 @@ export interface GraphOntologySummary<S extends GraphWebAnySource = GraphWebSour
|
|
|
320
320
|
projectFields: readonly string[];
|
|
321
321
|
edgeFields: readonly string[];
|
|
322
322
|
newEdgeRelations: readonly string[];
|
|
323
|
+
/** Kinds a correction may create (`create_entity`). */
|
|
324
|
+
newEntityTypes: readonly string[];
|
|
323
325
|
};
|
|
324
326
|
}
|
|
325
327
|
export type GraphWebErrorKind = 'invalid' | 'not_found' | 'unavailable';
|
|
@@ -345,6 +347,20 @@ export interface GraphPersonSummaryView extends GraphEntityView {
|
|
|
345
347
|
/** Distinct active projects with an active participates_in or accountable_for relation. */
|
|
346
348
|
projectCount: number;
|
|
347
349
|
accountableProjectCount: number;
|
|
350
|
+
/**
|
|
351
|
+
* The company text recorded on the person (`org`, or `organization` when
|
|
352
|
+
* `org` is absent), as written; null when none. A note only: it never
|
|
353
|
+
* decides the companies, the own company or internal / external.
|
|
354
|
+
*/
|
|
355
|
+
orgNote: string | null;
|
|
356
|
+
}
|
|
357
|
+
export type GraphCompanyKind = 'customer' | 'partner' | 'prospect';
|
|
358
|
+
export type GraphCompanyKindSource = 'orgType' | 'lifecycle_stage' | 'related_to' | 'deals';
|
|
359
|
+
export interface GraphCompanyDealView {
|
|
360
|
+
/** The deal name as recorded (`name` or `title`), or null when the record has none. */
|
|
361
|
+
name: string | null;
|
|
362
|
+
/** The deal stage as recorded (`stage` or `status`), or null. */
|
|
363
|
+
stage: string | null;
|
|
348
364
|
}
|
|
349
365
|
export interface GraphCompanySummaryView extends GraphEntityView {
|
|
350
366
|
type: 'org';
|
|
@@ -352,6 +368,25 @@ export interface GraphCompanySummaryView extends GraphEntityView {
|
|
|
352
368
|
memberIds: string[];
|
|
353
369
|
/** Active projects with an active owned_by to the company, by name. */
|
|
354
370
|
projectIds: string[];
|
|
371
|
+
/** Active projects the company is involved in through an active related_to, by name, with the edge role. */
|
|
372
|
+
relatedProjects: Array<{
|
|
373
|
+
projectId: string;
|
|
374
|
+
role: string | null;
|
|
375
|
+
}>;
|
|
376
|
+
/** The recorded company attributes, as written; null when not recorded. */
|
|
377
|
+
orgType: string | null;
|
|
378
|
+
lifecycleStage: string | null;
|
|
379
|
+
/** Proposals in progress recorded on the company (`deals`). */
|
|
380
|
+
deals: GraphCompanyDealView[];
|
|
381
|
+
/**
|
|
382
|
+
* What kind of company this is for the owner, only from the records above
|
|
383
|
+
* (attributes, related_to roles and deals), with where each kind came from.
|
|
384
|
+
* Empty when nothing is recorded: the kind is never guessed.
|
|
385
|
+
*/
|
|
386
|
+
kinds: Array<{
|
|
387
|
+
kind: GraphCompanyKind;
|
|
388
|
+
sources: GraphCompanyKindSource[];
|
|
389
|
+
}>;
|
|
355
390
|
}
|
|
356
391
|
export interface GraphPeopleAndCompanies<S extends GraphWebAnySource = GraphWebSource> {
|
|
357
392
|
status: 'ok';
|
package/dist/graph-web.js
CHANGED
|
@@ -2,7 +2,7 @@ import { access, readdir, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { isActiveAt } from './canonical-graph.js';
|
|
4
4
|
import { assertFoundationCatalog, findFoundationRecord } from './foundation-catalog.js';
|
|
5
|
-
import { GRAPH_CORRECTION_EDGE_FIELDS, GRAPH_CORRECTION_ENTITY_FIELDS, GRAPH_CORRECTION_NEW_EDGE_RELATIONS, GRAPH_CORRECTION_PROJECT_FIELDS, GRAPH_CORRECTIONS_FILE, graphRecordDigest, isProjectIconDataUrl, readGraphCorrectionHistory } from './graph-corrections.js';
|
|
5
|
+
import { GRAPH_CORRECTION_EDGE_FIELDS, GRAPH_CORRECTION_ENTITY_FIELDS, GRAPH_CORRECTION_NEW_EDGE_RELATIONS, GRAPH_CORRECTION_NEW_ENTITY_TYPES, GRAPH_CORRECTION_PROJECT_FIELDS, GRAPH_CORRECTIONS_FILE, graphRecordDigest, isProjectIconDataUrl, readGraphCorrectionHistory } from './graph-corrections.js';
|
|
6
6
|
import { ONTOLOGY_VERSION, portableOntology } from './ontology.js';
|
|
7
7
|
import { canonicalRelationRegistry, getCanonicalRelation } from './relation-registry.js';
|
|
8
8
|
import { loadPersonalOs } from './ssot.js';
|
|
@@ -123,21 +123,85 @@ function peopleAndCompaniesOf(graph, source, asOf) {
|
|
|
123
123
|
isOwner: person.id === ownerId,
|
|
124
124
|
companyIds: companiesOf(person.id),
|
|
125
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
|
|
126
|
+
accountableProjectCount: new Set(work.filter((edge) => edge.relation === 'accountable_for').map((edge) => edge.toId)).size,
|
|
127
|
+
orgNote: metadataString(person, 'org')?.trim() || metadataString(person, 'organization')?.trim() || null
|
|
127
128
|
};
|
|
128
129
|
}).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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
.sort((left, right) =>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
130
|
+
const companies = graph.entities.filter((entity) => entity.type === 'org').map((company) => {
|
|
131
|
+
const relatedProjects = live.filter((edge) => edge.relation === 'related_to' && edge.fromId === company.id
|
|
132
|
+
&& entities.get(edge.toId)?.type === 'project')
|
|
133
|
+
.map((edge) => ({ projectId: edge.toId, role: edge.role?.trim() || null }))
|
|
134
|
+
.sort((left, right) => byName(left.projectId, right.projectId));
|
|
135
|
+
const orgType = metadataString(company, 'orgType')?.trim() || null;
|
|
136
|
+
const lifecycleStage = metadataString(company, 'lifecycle_stage')?.trim() || null;
|
|
137
|
+
const deals = companyDeals(company.metadata?.deals);
|
|
138
|
+
return {
|
|
139
|
+
...entityView(company, asOf),
|
|
140
|
+
type: 'org',
|
|
141
|
+
memberIds: [...new Set(membership.filter((edge) => edge.toId === company.id).map((edge) => edge.fromId))]
|
|
142
|
+
.sort((left, right) => Number(left !== ownerId) - Number(right !== ownerId) || byName(left, right)),
|
|
143
|
+
projectIds: [...new Set(live.filter((edge) => edge.relation === 'owned_by' && edge.toId === company.id
|
|
144
|
+
&& entities.get(edge.fromId)?.type === 'project').map((edge) => edge.fromId))].sort(byName),
|
|
145
|
+
relatedProjects,
|
|
146
|
+
orgType,
|
|
147
|
+
lifecycleStage,
|
|
148
|
+
deals,
|
|
149
|
+
kinds: companyKinds({ orgType, lifecycleStage, roles: relatedProjects.map((item) => item.role), dealCount: deals.length })
|
|
150
|
+
};
|
|
151
|
+
}).sort((left, right) => Number(right.active) - Number(left.active)
|
|
137
152
|
|| Number(right.id === ownCompany.id) - Number(left.id === ownCompany.id)
|
|
153
|
+
|| Number(right.kinds.length > 0) - Number(left.kinds.length > 0)
|
|
138
154
|
|| byActiveThenName(left, right));
|
|
139
155
|
return { status: 'ok', source, asOf, ownCompany, people, companies, absenceConfirmed: people.length === 0 && companies.length === 0 };
|
|
140
156
|
}
|
|
157
|
+
const COMPANY_KINDS = ['customer', 'partner', 'prospect'];
|
|
158
|
+
const RELATION_KIND = Object.freeze({ customer: 'customer', partner: 'partner' });
|
|
159
|
+
const STAGE_KIND = Object.freeze({ customer: 'customer', partner: 'partner', opportunity: 'prospect' });
|
|
160
|
+
/**
|
|
161
|
+
* The kinds of a company, only from what is recorded: `orgType` and
|
|
162
|
+
* related_to roles of `customer` / `partner`, `lifecycle_stage` of
|
|
163
|
+
* `customer` / `partner` / `opportunity`, and any recorded deal (prospect).
|
|
164
|
+
* Other values give no kind; nothing is guessed from names.
|
|
165
|
+
*/
|
|
166
|
+
function companyKinds(input) {
|
|
167
|
+
const sources = new Map();
|
|
168
|
+
const add = (kind, source) => {
|
|
169
|
+
if (!kind)
|
|
170
|
+
return;
|
|
171
|
+
sources.set(kind, (sources.get(kind) ?? new Set()).add(source));
|
|
172
|
+
};
|
|
173
|
+
const lookup = (table, value) => {
|
|
174
|
+
const key = value?.toLowerCase();
|
|
175
|
+
return key && Object.hasOwn(table, key) ? table[key] : undefined;
|
|
176
|
+
};
|
|
177
|
+
add(lookup(RELATION_KIND, input.orgType), 'orgType');
|
|
178
|
+
add(lookup(STAGE_KIND, input.lifecycleStage), 'lifecycle_stage');
|
|
179
|
+
for (const role of input.roles)
|
|
180
|
+
add(lookup(RELATION_KIND, role), 'related_to');
|
|
181
|
+
if (input.dealCount > 0)
|
|
182
|
+
add('prospect', 'deals');
|
|
183
|
+
return COMPANY_KINDS.filter((kind) => sources.has(kind)).map((kind) => ({ kind, sources: [...sources.get(kind)] }));
|
|
184
|
+
}
|
|
185
|
+
function companyDeals(value) {
|
|
186
|
+
if (!Array.isArray(value))
|
|
187
|
+
return [];
|
|
188
|
+
return value.flatMap((deal) => {
|
|
189
|
+
if (typeof deal === 'string')
|
|
190
|
+
return deal.trim() ? [{ name: deal.trim(), stage: null }] : [];
|
|
191
|
+
if (!deal || typeof deal !== 'object' || Array.isArray(deal))
|
|
192
|
+
return [];
|
|
193
|
+
const record = deal;
|
|
194
|
+
const text = (...keys) => {
|
|
195
|
+
for (const key of keys) {
|
|
196
|
+
const item = record[key];
|
|
197
|
+
if (typeof item === 'string' && item.trim())
|
|
198
|
+
return item.trim();
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
};
|
|
202
|
+
return [{ name: text('name', 'title'), stage: text('stage', 'status') }];
|
|
203
|
+
});
|
|
204
|
+
}
|
|
141
205
|
export async function readGraphProject(dataDir, projectId, options = {}) {
|
|
142
206
|
const loaded = await loadForRead(dataDir);
|
|
143
207
|
if ('status' in loaded)
|
|
@@ -281,7 +345,8 @@ function ontologyOf(graph, source, asOf) {
|
|
|
281
345
|
entityFields: GRAPH_CORRECTION_ENTITY_FIELDS,
|
|
282
346
|
projectFields: GRAPH_CORRECTION_PROJECT_FIELDS,
|
|
283
347
|
edgeFields: GRAPH_CORRECTION_EDGE_FIELDS,
|
|
284
|
-
newEdgeRelations: GRAPH_CORRECTION_NEW_EDGE_RELATIONS
|
|
348
|
+
newEdgeRelations: GRAPH_CORRECTION_NEW_EDGE_RELATIONS,
|
|
349
|
+
newEntityTypes: GRAPH_CORRECTION_NEW_ENTITY_TYPES
|
|
285
350
|
}
|
|
286
351
|
};
|
|
287
352
|
}
|
|
@@ -31,6 +31,14 @@ const definitions = {
|
|
|
31
31
|
scopeTraversal: 'project_direct',
|
|
32
32
|
traversalDirection: 'reverse'
|
|
33
33
|
},
|
|
34
|
+
related_to: {
|
|
35
|
+
id: 'related_to',
|
|
36
|
+
from: 'org',
|
|
37
|
+
to: 'project',
|
|
38
|
+
meaning: 'An organization is involved in a project it does not own, such as a customer or a partner; the edge role names how.',
|
|
39
|
+
scopeTraversal: 'project_direct',
|
|
40
|
+
traversalDirection: 'forward'
|
|
41
|
+
},
|
|
34
42
|
governs: {
|
|
35
43
|
id: 'governs',
|
|
36
44
|
from: 'decision',
|
package/dist/server.d.ts
CHANGED
|
@@ -73,7 +73,7 @@ export declare const toolDefinitions: readonly [{
|
|
|
73
73
|
readonly required: readonly ["relation", "direction"];
|
|
74
74
|
readonly properties: {
|
|
75
75
|
readonly relation: {
|
|
76
|
-
readonly enum: readonly ["member_of", "participates_in", "accountable_for", "owned_by", "governs", "supersedes"];
|
|
76
|
+
readonly enum: readonly ["member_of", "participates_in", "accountable_for", "owned_by", "related_to", "governs", "supersedes"];
|
|
77
77
|
};
|
|
78
78
|
readonly direction: {
|
|
79
79
|
readonly enum: readonly ["incoming", "outgoing"];
|
package/dist/server.js
CHANGED
|
@@ -30,7 +30,7 @@ const argsSchema = z.object({
|
|
|
30
30
|
as_of: z.string().datetime({ offset: true }).optional(),
|
|
31
31
|
seedIds: z.array(z.string().min(1)).min(1).max(10).optional(),
|
|
32
32
|
steps: z.array(z.object({
|
|
33
|
-
relation: z.enum(['member_of', 'participates_in', 'accountable_for', 'owned_by', 'governs', 'supersedes']),
|
|
33
|
+
relation: z.enum(['member_of', 'participates_in', 'accountable_for', 'owned_by', 'related_to', 'governs', 'supersedes']),
|
|
34
34
|
direction: z.enum(['incoming', 'outgoing']),
|
|
35
35
|
targetType: z.enum(['person', 'org', 'project', 'decision']).optional()
|
|
36
36
|
}).strict()).min(1).max(3).optional(),
|
|
@@ -188,7 +188,7 @@ export const toolDefinitions = [
|
|
|
188
188
|
seedIds: { type: 'array', minItems: 1, maxItems: 10, items: { type: 'string' }, description: 'Canonical IDs chosen by the calling model; no embeddings needed.' },
|
|
189
189
|
steps: { type: 'array', minItems: 1, maxItems: 3, items: {
|
|
190
190
|
type: 'object', additionalProperties: false, required: ['relation', 'direction'], properties: {
|
|
191
|
-
relation: { enum: ['member_of', 'participates_in', 'accountable_for', 'owned_by', 'governs', 'supersedes'] },
|
|
191
|
+
relation: { enum: ['member_of', 'participates_in', 'accountable_for', 'owned_by', 'related_to', 'governs', 'supersedes'] },
|
|
192
192
|
direction: { enum: ['incoming', 'outgoing'] },
|
|
193
193
|
targetType: { enum: ['person', 'org', 'project', 'decision'] }
|
|
194
194
|
}
|
package/dist/templates.js
CHANGED
|
@@ -85,7 +85,7 @@ export const schemaTemplates = {
|
|
|
85
85
|
id: { type: 'string', minLength: 1 },
|
|
86
86
|
fromId: { type: 'string', minLength: 1 },
|
|
87
87
|
relation: {
|
|
88
|
-
enum: ['member_of', 'participates_in', 'accountable_for', 'owned_by', 'governs', 'supersedes']
|
|
88
|
+
enum: ['member_of', 'participates_in', 'accountable_for', 'owned_by', 'related_to', 'governs', 'supersedes']
|
|
89
89
|
},
|
|
90
90
|
toId: { type: 'string', minLength: 1 },
|
|
91
91
|
role: { type: 'string' },
|
package/dist/types.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { FoundationDefinition, FoundationRelationReference, FoundationRevision } from './ontology-foundation.js';
|
|
2
2
|
export type EntityKind = 'person' | 'org' | 'project' | 'relationship' | 'decision';
|
|
3
3
|
export type CanonicalEntityKind = 'person' | 'org' | 'project' | 'decision';
|
|
4
|
-
export type CoreRelation = 'member_of' | 'participates_in' | 'accountable_for' | 'owned_by' | 'governs' | 'supersedes';
|
|
4
|
+
export type CoreRelation = 'member_of' | 'participates_in' | 'accountable_for' | 'owned_by' | 'related_to' | 'governs' | 'supersedes';
|
|
5
5
|
export interface GraphEntity {
|
|
6
6
|
id: string;
|
|
7
7
|
type: Exclude<EntityKind, 'decision'>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unson/brainbase-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.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",
|
|
@@ -481,5 +481,5 @@
|
|
|
481
481
|
"engines": {
|
|
482
482
|
"node": ">=20"
|
|
483
483
|
},
|
|
484
|
-
"gitHead": "
|
|
484
|
+
"gitHead": "f6631b4f6a088162dbd4a3d1fb974de3e599dc85"
|
|
485
485
|
}
|
package/ui/graph-view-shared.js
CHANGED
|
@@ -56,6 +56,7 @@ export const GRAPH_RELATION_LABELS = Object.freeze({
|
|
|
56
56
|
participates_in: Object.freeze({ label: '参加', meaning: '人物がプロジェクトに参加している', outgoing: 'に参加', incoming: 'が参加' }),
|
|
57
57
|
accountable_for: Object.freeze({ label: '責任', meaning: '人物がプロジェクトの成果や判断に責任を持つ', outgoing: 'に責任を持つ', incoming: 'が責任を持つ' }),
|
|
58
58
|
owned_by: Object.freeze({ label: '所有', meaning: 'プロジェクトを組織が所有している', outgoing: 'が所有', incoming: 'を所有' }),
|
|
59
|
+
related_to: Object.freeze({ label: '関わる', meaning: '会社が顧客・協業先などとしてプロジェクトに関わっている', outgoing: 'に関わる', incoming: 'が関わる' }),
|
|
59
60
|
governs: Object.freeze({ label: '方針', meaning: '判断や原則がプロジェクトの進め方を決める', outgoing: 'の進め方を決める', incoming: 'が進め方を決める' }),
|
|
60
61
|
supersedes: Object.freeze({ label: '置き換え', meaning: '判断が以前の判断を置き換える', outgoing: 'を置き換える', incoming: 'に置き換えられた' }),
|
|
61
62
|
});
|
|
@@ -101,6 +102,7 @@ const CORRECTION_KIND_LABELS = Object.freeze({
|
|
|
101
102
|
update_entity: '記録を直した',
|
|
102
103
|
update_edge: '関係を直した',
|
|
103
104
|
create_edge: '関係を加えた',
|
|
105
|
+
create_entity: '記録を加えた',
|
|
104
106
|
});
|
|
105
107
|
|
|
106
108
|
/** Inputs of the correction form. `date` inputs hold a local day (YYYY-MM-DD). */
|
|
@@ -127,6 +129,9 @@ export const GRAPH_CORRECTION_ERROR_MESSAGES = Object.freeze({
|
|
|
127
129
|
correction_relation_change_not_allowed: '関係の種類は変えられません。今の関係を終えてから、新しい関係を加えてください。',
|
|
128
130
|
correction_endpoint_change_not_allowed: '関係の相手は変えられません。今の関係を終えてから、新しい関係を加えてください。',
|
|
129
131
|
correction_endpoint_type_invalid: 'この組み合わせの関係は加えられません。',
|
|
132
|
+
correction_entity_type_not_allowed: 'ここで作れるのは人と会社だけです。',
|
|
133
|
+
correction_name_required: '名前を書いてください。',
|
|
134
|
+
entity_already_exists: '同じ名前の記録がすでにあります。今の記録を開いて確かめてください。',
|
|
130
135
|
correction_field_not_allowed: 'この項目は、ここでは直せません。',
|
|
131
136
|
correction_changes_required: '変更がありません。直したい項目を書き換えてください。',
|
|
132
137
|
correction_scope_not_allowed: 'この画面では、この記録や関係は直せません。',
|
|
@@ -442,6 +447,7 @@ export function readOnlyGraphClient(client) {
|
|
|
442
447
|
* `GRAPH_CORRECTION_FIELDS` such as `name` or `goal` (default: every field).
|
|
443
448
|
* - `edges: false`: relations cannot be corrected or ended.
|
|
444
449
|
* - `createEdges: false`: relations cannot be added.
|
|
450
|
+
* - `createEntities: false`: people and companies cannot be created.
|
|
445
451
|
*/
|
|
446
452
|
export function graphCorrectionScope(scope) {
|
|
447
453
|
const given = isRecord(scope) ? scope : {};
|
|
@@ -451,6 +457,7 @@ export function graphCorrectionScope(scope) {
|
|
|
451
457
|
return Object.freeze({
|
|
452
458
|
edges: given.edges !== false,
|
|
453
459
|
createEdges: given.createEdges !== false,
|
|
460
|
+
createEntities: given.createEntities !== false,
|
|
454
461
|
/** Whether a record of this kind may be corrected. */
|
|
455
462
|
entity: (type) => types === null || types.has(type),
|
|
456
463
|
/** The given record fields that may be corrected, in their order. */
|
|
@@ -466,6 +473,7 @@ export function graphEntityCorrectionFields(type) {
|
|
|
466
473
|
/** Whether a built correction request stays inside the scope (`recordType` is the corrected entity's kind). */
|
|
467
474
|
function withinCorrectionScope(scope, body, recordType) {
|
|
468
475
|
if (body.kind === 'create_edge') return scope.createEdges;
|
|
476
|
+
if (body.kind === 'create_entity') return scope.createEntities && scope.entity(body.entity?.type);
|
|
469
477
|
if (body.kind === 'update_edge') return scope.edges;
|
|
470
478
|
const changed = Object.keys(body.changes ?? {});
|
|
471
479
|
return scope.entity(recordType) && scope.fields(changed).length === changed.length;
|
|
@@ -820,6 +828,24 @@ export function buildCorrectionRequest(form) {
|
|
|
820
828
|
return { ok: true, body: { kind: 'create_edge', reason, edge } };
|
|
821
829
|
}
|
|
822
830
|
|
|
831
|
+
if (form.kind === 'create_entity') {
|
|
832
|
+
const type = String(form.draft.type ?? '');
|
|
833
|
+
if (!form.entityTypes.includes(type)) return { ok: false, message: '作る記録の種類を選んでください。' };
|
|
834
|
+
const name = String(form.draft.name ?? '').trim();
|
|
835
|
+
if (!name) return { ok: false, message: GRAPH_CORRECTION_ERROR_MESSAGES.correction_name_required };
|
|
836
|
+
const entity = { type, name };
|
|
837
|
+
const aliases = splitAliases(form.draft.aliases ?? '').filter((alias) => alias !== name);
|
|
838
|
+
const summary = String(form.draft.summary ?? '').trim();
|
|
839
|
+
if (aliases.length > 0) entity.aliases = aliases;
|
|
840
|
+
if (summary) entity.summary = summary;
|
|
841
|
+
if (form.draft.validFrom) {
|
|
842
|
+
const validFrom = dayToRfc3339(form.draft.validFrom);
|
|
843
|
+
if (!validFrom) return { ok: false, message: '開始日の形式が正しくありません。' };
|
|
844
|
+
entity.validFrom = validFrom;
|
|
845
|
+
}
|
|
846
|
+
return { ok: true, body: { kind: 'create_entity', reason, entity } };
|
|
847
|
+
}
|
|
848
|
+
|
|
823
849
|
const changes = {};
|
|
824
850
|
for (const field of form.fields) {
|
|
825
851
|
if (field === 'icon') {
|
|
@@ -860,11 +886,17 @@ export function buildCorrectionRequest(form) {
|
|
|
860
886
|
|
|
861
887
|
function readBackFields(form, payload) {
|
|
862
888
|
if (form.kind === 'create_edge') return ['role', 'context', 'validFrom', 'validTo'];
|
|
889
|
+
if (form.kind === 'create_entity') return ['name', 'aliases', 'summary', 'validFrom'];
|
|
863
890
|
const changed = Array.isArray(payload?.correction?.changedFields) ? payload.correction.changedFields : [];
|
|
864
891
|
const fields = changed.map((field) => field.replace(/^metadata\./u, '')).filter((field) => GRAPH_CORRECTION_FIELDS[field]);
|
|
865
892
|
return fields.length > 0 ? fields : form.fields;
|
|
866
893
|
}
|
|
867
894
|
|
|
895
|
+
/** What a new record is, as rows shown before its values. */
|
|
896
|
+
function createdEntityRows(form, record) {
|
|
897
|
+
return [['種類', form.typeNames[record?.type] ?? typeLabel(record?.type)]];
|
|
898
|
+
}
|
|
899
|
+
|
|
868
900
|
/** What a new relation connects, as rows shown before its values. */
|
|
869
901
|
function createdRows(form, record) {
|
|
870
902
|
const counterpartId = record?.fromId === form.anchor.id ? record?.toId : record?.fromId;
|
|
@@ -1015,6 +1047,22 @@ function renderCreateFields(doc, form, callbacks) {
|
|
|
1015
1047
|
return group;
|
|
1016
1048
|
}
|
|
1017
1049
|
|
|
1050
|
+
function renderCreateEntityFields(doc, form, callbacks) {
|
|
1051
|
+
const group = makeElement(doc, 'div', { className: 'bb-graph-fields' });
|
|
1052
|
+
if (form.entityTypes.length > 1) {
|
|
1053
|
+
group.append(selectControl(doc, {
|
|
1054
|
+
id: `${form.formId}-type`,
|
|
1055
|
+
name: 'type',
|
|
1056
|
+
label: '種類',
|
|
1057
|
+
value: form.draft.type,
|
|
1058
|
+
options: form.entityTypes.map((type) => ({ value: type, label: form.typeNames[type] ?? typeLabel(type) })),
|
|
1059
|
+
onChange: (value) => callbacks.onInput('type', value),
|
|
1060
|
+
}));
|
|
1061
|
+
}
|
|
1062
|
+
for (const field of form.fields) group.append(fieldControl(doc, form, field, callbacks));
|
|
1063
|
+
return group;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1018
1066
|
/**
|
|
1019
1067
|
* The correction panel. `form` is the state held by `createGraphCorrection`;
|
|
1020
1068
|
* callbacks are the controller's handlers.
|
|
@@ -1035,7 +1083,9 @@ export function renderCorrectionPanel(doc, form, callbacks) {
|
|
|
1035
1083
|
const readBack = makeElement(doc, 'div', { className: 'bb-graph-readback', attrs: { 'aria-label': '読み直した内容' } });
|
|
1036
1084
|
readBack.append(
|
|
1037
1085
|
makeElement(doc, 'strong', { text: '読み直した内容' }),
|
|
1038
|
-
renderValues(doc, payload.record, readBackFields(form, payload), form.kind === 'create_edge'
|
|
1086
|
+
renderValues(doc, payload.record, readBackFields(form, payload), form.kind === 'create_edge'
|
|
1087
|
+
? createdRows(form, payload.record)
|
|
1088
|
+
: form.kind === 'create_entity' ? createdEntityRows(form, payload.record) : []),
|
|
1039
1089
|
);
|
|
1040
1090
|
panel.append(readBack, notice(doc, 'accent', mcpNote(payload)));
|
|
1041
1091
|
panel.append(recordDetails(doc, [['記録ID', payload.record?.id], ['保存した内容のdigest', payload.digest], ['訂正ID', payload.correction?.id]]));
|
|
@@ -1045,12 +1095,17 @@ export function renderCorrectionPanel(doc, form, callbacks) {
|
|
|
1045
1095
|
|
|
1046
1096
|
if (form.phase === 'conflict') {
|
|
1047
1097
|
const exists = form.conflictCode === 'edge_already_exists';
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1098
|
+
const entityExists = form.conflictCode === 'entity_already_exists';
|
|
1099
|
+
panel.append(notice(doc, 'warning', entityExists
|
|
1100
|
+
? '同じ名前の記録がすでにあるため、作りませんでした。今の記録を下に示します。同じ人や会社なら、今の記録を使ってください(終わっている記録は、終了日を消すと戻せます)。入力は残しています。'
|
|
1101
|
+
: exists
|
|
1102
|
+
? 'この関わりはすでに登録されているため、加えませんでした。今の内容を下に示します。終わった関わりをもう一度始めるときは、その関わりの終了日を消してください。入力は残しています。'
|
|
1103
|
+
: 'ほかの保存で内容が変わっていたため、保存しませんでした。今の内容を下に示します。入力は残しています。今の内容を確かめてから、もう一度保存してください。', 'alert'));
|
|
1051
1104
|
const current = makeElement(doc, 'div', { className: 'bb-graph-current', attrs: { 'aria-label': '今の内容' } });
|
|
1052
|
-
const fields = exists ? ['role', 'context', 'validFrom', 'validTo'] : form.fields;
|
|
1053
|
-
current.append(makeElement(doc, 'strong', { text: '今の内容' }), renderValues(doc, form.current.record, fields
|
|
1105
|
+
const fields = exists ? ['role', 'context', 'validFrom', 'validTo'] : entityExists ? ['name', 'aliases', 'summary', 'validFrom', 'validTo'] : form.fields;
|
|
1106
|
+
current.append(makeElement(doc, 'strong', { text: '今の内容' }), renderValues(doc, form.current.record, fields, entityExists
|
|
1107
|
+
? [['種類', form.typeNames[form.current.record?.type] ?? typeLabel(form.current.record?.type)]]
|
|
1108
|
+
: []));
|
|
1054
1109
|
current.append(recordDetails(doc, [['記録ID', form.current.record?.id], ['今の内容のdigest', form.current.digest]]));
|
|
1055
1110
|
panel.append(current);
|
|
1056
1111
|
} else if (form.message) {
|
|
@@ -1065,6 +1120,8 @@ export function renderCorrectionPanel(doc, form, callbacks) {
|
|
|
1065
1120
|
});
|
|
1066
1121
|
if (form.kind === 'create_edge') {
|
|
1067
1122
|
formElement.append(renderCreateFields(doc, form, callbacks));
|
|
1123
|
+
} else if (form.kind === 'create_entity') {
|
|
1124
|
+
formElement.append(renderCreateEntityFields(doc, form, callbacks));
|
|
1068
1125
|
} else {
|
|
1069
1126
|
const group = makeElement(doc, 'div', { className: 'bb-graph-fields' });
|
|
1070
1127
|
for (const field of form.fields) group.append(fieldControl(doc, form, field, callbacks));
|
|
@@ -1298,6 +1355,29 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
|
|
|
1298
1355
|
void searchCandidates('');
|
|
1299
1356
|
return form;
|
|
1300
1357
|
},
|
|
1358
|
+
/**
|
|
1359
|
+
* Create a person or a company. `types` lists the kinds offered (only
|
|
1360
|
+
* `person` and `org` are created; others are dropped), `typeNames` their
|
|
1361
|
+
* labels. Null outside the scope.
|
|
1362
|
+
*/
|
|
1363
|
+
openCreateEntity({ types = ['person', 'org'], type, typeNames = {}, title = '記録を加える', subject } = {}) {
|
|
1364
|
+
const entityTypes = allowed.createEntities
|
|
1365
|
+
? types.filter((candidate) => (candidate === 'person' || candidate === 'org') && allowed.entity(candidate))
|
|
1366
|
+
: [];
|
|
1367
|
+
if (entityTypes.length === 0) return null;
|
|
1368
|
+
const fields = ['name', 'aliases', 'summary', 'validFrom'];
|
|
1369
|
+
form = baseForm('create_entity', {
|
|
1370
|
+
title,
|
|
1371
|
+
subject: subject ?? null,
|
|
1372
|
+
entityTypes,
|
|
1373
|
+
typeNames,
|
|
1374
|
+
fields,
|
|
1375
|
+
original: Object.fromEntries(fields.map((field) => [field, ''])),
|
|
1376
|
+
draft: { type: entityTypes.includes(type) ? type : entityTypes[0], name: '', aliases: '', summary: '', validFrom: '' },
|
|
1377
|
+
});
|
|
1378
|
+
redraw();
|
|
1379
|
+
return form;
|
|
1380
|
+
},
|
|
1301
1381
|
close() {
|
|
1302
1382
|
iconReadGeneration += 1;
|
|
1303
1383
|
form = null;
|
|
@@ -1353,7 +1433,7 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
|
|
|
1353
1433
|
// The owner now sees the current record; the next save is based on it
|
|
1354
1434
|
// and still sends only the fields the owner changed. Untouched fields
|
|
1355
1435
|
// show the current values so the form never suggests reverting them.
|
|
1356
|
-
if (target.kind !== 'create_edge' && typeof result.current.digest === 'string') {
|
|
1436
|
+
if (target.kind !== 'create_edge' && target.kind !== 'create_entity' && typeof result.current.digest === 'string') {
|
|
1357
1437
|
target.expectedDigest = result.current.digest;
|
|
1358
1438
|
const currentValues = graphRecordValues(result.current.record);
|
|
1359
1439
|
for (const field of target.fields) {
|
package/ui/local-web-shell.js
CHANGED
|
@@ -270,7 +270,7 @@ function mountObjectives(container, context) {
|
|
|
270
270
|
return { editor, worldModel };
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
function mountGraphScreen(screenId, createView) {
|
|
273
|
+
function mountGraphScreen(screenId, createView, extra = {}) {
|
|
274
274
|
return (container, context) => {
|
|
275
275
|
const viewRoot = makeElement(context.document, 'div');
|
|
276
276
|
makePage(container, context).append(viewRoot);
|
|
@@ -282,6 +282,7 @@ function mountGraphScreen(screenId, createView) {
|
|
|
282
282
|
fetcher: context.fetcher,
|
|
283
283
|
token: context.token,
|
|
284
284
|
initialEntityId: context.initialEntityId,
|
|
285
|
+
...extra,
|
|
285
286
|
});
|
|
286
287
|
};
|
|
287
288
|
}
|
|
@@ -292,7 +293,7 @@ export const LOCAL_WEB_SCREENS = Object.freeze([
|
|
|
292
293
|
Object.freeze({ id: 'objectives', label: '目的と現状', area: 'purpose', usesGraph: true, rail: true, source: '手元のGraph', mount: mountObjectives }),
|
|
293
294
|
Object.freeze({ id: 'projects', label: 'プロジェクトと関係者', area: 'work', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('projects', createGraphProjectsView) }),
|
|
294
295
|
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) }),
|
|
296
|
+
Object.freeze({ id: 'people', label: '人と会社', area: 'information', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('people', createPeopleAndCompaniesView, { canCorrect: true }) }),
|
|
296
297
|
]);
|
|
297
298
|
|
|
298
299
|
function renderGraphGate(doc, slot, status, onRecheck, label) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* 「人と会社」. Load after workspace-kit.css and graph-view-shared.css.
|
|
3
|
-
* Column templates of the people and company ledgers, the group titles
|
|
4
|
-
* the
|
|
3
|
+
* Column templates of the people and company ledgers, the group titles,
|
|
4
|
+
* the write buttons, the recorded company note and the relations in the
|
|
5
|
+
* right rail, with a 760px variant.
|
|
5
6
|
* Uses only the shared tokens in brainbase-tokens.css.
|
|
6
7
|
*/
|
|
7
8
|
.bb-pc-people,
|
|
@@ -35,7 +36,7 @@
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
.bb-pc-company-ledger .bb-ws-ledger-row {
|
|
38
|
-
grid-template-columns: minmax(0, 1.4fr)
|
|
39
|
+
grid-template-columns: minmax(0, 1.4fr) minmax(0, .8fr) 84px minmax(0, 1fr) minmax(0, 1fr);
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
.bb-pc-relation-list {
|
|
@@ -74,6 +75,32 @@
|
|
|
74
75
|
font-size: 12px;
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
.bb-pc-actions,
|
|
79
|
+
.bb-pc-relation-actions {
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-wrap: wrap;
|
|
82
|
+
gap: 8px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.bb-pc-actions {
|
|
86
|
+
margin: 0 0 12px;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.bb-pc-company-cell {
|
|
90
|
+
display: grid;
|
|
91
|
+
gap: 2px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* A note recorded as text on the person, never a membership: muted and dashed. */
|
|
95
|
+
.bb-pc-org-note {
|
|
96
|
+
justify-self: start;
|
|
97
|
+
padding: 1px 6px;
|
|
98
|
+
border: 1px dashed var(--bb-color-border);
|
|
99
|
+
border-radius: 4px;
|
|
100
|
+
color: var(--bb-color-muted);
|
|
101
|
+
font-size: 11px;
|
|
102
|
+
}
|
|
103
|
+
|
|
77
104
|
@media (max-width: 760px) {
|
|
78
105
|
.bb-pc-people-ledger .bb-ws-ledger-row {
|
|
79
106
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 84px;
|
|
@@ -84,10 +111,11 @@
|
|
|
84
111
|
}
|
|
85
112
|
|
|
86
113
|
.bb-pc-company-ledger .bb-ws-ledger-row {
|
|
87
|
-
grid-template-columns: minmax(0, 1fr)
|
|
114
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, .7fr) 64px;
|
|
88
115
|
}
|
|
89
116
|
|
|
90
|
-
.bb-pc-company-ledger .bb-ws-ledger-row > :nth-child(4)
|
|
117
|
+
.bb-pc-company-ledger .bb-ws-ledger-row > :nth-child(4),
|
|
118
|
+
.bb-pc-company-ledger .bb-ws-ledger-row > :nth-child(5) {
|
|
91
119
|
display: none;
|
|
92
120
|
}
|
|
93
121
|
}
|
|
@@ -4,8 +4,23 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Reads `GET {base}/people-and-companies` for the ledgers and
|
|
6
6
|
* `GET {base}/entities/:id` for the selected record in the right rail (see
|
|
7
|
-
* `graph-view-shared.js`). Read only
|
|
8
|
-
*
|
|
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).
|
|
9
24
|
*
|
|
10
25
|
* Internal and external are not stored anywhere. They come from the
|
|
11
26
|
* company memberships (`member_of`) and the own company the host answers.
|
|
@@ -19,7 +34,9 @@
|
|
|
19
34
|
|
|
20
35
|
import {
|
|
21
36
|
badge,
|
|
37
|
+
button,
|
|
22
38
|
createGraphClient,
|
|
39
|
+
createGraphCorrection,
|
|
23
40
|
createGraphLayout,
|
|
24
41
|
getDocument,
|
|
25
42
|
graphHostEmptyNotice,
|
|
@@ -30,6 +47,8 @@ import {
|
|
|
30
47
|
isRecord,
|
|
31
48
|
makeElement,
|
|
32
49
|
provenanceSummary,
|
|
50
|
+
readOnlyGraphClient,
|
|
51
|
+
graphCorrectionScope,
|
|
33
52
|
relationLabel,
|
|
34
53
|
textOrNull,
|
|
35
54
|
validityLabel,
|
|
@@ -48,7 +67,18 @@ import {
|
|
|
48
67
|
|
|
49
68
|
export const PEOPLE_AND_COMPANIES_VIEW_CONTRACT_VERSION = 'brainbase.people-and-companies-view.v1';
|
|
50
69
|
export const PEOPLE_LEDGER_COLUMNS = Object.freeze(['名前', '会社', '関わるプロジェクト', '有効期間']);
|
|
51
|
-
export const COMPANY_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: '協業先' });
|
|
52
82
|
|
|
53
83
|
const INVALID = Object.freeze({ state: 'invalid', reason: '応答の形式が不正です' });
|
|
54
84
|
|
|
@@ -62,6 +92,31 @@ function isIdList(value) {
|
|
|
62
92
|
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
63
93
|
}
|
|
64
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
|
+
|
|
65
120
|
/** The read is `ok` only when every person and company is well formed. */
|
|
66
121
|
export function normalizePeopleAndCompanies(result) {
|
|
67
122
|
if (result?.state !== 'ok') return result ?? INVALID;
|
|
@@ -69,9 +124,9 @@ export function normalizePeopleAndCompanies(result) {
|
|
|
69
124
|
const own = payload.ownCompany;
|
|
70
125
|
if (!isRecord(own) || !(own.id === null || typeof own.id === 'string') || typeof own.reason !== 'string' || !isIdList(own.candidateIds)) return INVALID;
|
|
71
126
|
if (!Array.isArray(payload.people) || !payload.people.every((person) => isEntityView(person)
|
|
72
|
-
&& isIdList(person.companyIds) && Number.isInteger(person.projectCount) && Number.isInteger(person.accountableProjectCount)
|
|
73
|
-
|
|
74
|
-
|
|
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;
|
|
75
130
|
if (payload.people.length === 0 && payload.companies.length === 0 && payload.absenceConfirmed !== true) return { state: 'unknown' };
|
|
76
131
|
return { state: 'ok', payload };
|
|
77
132
|
}
|
|
@@ -116,6 +171,27 @@ export function personStanding(person, ownCompanyId) {
|
|
|
116
171
|
return person.companyIds.length > 0 ? '社外' : '所属未確認';
|
|
117
172
|
}
|
|
118
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
|
+
*/
|
|
119
195
|
export function createPeopleAndCompaniesView({
|
|
120
196
|
root,
|
|
121
197
|
rail,
|
|
@@ -123,16 +199,29 @@ export function createPeopleAndCompaniesView({
|
|
|
123
199
|
document: explicitDocument,
|
|
124
200
|
fetcher,
|
|
125
201
|
basePath = '/api/graph',
|
|
202
|
+
token,
|
|
203
|
+
tokenHeader,
|
|
126
204
|
autoLoad = true,
|
|
127
205
|
initialEntityId,
|
|
128
206
|
now = () => new Date(),
|
|
207
|
+
canCorrect = false,
|
|
208
|
+
correctionScope,
|
|
129
209
|
sourceNotice,
|
|
130
210
|
emptyNotice,
|
|
131
211
|
correctionHref = (id) => `#graph?entity_id=${encodeURIComponent(id)}`,
|
|
132
212
|
} = {}) {
|
|
133
213
|
if (!root) throw new TypeError('root is required');
|
|
134
214
|
const doc = getDocument(explicitDocument);
|
|
135
|
-
const
|
|
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
|
+
});
|
|
136
225
|
const context = graphPageContext(page);
|
|
137
226
|
const layout = createGraphLayout(doc, {
|
|
138
227
|
root,
|
|
@@ -142,6 +231,21 @@ export function createPeopleAndCompaniesView({
|
|
|
142
231
|
contractVersion: PEOPLE_AND_COMPANIES_VIEW_CONTRACT_VERSION,
|
|
143
232
|
});
|
|
144
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
|
+
});
|
|
145
249
|
|
|
146
250
|
const nameOf = (id) => {
|
|
147
251
|
if (state.list.state !== 'ok') return id;
|
|
@@ -161,7 +265,7 @@ export function createPeopleAndCompaniesView({
|
|
|
161
265
|
onSelect: (id) => void controller.openEntity(id),
|
|
162
266
|
cells: [
|
|
163
267
|
{ primary: person.isOwner ? `${person.name}(あなた)` : person.name, secondary: person.id },
|
|
164
|
-
person.companyIds.length > 0 ? person.companyIds.map(nameOf).join('、') :
|
|
268
|
+
person.companyIds.length > 0 ? person.companyIds.map(nameOf).join('、') : companyCell(person, ownId),
|
|
165
269
|
person.accountableProjectCount > 0
|
|
166
270
|
? `${person.projectCount}件(うち責任 ${person.accountableProjectCount}件)`
|
|
167
271
|
: `${person.projectCount}件`,
|
|
@@ -171,6 +275,36 @@ export function createPeopleAndCompaniesView({
|
|
|
171
275
|
}));
|
|
172
276
|
}
|
|
173
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
|
+
|
|
174
308
|
function peopleLedger(label, people, empty) {
|
|
175
309
|
return workspaceLedger(doc, {
|
|
176
310
|
className: 'bb-pc-people-ledger',
|
|
@@ -184,6 +318,7 @@ export function createPeopleAndCompaniesView({
|
|
|
184
318
|
function peopleSection(payload) {
|
|
185
319
|
const section = makeElement(doc, 'section', { className: 'bb-pc-people', attrs: { 'aria-label': '人' } });
|
|
186
320
|
section.append(workspaceSectionTitle(doc, { title: '人', lead: '社内か社外かは、所属(会社への所属の関係)と自社から決めています。印は別に持っていません。' }));
|
|
321
|
+
if (can.createPerson) section.append(sectionActions('人を加える', () => openCreateEntity('person')));
|
|
187
322
|
const groups = groupPeople(payload);
|
|
188
323
|
if (!groups.ownCompanyId) {
|
|
189
324
|
const reason = OWN_COMPANY_REASONS[payload.ownCompany.reason] ?? '自社を決められません。';
|
|
@@ -210,7 +345,11 @@ export function createPeopleAndCompaniesView({
|
|
|
210
345
|
|
|
211
346
|
function companySection(payload) {
|
|
212
347
|
const section = makeElement(doc, 'section', { className: 'bb-pc-companies', attrs: { 'aria-label': '会社' } });
|
|
213
|
-
section.append(workspaceSectionTitle(doc, {
|
|
348
|
+
section.append(workspaceSectionTitle(doc, {
|
|
349
|
+
title: '会社',
|
|
350
|
+
lead: '自社を先頭に、顧客・協業先など関わる会社を並べています。種類は、会社の属性、プロジェクトとの関係の役割、提案中の案件の記録から出しています。記録が無い会社には種類を出しません。',
|
|
351
|
+
}));
|
|
352
|
+
if (can.createCompany) section.append(sectionActions('会社を加える', () => openCreateEntity('org')));
|
|
214
353
|
const active = payload.companies.filter((company) => company.active);
|
|
215
354
|
section.append(workspaceLedger(doc, {
|
|
216
355
|
className: 'bb-pc-company-ledger',
|
|
@@ -223,8 +362,11 @@ export function createPeopleAndCompaniesView({
|
|
|
223
362
|
onSelect: (id) => void controller.openEntity(id),
|
|
224
363
|
cells: [
|
|
225
364
|
{ primary: company.id === payload.ownCompany.id ? `${company.name}(自社)` : company.name, secondary: company.id },
|
|
365
|
+
companyKindLabel(company),
|
|
226
366
|
`${company.memberIds.length}人`,
|
|
227
|
-
|
|
367
|
+
company.relatedProjects.length > 0
|
|
368
|
+
? `${company.projectIds.length}件(関わる ${company.relatedProjects.length}件)`
|
|
369
|
+
: `${company.projectIds.length}件`,
|
|
228
370
|
validityLabel(company, payload.asOf),
|
|
229
371
|
],
|
|
230
372
|
})),
|
|
@@ -264,7 +406,7 @@ export function createPeopleAndCompaniesView({
|
|
|
264
406
|
// -------------------------------------------------------------------------
|
|
265
407
|
// Rail
|
|
266
408
|
|
|
267
|
-
function edgeList(edges, empty) {
|
|
409
|
+
function edgeList(edges, empty, { roleLabel = textOrNull, actions } = {}) {
|
|
268
410
|
if (edges.length === 0) return makeElement(doc, 'p', { className: 'bb-pc-empty', text: empty });
|
|
269
411
|
const list = makeElement(doc, 'ul', { className: 'bb-pc-relation-list' });
|
|
270
412
|
for (const edge of edges) {
|
|
@@ -276,16 +418,68 @@ export function createPeopleAndCompaniesView({
|
|
|
276
418
|
head,
|
|
277
419
|
makeElement(doc, 'span', { className: 'bb-pc-relation-meta', text: [
|
|
278
420
|
validityLabel(edge, asOf()),
|
|
279
|
-
|
|
421
|
+
roleLabel(edge.role) ? `役割: ${roleLabel(edge.role)}` : null,
|
|
280
422
|
`出典: ${source.kind}${source.reference ? `(${source.reference})` : ''}`,
|
|
281
423
|
].filter(Boolean).join(' ') }),
|
|
282
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}` }));
|
|
283
442
|
list.append(item);
|
|
284
443
|
}
|
|
285
444
|
return list;
|
|
286
445
|
}
|
|
287
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
|
+
|
|
288
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() {
|
|
289
483
|
const detail = state.detail;
|
|
290
484
|
if (!detail) {
|
|
291
485
|
return [workspaceDetailEmpty(doc, { mark: '人', title: '人か会社を選んでください', text: '所属と関わるプロジェクトを、有効期間と出典つきで確かめられます。' })];
|
|
@@ -297,6 +491,9 @@ export function createPeopleAndCompaniesView({
|
|
|
297
491
|
? state.list.payload.people.find((person) => person.id === entity.id)
|
|
298
492
|
: null;
|
|
299
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;
|
|
300
497
|
const blocks = [workspaceRailHead(doc, {
|
|
301
498
|
kicker: entity.type === 'org' ? '会社' : '人',
|
|
302
499
|
title: entity.name,
|
|
@@ -305,14 +502,33 @@ export function createPeopleAndCompaniesView({
|
|
|
305
502
|
})];
|
|
306
503
|
blocks.push(workspaceDefinition(doc, [
|
|
307
504
|
...(entity.type === 'person' && listed ? [['区分', personStanding(listed, ownId)]] : []),
|
|
505
|
+
...(entity.type === 'person' && listed && personOrgNote(listed, ownId)
|
|
506
|
+
? [['記録上のメモ', `${personOrgNote(listed, ownId)}(人物の記録に文字で残っている会社名です。所属の記録ではありません)`]]
|
|
507
|
+
: []),
|
|
308
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]] : []),
|
|
309
514
|
['有効期間', validityLabel(entity, asOf())],
|
|
310
515
|
]));
|
|
311
516
|
if (entity.type === 'org') {
|
|
312
|
-
blocks.push(workspaceRailBlock(doc, { title: '所属する人', content:
|
|
313
|
-
|
|
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) }));
|
|
314
527
|
} else {
|
|
315
|
-
blocks.push(workspaceRailBlock(doc, { title: '所属', content:
|
|
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
|
+
] }));
|
|
316
532
|
blocks.push(workspaceRailBlock(doc, {
|
|
317
533
|
title: '関わるプロジェクト',
|
|
318
534
|
content: edgeList(outgoing.filter((edge) => edge.relation === 'participates_in' || edge.relation === 'accountable_for'), '関わるプロジェクトの記録はありません。'),
|
|
@@ -320,7 +536,9 @@ export function createPeopleAndCompaniesView({
|
|
|
320
536
|
}
|
|
321
537
|
const fix = makeElement(doc, 'p', { className: 'bb-pc-fix' });
|
|
322
538
|
fix.append(
|
|
323
|
-
makeElement(doc, 'span', { text:
|
|
539
|
+
makeElement(doc, 'span', { text: writable
|
|
540
|
+
? '名前・別名・要約などの間違いは「情報と関係」で、この記録を開いて直します。'
|
|
541
|
+
: '間違いは「情報と関係」で、この記録を開いて直します。' }),
|
|
324
542
|
makeElement(doc, 'a', { text: '情報と関係で開く', attrs: { href: correctionHref(entity.id) } }),
|
|
325
543
|
);
|
|
326
544
|
blocks.push(fix);
|
|
@@ -334,17 +552,25 @@ export function createPeopleAndCompaniesView({
|
|
|
334
552
|
get state() {
|
|
335
553
|
return state;
|
|
336
554
|
},
|
|
555
|
+
get correction() {
|
|
556
|
+
return correction;
|
|
557
|
+
},
|
|
337
558
|
render() {
|
|
338
559
|
layout.render(workspace(), railContent());
|
|
339
560
|
},
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
+
}
|
|
343
567
|
state.list = normalizePeopleAndCompanies(await client.read('/people-and-companies'));
|
|
344
568
|
controller.render();
|
|
345
569
|
if (initialEntityId && !state.detail) await controller.openEntity(initialEntityId);
|
|
346
570
|
},
|
|
347
|
-
|
|
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();
|
|
348
574
|
state.detail = { id, state: 'loading' };
|
|
349
575
|
controller.render();
|
|
350
576
|
const result = await client.read(`/entities/${encodeURIComponent(id)}`);
|