@unson/brainbase-mcp 0.38.0 → 0.40.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 +123 -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 +254 -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.40.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": "05c940b491a191339b9bc59ffb123579220714a3"
|
|
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,29 @@ 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
|
+
if (form.projects.length > 0) {
|
|
838
|
+
const projectCode = String(form.draft.projectCode ?? '');
|
|
839
|
+
if (!form.projects.some((project) => project.code === projectCode)) return { ok: false, message: '人を加えるプロジェクトを選んでください。' };
|
|
840
|
+
entity.projectCode = projectCode;
|
|
841
|
+
}
|
|
842
|
+
const aliases = splitAliases(form.draft.aliases ?? '').filter((alias) => alias !== name);
|
|
843
|
+
const summary = String(form.draft.summary ?? '').trim();
|
|
844
|
+
if (aliases.length > 0) entity.aliases = aliases;
|
|
845
|
+
if (summary) entity.summary = summary;
|
|
846
|
+
if (form.draft.validFrom) {
|
|
847
|
+
const validFrom = dayToRfc3339(form.draft.validFrom);
|
|
848
|
+
if (!validFrom) return { ok: false, message: '開始日の形式が正しくありません。' };
|
|
849
|
+
entity.validFrom = validFrom;
|
|
850
|
+
}
|
|
851
|
+
return { ok: true, body: { kind: 'create_entity', reason, entity } };
|
|
852
|
+
}
|
|
853
|
+
|
|
823
854
|
const changes = {};
|
|
824
855
|
for (const field of form.fields) {
|
|
825
856
|
if (field === 'icon') {
|
|
@@ -860,11 +891,21 @@ export function buildCorrectionRequest(form) {
|
|
|
860
891
|
|
|
861
892
|
function readBackFields(form, payload) {
|
|
862
893
|
if (form.kind === 'create_edge') return ['role', 'context', 'validFrom', 'validTo'];
|
|
894
|
+
if (form.kind === 'create_entity') return ['name', 'aliases', 'summary', 'validFrom'];
|
|
863
895
|
const changed = Array.isArray(payload?.correction?.changedFields) ? payload.correction.changedFields : [];
|
|
864
896
|
const fields = changed.map((field) => field.replace(/^metadata\./u, '')).filter((field) => GRAPH_CORRECTION_FIELDS[field]);
|
|
865
897
|
return fields.length > 0 ? fields : form.fields;
|
|
866
898
|
}
|
|
867
899
|
|
|
900
|
+
/** What a new record is, as rows shown before its values. */
|
|
901
|
+
function createdEntityRows(form, record) {
|
|
902
|
+
const project = form.projects.find((candidate) => candidate.code === form.draft.projectCode);
|
|
903
|
+
return [
|
|
904
|
+
['種類', form.typeNames[record?.type] ?? typeLabel(record?.type)],
|
|
905
|
+
...(project ? [['関わるプロジェクト', project.name ?? project.code]] : []),
|
|
906
|
+
];
|
|
907
|
+
}
|
|
908
|
+
|
|
868
909
|
/** What a new relation connects, as rows shown before its values. */
|
|
869
910
|
function createdRows(form, record) {
|
|
870
911
|
const counterpartId = record?.fromId === form.anchor.id ? record?.toId : record?.fromId;
|
|
@@ -1015,6 +1056,48 @@ function renderCreateFields(doc, form, callbacks) {
|
|
|
1015
1056
|
return group;
|
|
1016
1057
|
}
|
|
1017
1058
|
|
|
1059
|
+
function renderCreateEntityFields(doc, form, callbacks) {
|
|
1060
|
+
const group = makeElement(doc, 'div', { className: 'bb-graph-fields' });
|
|
1061
|
+
if (form.entityTypes.length > 1) {
|
|
1062
|
+
group.append(selectControl(doc, {
|
|
1063
|
+
id: `${form.formId}-type`,
|
|
1064
|
+
name: 'type',
|
|
1065
|
+
label: '種類',
|
|
1066
|
+
value: form.draft.type,
|
|
1067
|
+
options: form.entityTypes.map((type) => ({ value: type, label: form.typeNames[type] ?? typeLabel(type) })),
|
|
1068
|
+
onChange: (value) => callbacks.onInput('type', value),
|
|
1069
|
+
}));
|
|
1070
|
+
}
|
|
1071
|
+
if (form.projects.length > 0) {
|
|
1072
|
+
group.append(selectControl(doc, {
|
|
1073
|
+
id: `${form.formId}-project`,
|
|
1074
|
+
name: 'projectCode',
|
|
1075
|
+
label: '関わるプロジェクト',
|
|
1076
|
+
value: String(form.draft.projectCode ?? ''),
|
|
1077
|
+
options: [
|
|
1078
|
+
{ value: '', label: 'プロジェクトを選んでください' },
|
|
1079
|
+
...form.projects.map((project) => ({ value: project.code, label: project.name ?? project.code })),
|
|
1080
|
+
],
|
|
1081
|
+
onChange: (value) => callbacks.onInput('projectCode', value),
|
|
1082
|
+
}));
|
|
1083
|
+
group.append(notice(doc, 'muted', '人はこのプロジェクトの関係者として記録されます。会社への所属は、作った後に加えてください。'));
|
|
1084
|
+
}
|
|
1085
|
+
for (const field of form.fields) group.append(fieldControl(doc, form, field, callbacks));
|
|
1086
|
+
return group;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/** The projects a host offers for a new person, as `{ code, name }`, without duplicates. */
|
|
1090
|
+
function creationProjects(projects) {
|
|
1091
|
+
const seen = new Set();
|
|
1092
|
+
return (Array.isArray(projects) ? projects : []).flatMap((project) => {
|
|
1093
|
+
const code = typeof project?.code === 'string' ? project.code.trim() : '';
|
|
1094
|
+
if (!code || seen.has(code)) return [];
|
|
1095
|
+
seen.add(code);
|
|
1096
|
+
const name = typeof project.name === 'string' && project.name.trim() ? project.name.trim() : null;
|
|
1097
|
+
return [{ code, name }];
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1018
1101
|
/**
|
|
1019
1102
|
* The correction panel. `form` is the state held by `createGraphCorrection`;
|
|
1020
1103
|
* callbacks are the controller's handlers.
|
|
@@ -1035,7 +1118,9 @@ export function renderCorrectionPanel(doc, form, callbacks) {
|
|
|
1035
1118
|
const readBack = makeElement(doc, 'div', { className: 'bb-graph-readback', attrs: { 'aria-label': '読み直した内容' } });
|
|
1036
1119
|
readBack.append(
|
|
1037
1120
|
makeElement(doc, 'strong', { text: '読み直した内容' }),
|
|
1038
|
-
renderValues(doc, payload.record, readBackFields(form, payload), form.kind === 'create_edge'
|
|
1121
|
+
renderValues(doc, payload.record, readBackFields(form, payload), form.kind === 'create_edge'
|
|
1122
|
+
? createdRows(form, payload.record)
|
|
1123
|
+
: form.kind === 'create_entity' ? createdEntityRows(form, payload.record) : []),
|
|
1039
1124
|
);
|
|
1040
1125
|
panel.append(readBack, notice(doc, 'accent', mcpNote(payload)));
|
|
1041
1126
|
panel.append(recordDetails(doc, [['記録ID', payload.record?.id], ['保存した内容のdigest', payload.digest], ['訂正ID', payload.correction?.id]]));
|
|
@@ -1045,12 +1130,17 @@ export function renderCorrectionPanel(doc, form, callbacks) {
|
|
|
1045
1130
|
|
|
1046
1131
|
if (form.phase === 'conflict') {
|
|
1047
1132
|
const exists = form.conflictCode === 'edge_already_exists';
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1133
|
+
const entityExists = form.conflictCode === 'entity_already_exists';
|
|
1134
|
+
panel.append(notice(doc, 'warning', entityExists
|
|
1135
|
+
? '同じ名前の記録がすでにあるため、作りませんでした。今の記録を下に示します。同じ人や会社なら、今の記録を使ってください(終わっている記録は、終了日を消すと戻せます)。入力は残しています。'
|
|
1136
|
+
: exists
|
|
1137
|
+
? 'この関わりはすでに登録されているため、加えませんでした。今の内容を下に示します。終わった関わりをもう一度始めるときは、その関わりの終了日を消してください。入力は残しています。'
|
|
1138
|
+
: 'ほかの保存で内容が変わっていたため、保存しませんでした。今の内容を下に示します。入力は残しています。今の内容を確かめてから、もう一度保存してください。', 'alert'));
|
|
1051
1139
|
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
|
|
1140
|
+
const fields = exists ? ['role', 'context', 'validFrom', 'validTo'] : entityExists ? ['name', 'aliases', 'summary', 'validFrom', 'validTo'] : form.fields;
|
|
1141
|
+
current.append(makeElement(doc, 'strong', { text: '今の内容' }), renderValues(doc, form.current.record, fields, entityExists
|
|
1142
|
+
? [['種類', form.typeNames[form.current.record?.type] ?? typeLabel(form.current.record?.type)]]
|
|
1143
|
+
: []));
|
|
1054
1144
|
current.append(recordDetails(doc, [['記録ID', form.current.record?.id], ['今の内容のdigest', form.current.digest]]));
|
|
1055
1145
|
panel.append(current);
|
|
1056
1146
|
} else if (form.message) {
|
|
@@ -1065,6 +1155,8 @@ export function renderCorrectionPanel(doc, form, callbacks) {
|
|
|
1065
1155
|
});
|
|
1066
1156
|
if (form.kind === 'create_edge') {
|
|
1067
1157
|
formElement.append(renderCreateFields(doc, form, callbacks));
|
|
1158
|
+
} else if (form.kind === 'create_entity') {
|
|
1159
|
+
formElement.append(renderCreateEntityFields(doc, form, callbacks));
|
|
1068
1160
|
} else {
|
|
1069
1161
|
const group = makeElement(doc, 'div', { className: 'bb-graph-fields' });
|
|
1070
1162
|
for (const field of form.fields) group.append(fieldControl(doc, form, field, callbacks));
|
|
@@ -1298,6 +1390,30 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
|
|
|
1298
1390
|
void searchCandidates('');
|
|
1299
1391
|
return form;
|
|
1300
1392
|
},
|
|
1393
|
+
/**
|
|
1394
|
+
* Create a person or a company. `types` lists the kinds offered (only
|
|
1395
|
+
* `person` and `org` are created; others are dropped), `typeNames` their
|
|
1396
|
+
* labels. Null outside the scope.
|
|
1397
|
+
*/
|
|
1398
|
+
openCreateEntity({ types = ['person', 'org'], type, typeNames = {}, title = '記録を加える', subject, projects = [] } = {}) {
|
|
1399
|
+
const entityTypes = allowed.createEntities
|
|
1400
|
+
? types.filter((candidate) => (candidate === 'person' || candidate === 'org') && allowed.entity(candidate))
|
|
1401
|
+
: [];
|
|
1402
|
+
if (entityTypes.length === 0) return null;
|
|
1403
|
+
const fields = ['name', 'aliases', 'summary', 'validFrom'];
|
|
1404
|
+
form = baseForm('create_entity', {
|
|
1405
|
+
title,
|
|
1406
|
+
subject: subject ?? null,
|
|
1407
|
+
entityTypes,
|
|
1408
|
+
typeNames,
|
|
1409
|
+
fields,
|
|
1410
|
+
projects: entityTypes.length === 1 && entityTypes[0] === 'person' ? creationProjects(projects) : [],
|
|
1411
|
+
original: Object.fromEntries(fields.map((field) => [field, ''])),
|
|
1412
|
+
draft: { type: entityTypes.includes(type) ? type : entityTypes[0], name: '', aliases: '', summary: '', validFrom: '', projectCode: '' },
|
|
1413
|
+
});
|
|
1414
|
+
redraw();
|
|
1415
|
+
return form;
|
|
1416
|
+
},
|
|
1301
1417
|
close() {
|
|
1302
1418
|
iconReadGeneration += 1;
|
|
1303
1419
|
form = null;
|
|
@@ -1353,7 +1469,7 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
|
|
|
1353
1469
|
// The owner now sees the current record; the next save is based on it
|
|
1354
1470
|
// and still sends only the fields the owner changed. Untouched fields
|
|
1355
1471
|
// show the current values so the form never suggests reverting them.
|
|
1356
|
-
if (target.kind !== 'create_edge' && typeof result.current.digest === 'string') {
|
|
1472
|
+
if (target.kind !== 'create_edge' && target.kind !== 'create_entity' && typeof result.current.digest === 'string') {
|
|
1357
1473
|
target.expectedDigest = result.current.digest;
|
|
1358
1474
|
const currentValues = graphRecordValues(result.current.record);
|
|
1359
1475
|
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,11 @@ 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;
|
|
130
|
+
if (payload.personProjects !== undefined && !(Array.isArray(payload.personProjects) && payload.personProjects.every((project) => isRecord(project)
|
|
131
|
+
&& typeof project.code === 'string' && project.code.trim() && isTextOrNull(project.name)))) return INVALID;
|
|
75
132
|
if (payload.people.length === 0 && payload.companies.length === 0 && payload.absenceConfirmed !== true) return { state: 'unknown' };
|
|
76
133
|
return { state: 'ok', payload };
|
|
77
134
|
}
|
|
@@ -116,6 +173,30 @@ export function personStanding(person, ownCompanyId) {
|
|
|
116
173
|
return person.companyIds.length > 0 ? '社外' : '所属未確認';
|
|
117
174
|
}
|
|
118
175
|
|
|
176
|
+
/** The company text recorded on a person whose company is not yet known, or null. */
|
|
177
|
+
export function personOrgNote(person, ownCompanyId) {
|
|
178
|
+
const note = textOrNull(person?.orgNote);
|
|
179
|
+
if (!note) return null;
|
|
180
|
+
const standing = personStanding(person, ownCompanyId);
|
|
181
|
+
return standing === '社内' || standing === '社外' ? null : note;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {object} options
|
|
186
|
+
* @param {boolean} [options.canCorrect=false] Whether the host lets this viewer write. Only then
|
|
187
|
+
* 人を加える, 会社を加える, 所属を付ける, 所属する人を加える and 所属を終える are drawn; otherwise the
|
|
188
|
+
* client refuses every correction before sending it. The host decides it (for example from the
|
|
189
|
+
* viewer's role); this part holds no role name.
|
|
190
|
+
* @param {{ entityTypes?: string[], edges?: boolean, createEdges?: boolean, createEntities?: boolean }} [options.correctionScope]
|
|
191
|
+
* With writes allowed, only these (see `graphCorrectionScope`): `createEntities: false` withholds
|
|
192
|
+
* 人を加える・会社を加える, `createEdges: false` withholds 所属を付ける・所属する人を加える, `edges: false`
|
|
193
|
+
* withholds 所属を終える, and `entityTypes` limits the kinds that may be created.
|
|
194
|
+
* A host that places people in projects lists the projects a new person may join as
|
|
195
|
+
* `personProjects` (`[{ code, name }]`) in its read; 人を加える then requires one and sends it as
|
|
196
|
+
* `entity.projectCode`. Without that list (the local host) no project is asked or sent.
|
|
197
|
+
* @param {string} [options.token] Launch token sent with a correction.
|
|
198
|
+
* @param {string} [options.tokenHeader] Header name for the token.
|
|
199
|
+
*/
|
|
119
200
|
export function createPeopleAndCompaniesView({
|
|
120
201
|
root,
|
|
121
202
|
rail,
|
|
@@ -123,16 +204,29 @@ export function createPeopleAndCompaniesView({
|
|
|
123
204
|
document: explicitDocument,
|
|
124
205
|
fetcher,
|
|
125
206
|
basePath = '/api/graph',
|
|
207
|
+
token,
|
|
208
|
+
tokenHeader,
|
|
126
209
|
autoLoad = true,
|
|
127
210
|
initialEntityId,
|
|
128
211
|
now = () => new Date(),
|
|
212
|
+
canCorrect = false,
|
|
213
|
+
correctionScope,
|
|
129
214
|
sourceNotice,
|
|
130
215
|
emptyNotice,
|
|
131
216
|
correctionHref = (id) => `#graph?entity_id=${encodeURIComponent(id)}`,
|
|
132
217
|
} = {}) {
|
|
133
218
|
if (!root) throw new TypeError('root is required');
|
|
134
219
|
const doc = getDocument(explicitDocument);
|
|
135
|
-
const
|
|
220
|
+
const graphClient = createGraphClient({ fetcher, basePath, token, ...(tokenHeader ? { tokenHeader } : {}) });
|
|
221
|
+
const writable = canCorrect === true;
|
|
222
|
+
const client = writable ? graphClient : readOnlyGraphClient(graphClient);
|
|
223
|
+
const scope = graphCorrectionScope(correctionScope);
|
|
224
|
+
const can = Object.freeze({
|
|
225
|
+
createPerson: writable && scope.createEntities && scope.entity('person'),
|
|
226
|
+
createCompany: writable && scope.createEntities && scope.entity('org'),
|
|
227
|
+
addMembership: writable && scope.createEdges,
|
|
228
|
+
endMembership: writable && scope.edges,
|
|
229
|
+
});
|
|
136
230
|
const context = graphPageContext(page);
|
|
137
231
|
const layout = createGraphLayout(doc, {
|
|
138
232
|
root,
|
|
@@ -142,6 +236,21 @@ export function createPeopleAndCompaniesView({
|
|
|
142
236
|
contractVersion: PEOPLE_AND_COMPANIES_VIEW_CONTRACT_VERSION,
|
|
143
237
|
});
|
|
144
238
|
const state = { list: { state: 'loading' }, detail: null };
|
|
239
|
+
const correction = createGraphCorrection({
|
|
240
|
+
client,
|
|
241
|
+
now,
|
|
242
|
+
scope: correctionScope,
|
|
243
|
+
rerender: () => controller.render(),
|
|
244
|
+
onSaved: async (payload, form) => {
|
|
245
|
+
// Read the saved state back from the host, never from the form.
|
|
246
|
+
const id = form.kind === 'create_entity' ? payload.record?.id : state.detail?.id;
|
|
247
|
+
await controller.load({ keepDetail: true });
|
|
248
|
+
if (id) await controller.openEntity(id, { keepCorrection: true });
|
|
249
|
+
},
|
|
250
|
+
onConflict: async () => {
|
|
251
|
+
if (state.detail) await controller.openEntity(state.detail.id, { keepCorrection: true });
|
|
252
|
+
},
|
|
253
|
+
});
|
|
145
254
|
|
|
146
255
|
const nameOf = (id) => {
|
|
147
256
|
if (state.list.state !== 'ok') return id;
|
|
@@ -161,7 +270,7 @@ export function createPeopleAndCompaniesView({
|
|
|
161
270
|
onSelect: (id) => void controller.openEntity(id),
|
|
162
271
|
cells: [
|
|
163
272
|
{ primary: person.isOwner ? `${person.name}(あなた)` : person.name, secondary: person.id },
|
|
164
|
-
person.companyIds.length > 0 ? person.companyIds.map(nameOf).join('、') :
|
|
273
|
+
person.companyIds.length > 0 ? person.companyIds.map(nameOf).join('、') : companyCell(person, ownId),
|
|
165
274
|
person.accountableProjectCount > 0
|
|
166
275
|
? `${person.projectCount}件(うち責任 ${person.accountableProjectCount}件)`
|
|
167
276
|
: `${person.projectCount}件`,
|
|
@@ -171,6 +280,39 @@ export function createPeopleAndCompaniesView({
|
|
|
171
280
|
}));
|
|
172
281
|
}
|
|
173
282
|
|
|
283
|
+
function orgNoteText(note) {
|
|
284
|
+
return `記録上のメモ: ${note}(所属ではありません)`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function companyCell(person, ownId) {
|
|
288
|
+
const note = personOrgNote(person, ownId);
|
|
289
|
+
if (!note) return '所属の記録なし';
|
|
290
|
+
const cell = makeElement(doc, 'span', { className: 'bb-pc-company-cell' });
|
|
291
|
+
cell.append(
|
|
292
|
+
makeElement(doc, 'span', { text: '所属の記録なし' }),
|
|
293
|
+
makeElement(doc, 'small', { className: 'bb-pc-org-note', text: orgNoteText(note) }),
|
|
294
|
+
);
|
|
295
|
+
return cell;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function sectionActions(label, onClick) {
|
|
299
|
+
const actions = makeElement(doc, 'div', { className: 'bb-pc-actions' });
|
|
300
|
+
actions.append(button(doc, label, onClick));
|
|
301
|
+
return actions;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function openCreateEntity(type) {
|
|
305
|
+
// A host that places people in projects lists the ones a new person may join (`personProjects`).
|
|
306
|
+
const projects = type === 'person' && state.list.state === 'ok' ? state.list.payload.personProjects ?? [] : [];
|
|
307
|
+
correction.openCreateEntity({
|
|
308
|
+
types: [type],
|
|
309
|
+
typeNames: { person: '人', org: '会社' },
|
|
310
|
+
title: type === 'org' ? '会社を加える' : '人を加える',
|
|
311
|
+
subject: type === 'org' ? 'Graphに会社の記録を作ります。' : 'Graphに人の記録を作ります。',
|
|
312
|
+
projects,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
174
316
|
function peopleLedger(label, people, empty) {
|
|
175
317
|
return workspaceLedger(doc, {
|
|
176
318
|
className: 'bb-pc-people-ledger',
|
|
@@ -184,6 +326,7 @@ export function createPeopleAndCompaniesView({
|
|
|
184
326
|
function peopleSection(payload) {
|
|
185
327
|
const section = makeElement(doc, 'section', { className: 'bb-pc-people', attrs: { 'aria-label': '人' } });
|
|
186
328
|
section.append(workspaceSectionTitle(doc, { title: '人', lead: '社内か社外かは、所属(会社への所属の関係)と自社から決めています。印は別に持っていません。' }));
|
|
329
|
+
if (can.createPerson) section.append(sectionActions('人を加える', () => openCreateEntity('person')));
|
|
187
330
|
const groups = groupPeople(payload);
|
|
188
331
|
if (!groups.ownCompanyId) {
|
|
189
332
|
const reason = OWN_COMPANY_REASONS[payload.ownCompany.reason] ?? '自社を決められません。';
|
|
@@ -210,7 +353,11 @@ export function createPeopleAndCompaniesView({
|
|
|
210
353
|
|
|
211
354
|
function companySection(payload) {
|
|
212
355
|
const section = makeElement(doc, 'section', { className: 'bb-pc-companies', attrs: { 'aria-label': '会社' } });
|
|
213
|
-
section.append(workspaceSectionTitle(doc, {
|
|
356
|
+
section.append(workspaceSectionTitle(doc, {
|
|
357
|
+
title: '会社',
|
|
358
|
+
lead: '自社を先頭に、顧客・協業先など関わる会社を並べています。種類は、会社の属性、プロジェクトとの関係の役割、提案中の案件の記録から出しています。記録が無い会社には種類を出しません。',
|
|
359
|
+
}));
|
|
360
|
+
if (can.createCompany) section.append(sectionActions('会社を加える', () => openCreateEntity('org')));
|
|
214
361
|
const active = payload.companies.filter((company) => company.active);
|
|
215
362
|
section.append(workspaceLedger(doc, {
|
|
216
363
|
className: 'bb-pc-company-ledger',
|
|
@@ -223,8 +370,11 @@ export function createPeopleAndCompaniesView({
|
|
|
223
370
|
onSelect: (id) => void controller.openEntity(id),
|
|
224
371
|
cells: [
|
|
225
372
|
{ primary: company.id === payload.ownCompany.id ? `${company.name}(自社)` : company.name, secondary: company.id },
|
|
373
|
+
companyKindLabel(company),
|
|
226
374
|
`${company.memberIds.length}人`,
|
|
227
|
-
|
|
375
|
+
company.relatedProjects.length > 0
|
|
376
|
+
? `${company.projectIds.length}件(関わる ${company.relatedProjects.length}件)`
|
|
377
|
+
: `${company.projectIds.length}件`,
|
|
228
378
|
validityLabel(company, payload.asOf),
|
|
229
379
|
],
|
|
230
380
|
})),
|
|
@@ -264,7 +414,7 @@ export function createPeopleAndCompaniesView({
|
|
|
264
414
|
// -------------------------------------------------------------------------
|
|
265
415
|
// Rail
|
|
266
416
|
|
|
267
|
-
function edgeList(edges, empty) {
|
|
417
|
+
function edgeList(edges, empty, { roleLabel = textOrNull, actions } = {}) {
|
|
268
418
|
if (edges.length === 0) return makeElement(doc, 'p', { className: 'bb-pc-empty', text: empty });
|
|
269
419
|
const list = makeElement(doc, 'ul', { className: 'bb-pc-relation-list' });
|
|
270
420
|
for (const edge of edges) {
|
|
@@ -276,16 +426,68 @@ export function createPeopleAndCompaniesView({
|
|
|
276
426
|
head,
|
|
277
427
|
makeElement(doc, 'span', { className: 'bb-pc-relation-meta', text: [
|
|
278
428
|
validityLabel(edge, asOf()),
|
|
279
|
-
|
|
429
|
+
roleLabel(edge.role) ? `役割: ${roleLabel(edge.role)}` : null,
|
|
280
430
|
`出典: ${source.kind}${source.reference ? `(${source.reference})` : ''}`,
|
|
281
431
|
].filter(Boolean).join(' ') }),
|
|
282
432
|
);
|
|
433
|
+
const controls = actions?.(edge) ?? [];
|
|
434
|
+
if (controls.length > 0) {
|
|
435
|
+
const row = makeElement(doc, 'div', { className: 'bb-pc-relation-actions' });
|
|
436
|
+
row.append(...controls);
|
|
437
|
+
item.append(row);
|
|
438
|
+
}
|
|
439
|
+
list.append(item);
|
|
440
|
+
}
|
|
441
|
+
return list;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function dealList(deals) {
|
|
445
|
+
const list = makeElement(doc, 'ul', { className: 'bb-pc-relation-list' });
|
|
446
|
+
for (const deal of deals) {
|
|
447
|
+
const item = makeElement(doc, 'li', { className: 'bb-pc-relation' });
|
|
448
|
+
item.append(makeElement(doc, 'strong', { text: deal.name ?? '名前の記録が無い案件' }));
|
|
449
|
+
if (deal.stage) item.append(makeElement(doc, 'span', { className: 'bb-pc-relation-meta', text: `段階: ${deal.stage}` }));
|
|
283
450
|
list.append(item);
|
|
284
451
|
}
|
|
285
452
|
return list;
|
|
286
453
|
}
|
|
287
454
|
|
|
455
|
+
function membershipActions(edge, entity) {
|
|
456
|
+
if (!can.endMembership || !edge.active) return [];
|
|
457
|
+
const person = entity.type === 'org' ? edge.counterpart.name : entity.name;
|
|
458
|
+
const company = entity.type === 'org' ? entity.name : edge.counterpart.name;
|
|
459
|
+
return [button(doc, '所属を終える', () => correction.openEdge(edge, {
|
|
460
|
+
mode: 'end',
|
|
461
|
+
title: '所属を終える',
|
|
462
|
+
subject: `${person}の「${company}」への所属に終了日を入れます。記録は消さずに残ります。`,
|
|
463
|
+
}))];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function addMembershipButton(entity) {
|
|
467
|
+
if (!can.addMembership) return null;
|
|
468
|
+
const label = entity.type === 'org' ? '所属する人を加える' : '所属を付ける';
|
|
469
|
+
return sectionActions(label, () => correction.openCreate(
|
|
470
|
+
{ id: entity.id, type: entity.type, name: entity.name },
|
|
471
|
+
{
|
|
472
|
+
relations: ['member_of'],
|
|
473
|
+
relationNames: { member_of: '所属' },
|
|
474
|
+
relationLabel: '関わり方',
|
|
475
|
+
title: label,
|
|
476
|
+
subject: entity.type === 'org' ? `「${entity.name}」に所属する人を選びます。` : `${entity.name}が所属する会社を選びます。`,
|
|
477
|
+
},
|
|
478
|
+
));
|
|
479
|
+
}
|
|
480
|
+
|
|
288
481
|
function railContent() {
|
|
482
|
+
const panel = correction.render(doc);
|
|
483
|
+
const blocks = railBlocks();
|
|
484
|
+
if (!panel) return blocks;
|
|
485
|
+
// A new record's form shows first; a membership form right after the record's head.
|
|
486
|
+
if (correction.form?.kind === 'create_entity' || !state.detail || state.detail.state !== 'ok') return [panel, ...blocks];
|
|
487
|
+
return [blocks[0], panel, ...blocks.slice(1)];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function railBlocks() {
|
|
289
491
|
const detail = state.detail;
|
|
290
492
|
if (!detail) {
|
|
291
493
|
return [workspaceDetailEmpty(doc, { mark: '人', title: '人か会社を選んでください', text: '所属と関わるプロジェクトを、有効期間と出典つきで確かめられます。' })];
|
|
@@ -297,6 +499,9 @@ export function createPeopleAndCompaniesView({
|
|
|
297
499
|
? state.list.payload.people.find((person) => person.id === entity.id)
|
|
298
500
|
: null;
|
|
299
501
|
const ownId = state.list.state === 'ok' ? state.list.payload.ownCompany.id : null;
|
|
502
|
+
const company = state.list.state === 'ok' && entity.type === 'org'
|
|
503
|
+
? state.list.payload.companies.find((item) => item.id === entity.id)
|
|
504
|
+
: null;
|
|
300
505
|
const blocks = [workspaceRailHead(doc, {
|
|
301
506
|
kicker: entity.type === 'org' ? '会社' : '人',
|
|
302
507
|
title: entity.name,
|
|
@@ -305,14 +510,33 @@ export function createPeopleAndCompaniesView({
|
|
|
305
510
|
})];
|
|
306
511
|
blocks.push(workspaceDefinition(doc, [
|
|
307
512
|
...(entity.type === 'person' && listed ? [['区分', personStanding(listed, ownId)]] : []),
|
|
513
|
+
...(entity.type === 'person' && listed && personOrgNote(listed, ownId)
|
|
514
|
+
? [['記録上のメモ', `${personOrgNote(listed, ownId)}(人物の記録に文字で残っている会社名です。所属の記録ではありません)`]]
|
|
515
|
+
: []),
|
|
308
516
|
...(entity.type === 'org' && entity.id === ownId ? [['区分', '自社']] : []),
|
|
517
|
+
...(company && company.kinds.length > 0 ? [['種類', company.kinds.map((item) => {
|
|
518
|
+
const sources = item.sources.map((source) => ownKey(COMPANY_KIND_SOURCE_LABELS, source) ?? source).join('・');
|
|
519
|
+
return `${ownKey(COMPANY_KIND_LABELS, item.kind) ?? item.kind}(${sources}から)`;
|
|
520
|
+
}).join('、')]] : []),
|
|
521
|
+
...(company?.lifecycleStage ? [['記録上の段階', company.lifecycleStage]] : []),
|
|
309
522
|
['有効期間', validityLabel(entity, asOf())],
|
|
310
523
|
]));
|
|
311
524
|
if (entity.type === 'org') {
|
|
312
|
-
blocks.push(workspaceRailBlock(doc, { title: '所属する人', content:
|
|
313
|
-
|
|
525
|
+
blocks.push(workspaceRailBlock(doc, { title: '所属する人', content: [
|
|
526
|
+
edgeList(incoming.filter((edge) => edge.relation === 'member_of'), '所属する人の記録はありません。', { actions: (edge) => membershipActions(edge, entity) }),
|
|
527
|
+
addMembershipButton(entity),
|
|
528
|
+
] }));
|
|
529
|
+
blocks.push(workspaceRailBlock(doc, { title: 'この会社のプロジェクト', content: edgeList(incoming.filter((edge) => edge.relation === 'owned_by'), 'この会社のプロジェクトの記録はありません。') }));
|
|
530
|
+
blocks.push(workspaceRailBlock(doc, {
|
|
531
|
+
title: '顧客・協業先として関わるプロジェクト',
|
|
532
|
+
content: edgeList(outgoing.filter((edge) => edge.relation === 'related_to'), '顧客・協業先として関わるプロジェクトの記録はありません。', { roleLabel: relatedRoleLabel }),
|
|
533
|
+
}));
|
|
534
|
+
if (company && company.deals.length > 0) blocks.push(workspaceRailBlock(doc, { title: '提案中の案件', content: dealList(company.deals) }));
|
|
314
535
|
} else {
|
|
315
|
-
blocks.push(workspaceRailBlock(doc, { title: '所属', content:
|
|
536
|
+
blocks.push(workspaceRailBlock(doc, { title: '所属', content: [
|
|
537
|
+
edgeList(outgoing.filter((edge) => edge.relation === 'member_of'), '会社への所属の記録はありません。', { actions: (edge) => membershipActions(edge, entity) }),
|
|
538
|
+
addMembershipButton(entity),
|
|
539
|
+
] }));
|
|
316
540
|
blocks.push(workspaceRailBlock(doc, {
|
|
317
541
|
title: '関わるプロジェクト',
|
|
318
542
|
content: edgeList(outgoing.filter((edge) => edge.relation === 'participates_in' || edge.relation === 'accountable_for'), '関わるプロジェクトの記録はありません。'),
|
|
@@ -320,7 +544,9 @@ export function createPeopleAndCompaniesView({
|
|
|
320
544
|
}
|
|
321
545
|
const fix = makeElement(doc, 'p', { className: 'bb-pc-fix' });
|
|
322
546
|
fix.append(
|
|
323
|
-
makeElement(doc, 'span', { text:
|
|
547
|
+
makeElement(doc, 'span', { text: writable
|
|
548
|
+
? '名前・別名・要約などの間違いは「情報と関係」で、この記録を開いて直します。'
|
|
549
|
+
: '間違いは「情報と関係」で、この記録を開いて直します。' }),
|
|
324
550
|
makeElement(doc, 'a', { text: '情報と関係で開く', attrs: { href: correctionHref(entity.id) } }),
|
|
325
551
|
);
|
|
326
552
|
blocks.push(fix);
|
|
@@ -334,17 +560,25 @@ export function createPeopleAndCompaniesView({
|
|
|
334
560
|
get state() {
|
|
335
561
|
return state;
|
|
336
562
|
},
|
|
563
|
+
get correction() {
|
|
564
|
+
return correction;
|
|
565
|
+
},
|
|
337
566
|
render() {
|
|
338
567
|
layout.render(workspace(), railContent());
|
|
339
568
|
},
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
569
|
+
/** Reads the ledgers. `keepDetail` keeps the open record (and the shown ledgers) while reading. */
|
|
570
|
+
async load({ keepDetail = false } = {}) {
|
|
571
|
+
if (!keepDetail || state.list.state !== 'ok') {
|
|
572
|
+
state.list = { state: 'loading' };
|
|
573
|
+
controller.render();
|
|
574
|
+
}
|
|
343
575
|
state.list = normalizePeopleAndCompanies(await client.read('/people-and-companies'));
|
|
344
576
|
controller.render();
|
|
345
577
|
if (initialEntityId && !state.detail) await controller.openEntity(initialEntityId);
|
|
346
578
|
},
|
|
347
|
-
|
|
579
|
+
/** Opens a record in the rail. Choosing another record closes an open form unless `keepCorrection`. */
|
|
580
|
+
async openEntity(id, { keepCorrection = false } = {}) {
|
|
581
|
+
if (!keepCorrection && correction.form && state.detail?.id !== id) correction.close();
|
|
348
582
|
state.detail = { id, state: 'loading' };
|
|
349
583
|
controller.render();
|
|
350
584
|
const result = await client.read(`/entities/${encodeURIComponent(id)}`);
|