@unson/brainbase-mcp 0.41.11 → 0.41.13

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.
@@ -3,14 +3,21 @@
3
3
  * to, and whether they are internal, external or not yet known.
4
4
  *
5
5
  * Reads `GET {base}/people-and-companies` for the ledgers and
6
- * `GET {base}/entities/:id` for the selected record in the right rail (see
7
- * `graph-view-shared.js`). Read only unless the host says it can write
8
- * (`canCorrect: true`): then it creates people and companies
9
- * (`create_entity`), adds a company membership (`create_edge` of
10
- * `member_of`) and ends one with an end date (`update_edge`), all through
11
- * `POST {base}/corrections`. Nothing is deleted. After a save the ledgers
12
- * and the rail are read again from the host. Other corrections stay in
13
- * 「情報と関係」, which the rail links to.
6
+ * `GET {base}/entities/:id` for the selected record (see
7
+ * `graph-view-shared.js`). Choosing a row turns the workspace into the
8
+ * record's page, as a CRM does: a way back to the ledgers with the record's
9
+ * place and previous / next, the head with what can be done to this record,
10
+ * then its memberships and projects. The record's facts (standing, kind,
11
+ * validity, id) sit in the right rail, or on the page when the host has no
12
+ * rail. So the page never depends on where a host puts its rail on a narrow
13
+ * screen. Read only unless the host says it can write (`canCorrect: true`):
14
+ * then it creates people and companies (`create_entity`), adds a company
15
+ * membership (`create_edge` of `member_of`) and ends one with an end date
16
+ * (`update_edge`), all through `POST {base}/corrections`. Nothing is
17
+ * deleted, and the page says so next to the actions. A viewer who cannot
18
+ * write sees the actions disabled with the reason. After a save the ledgers
19
+ * and the record are read again from the host. Other corrections stay in
20
+ * 「情報と関係」, which the page links to.
14
21
  *
15
22
  * The company text recorded on a person (`orgNote`) is shown only for
16
23
  * people whose company is not yet known, and only as a note: it never
@@ -54,6 +61,7 @@ import {
54
61
  validityLabel,
55
62
  } from './graph-view-shared.js';
56
63
  import {
64
+ workspaceButton,
57
65
  workspaceDefinition,
58
66
  workspaceDetailEmpty,
59
67
  workspaceHostNotice,
@@ -61,7 +69,6 @@ import {
61
69
  workspaceNotice,
62
70
  workspacePageHeader,
63
71
  workspaceRailBlock,
64
- workspaceRailHead,
65
72
  workspaceSectionTitle,
66
73
  } from './workspace-kit.js';
67
74
 
@@ -82,6 +89,10 @@ const RELATED_ROLE_LABELS = Object.freeze({ customer: '顧客', partner: '協業
82
89
 
83
90
  const INVALID = Object.freeze({ state: 'invalid', reason: '応答の形式が不正です' });
84
91
 
92
+ /** What the page says next to the actions: nothing is deleted, a membership is ended. */
93
+ export const NO_DELETE_NOTE = '消す操作はありません。所属は終了日を入れて「終える」と一覧から外れ、記録は履歴に残ります。';
94
+ const DEFAULT_READ_ONLY_NOTE = 'この画面では見るだけです。人・会社の追加と、所属を付ける・終える操作はできません。';
95
+
85
96
  const OWN_COMPANY_REASONS = Object.freeze({
86
97
  owner_has_no_company: 'あなた(本人)の所属する会社がGraphにありません。',
87
98
  owner_has_several_companies: 'あなた(本人)が複数の会社に所属しているため、自社を1つに決められません。',
@@ -209,6 +220,12 @@ export function personOrgNote(person, ownCompanyId) {
209
220
  * A host that places people in projects lists the projects a new person may join as
210
221
  * `personProjects` (`[{ code, name }]`) in its read; 人を加える then requires one and sends it as
211
222
  * `entity.projectCode`. Without that list (the local host) no project is asked or sent.
223
+ * @param {string | ((subject: { id: string, type: string, name: string } | null) => string | null)} [options.readOnlyNote]
224
+ * Without writes, why the actions are disabled (for example who can write). Without it a
225
+ * general sentence is shown; the part names no role itself.
226
+ * @param {string} [options.recordFixNote] When the host cannot correct a person's or company's own
227
+ * record (name, aliases, summary), why; the page then shows this instead of the link to
228
+ * 「情報と関係」.
212
229
  * @param {string} [options.token] Launch token sent with a correction.
213
230
  * @param {string} [options.tokenHeader] Header name for the token.
214
231
  */
@@ -228,6 +245,8 @@ export function createPeopleAndCompaniesView({
228
245
  correctionScope,
229
246
  sourceNotice,
230
247
  emptyNotice,
248
+ readOnlyNote,
249
+ recordFixNote,
231
250
  correctionHref = (id) => `#graph?entity_id=${encodeURIComponent(id)}`,
232
251
  } = {}) {
233
252
  if (!root) throw new TypeError('root is required');
@@ -251,6 +270,10 @@ export function createPeopleAndCompaniesView({
251
270
  contractVersion: PEOPLE_AND_COMPANIES_VIEW_CONTRACT_VERSION,
252
271
  });
253
272
  const state = { list: { state: 'loading' }, detail: null };
273
+ // Where to move after the next render: the opened record's head, or the row it was opened from.
274
+ let pendingScroll = null;
275
+ let detailTop = null;
276
+ let detailHead = null;
254
277
  const correction = createGraphCorrection({
255
278
  client,
256
279
  now,
@@ -371,10 +394,35 @@ export function createPeopleAndCompaniesView({
371
394
  return cell;
372
395
  }
373
396
 
374
- function sectionActions(label, onClick) {
375
- const actions = makeElement(doc, 'div', { className: 'bb-pc-actions' });
376
- actions.append(button(doc, label, onClick));
377
- return actions;
397
+ function readOnlyText(subject) {
398
+ const value = typeof readOnlyNote === 'function' ? readOnlyNote(subject ?? null) : readOnlyNote;
399
+ return textOrNull(value) ?? DEFAULT_READ_ONLY_NOTE;
400
+ }
401
+
402
+ /**
403
+ * One action. Allowed: a button. Withheld by the host's scope: nothing.
404
+ * Without writes: the button disabled, pointing at the reason shown next to it.
405
+ */
406
+ function actionButton(text, allowed, onClick, { variant = 'default', reasonId, adds = false } = {}) {
407
+ const attrs = { 'data-adds': adds };
408
+ if (allowed) return workspaceButton(doc, { text, variant, onClick, attrs });
409
+ if (writable) return null;
410
+ return workspaceButton(doc, { text, variant, disabled: true, attrs: { ...attrs, 'aria-describedby': reasonId } });
411
+ }
412
+
413
+ /** The actions with what the page says about them: why they are disabled, and that nothing is deleted. */
414
+ function actionGroup({ label, buttons, notes = [], subject, reasonId, className = '' }) {
415
+ const shown = buttons.filter(Boolean);
416
+ const group = makeElement(doc, 'div', { className: `bb-pc-action-group${className ? ` ${className}` : ''}`, attrs: { role: 'group', 'aria-label': label } });
417
+ if (shown.length > 0) {
418
+ const row = makeElement(doc, 'div', { className: 'bb-pc-action-row' });
419
+ row.append(...shown);
420
+ group.append(row);
421
+ }
422
+ if (!writable && shown.length > 0) group.append(makeElement(doc, 'p', { className: 'bb-pc-action-reason', text: readOnlyText(subject), attrs: { id: reasonId } }));
423
+ const shownNotes = notes.filter(Boolean);
424
+ for (const note of shownNotes) group.append(typeof note === 'string' ? makeElement(doc, 'p', { className: 'bb-pc-action-note', text: note }) : note);
425
+ return shown.length > 0 || shownNotes.length > 0 ? group : null;
378
426
  }
379
427
 
380
428
  function openCreateEntity(type) {
@@ -402,7 +450,6 @@ export function createPeopleAndCompaniesView({
402
450
  function peopleSection(payload) {
403
451
  const section = makeElement(doc, 'section', { className: 'bb-pc-people', attrs: { 'aria-label': '人', id: 'bb-pc-people' } });
404
452
  section.append(workspaceSectionTitle(doc, { title: '人', lead: '社内か社外かは、所属(会社への所属の関係)と自社から決めています。印は別に持っていません。' }));
405
- if (can.createPerson) section.append(sectionActions('人を加える', () => openCreateEntity('person')));
406
453
  const groups = groupPeople(payload);
407
454
  if (!groups.ownCompanyId) {
408
455
  const reason = OWN_COMPANY_REASONS[payload.ownCompany.reason] ?? '自社を決められません。';
@@ -433,7 +480,6 @@ export function createPeopleAndCompaniesView({
433
480
  title: '会社',
434
481
  lead: '自社を先頭に、顧客・協業先など関わる会社を並べています。種類は、会社の属性、プロジェクトとの関係の役割、提案中の案件の記録から出しています。記録が無い会社には種類を出しません。',
435
482
  }));
436
- if (can.createCompany) section.append(sectionActions('会社を加える', () => openCreateEntity('org')));
437
483
  const active = payload.companies.filter((company) => company.active);
438
484
  section.append(workspaceLedger(doc, {
439
485
  className: 'bb-pc-company-ledger',
@@ -469,10 +515,23 @@ export function createPeopleAndCompaniesView({
469
515
  }
470
516
 
471
517
  function workspace() {
518
+ if (state.detail) return detailPage();
519
+ const listed = state.list.state === 'ok' && !state.list.payload.absenceConfirmed;
520
+ const addActions = listed
521
+ ? actionGroup({
522
+ label: '人と会社を加える',
523
+ className: 'bb-pc-add',
524
+ reasonId: 'bb-pc-add-reason',
525
+ buttons: [
526
+ actionButton('人を加える', can.createPerson, () => openCreateEntity('person'), { variant: 'primary', reasonId: 'bb-pc-add-reason', adds: true }),
527
+ actionButton('会社を加える', can.createCompany, () => openCreateEntity('org'), { reasonId: 'bb-pc-add-reason', adds: true }),
528
+ ],
529
+ })
530
+ : null;
472
531
  const children = [workspacePageHeader(doc, {
473
532
  crumbs: context.crumbs,
474
533
  title: '人と会社',
475
- lead: 'Graphに登録された人が、どの会社の人か、社内か社外か所属が分からないかを確かめます。',
534
+ lead: 'Graphに登録された人が、どの会社の人か、社内か社外か所属が分からないかを確かめます。行を押すと、その人・会社のページが開きます。',
476
535
  source: context.source,
477
536
  })];
478
537
  const hostNotice = sourceNotice ? workspaceHostNotice(doc, sourceNotice) : null;
@@ -481,10 +540,192 @@ export function createPeopleAndCompaniesView({
481
540
  if (notice) return [...children, notice];
482
541
  const payload = state.list.payload;
483
542
  if (payload.absenceConfirmed) {
484
- return [...children, graphHostEmptyNotice(doc, emptyNotice)
485
- ?? workspaceNotice(doc, { label: '未登録', tone: 'info', role: 'status', text: 'Graphに人と会社の記録はまだありません。' })];
543
+ // Nothing is listed yet, so creating the first record is the next step.
544
+ const empty = graphHostEmptyNotice(doc, emptyNotice)
545
+ ?? workspaceNotice(doc, { label: '未登録', tone: 'info', role: 'status', text: 'Graphに人と会社の記録はまだありません。' });
546
+ const first = actionGroup({
547
+ label: '人と会社を加える',
548
+ className: 'bb-pc-add',
549
+ reasonId: 'bb-pc-add-reason',
550
+ buttons: [
551
+ actionButton('人を加える', can.createPerson, () => openCreateEntity('person'), { variant: 'primary', reasonId: 'bb-pc-add-reason', adds: true }),
552
+ actionButton('会社を加える', can.createCompany, () => openCreateEntity('org'), { reasonId: 'bb-pc-add-reason', adds: true }),
553
+ ],
554
+ });
555
+ return [...children, empty, first, correction.render(doc)];
486
556
  }
487
- return [...children, jumpLinks(payload), peopleSection(payload), companySection(payload), endedNote(payload)];
557
+ // A new record's form opens right under the buttons that open it.
558
+ return [...children, addActions, correction.render(doc), jumpLinks(payload), peopleSection(payload), companySection(payload), endedNote(payload)];
559
+ }
560
+
561
+ // -------------------------------------------------------------------------
562
+ // The record's page
563
+
564
+ /** The listed records of the opened record's kind, in the order the ledgers show them. */
565
+ function listedIds(type) {
566
+ if (state.list.state !== 'ok') return [];
567
+ const payload = state.list.payload;
568
+ if (type === 'org') return payload.companies.filter((company) => company.active).map((company) => company.id);
569
+ const groups = groupPeople(payload);
570
+ return [...groups.internal, ...groups.external.flatMap((group) => group.people), ...groups.unknown].map((person) => person.id);
571
+ }
572
+
573
+ function backToList() {
574
+ const id = state.detail?.id;
575
+ correction.close();
576
+ state.detail = null;
577
+ pendingScroll = id ? { row: id } : null;
578
+ controller.render();
579
+ }
580
+
581
+ function detailNav(entity) {
582
+ const nav = makeElement(doc, 'nav', { className: 'bb-pc-detail-nav', attrs: { 'aria-label': '人と会社の一覧の中の位置' } });
583
+ nav.append(button(doc, '← 人と会社の一覧へ', backToList, { className: 'bb-ws-button is-quiet bb-pc-back' }));
584
+ const type = entity?.type === 'org' ? 'org' : 'person';
585
+ const ids = listedIds(type);
586
+ const index = entity ? ids.indexOf(entity.id) : -1;
587
+ if (index >= 0) {
588
+ const place = makeElement(doc, 'span', { className: 'bb-pc-detail-place' });
589
+ place.append(
590
+ makeElement(doc, 'span', { text: type === 'org' ? '会社' : '人' }),
591
+ makeElement(doc, 'span', { className: 'bb-pc-count', text: `${index + 1} / ${ids.length}` }),
592
+ );
593
+ const step = (offset, text, label) => workspaceButton(doc, {
594
+ text,
595
+ variant: 'quiet',
596
+ disabled: !ids[index + offset],
597
+ attrs: { 'aria-label': label },
598
+ onClick: () => { if (ids[index + offset]) void controller.openEntity(ids[index + offset]); },
599
+ });
600
+ nav.append(place, step(-1, '‹', '前へ'), step(1, '›', '次へ'));
601
+ }
602
+ return nav;
603
+ }
604
+
605
+ function detailHeadOf(entity, standing, company) {
606
+ const head = makeElement(doc, 'header', { className: 'bb-pc-detail-head' });
607
+ const mark = makeElement(doc, 'span', {
608
+ className: `bb-pc-mark ${entity.type === 'org' ? 'bb-pc-company-mark' : 'bb-pc-person-mark'} bb-pc-tone-${markTone(entity.id)} bb-pc-detail-mark`,
609
+ text: nameInitial(entity.name),
610
+ attrs: { 'aria-hidden': 'true' },
611
+ });
612
+ const copy = makeElement(doc, 'div', { className: 'bb-pc-detail-copy' });
613
+ const listed = state.list.state === 'ok' ? state.list.payload.people.find((person) => person.id === entity.id) : null;
614
+ // The heading takes the focus when the page opens, so a keyboard or screen reader lands here.
615
+ const title = makeElement(doc, 'h2', { text: listed?.isOwner ? `${entity.name}(あなた)` : entity.name, attrs: { tabindex: '-1' } });
616
+ detailHead = title;
617
+ const sub = [entity.type === 'org' ? '会社' : '人', standing, entity.type === 'org' && company ? `所属する人 ${company.memberIds.length}人` : null]
618
+ .filter(Boolean).join(' · ');
619
+ copy.append(title, makeElement(doc, 'p', { className: 'bb-pc-detail-sub', text: sub }));
620
+ const summary = textOrNull(entity.summary);
621
+ if (summary) copy.append(makeElement(doc, 'p', { className: 'bb-pc-detail-lead', text: summary }));
622
+ head.append(mark, copy);
623
+ return head;
624
+ }
625
+
626
+ /** What can be done to this record, gathered under its head. */
627
+ function detailActions(entity, memberships) {
628
+ const subject = { id: entity.id, type: entity.type, name: entity.name };
629
+ const reasonId = 'bb-pc-detail-reason';
630
+ const buttons = entity.type === 'org'
631
+ ? [actionButton('所属する人を加える', can.addMembership, () => openMembership(entity), { variant: 'primary', reasonId, adds: true })]
632
+ : [
633
+ actionButton('所属を付ける', can.addMembership, () => openMembership(entity), { variant: 'primary', reasonId, adds: true }),
634
+ ...memberships.filter((edge) => edge.active).map((edge) => actionButton(
635
+ `「${edge.counterpart.name}」の所属を終える`,
636
+ can.endMembership,
637
+ () => openEndMembership(edge, entity),
638
+ { reasonId },
639
+ )),
640
+ ];
641
+ const fix = recordFixNote
642
+ ? makeElement(doc, 'p', { className: 'bb-pc-action-note', text: recordFixNote })
643
+ : (() => {
644
+ const line = makeElement(doc, 'p', { className: 'bb-pc-action-note' });
645
+ line.append(
646
+ makeElement(doc, 'span', { text: '名前・別名・要約の間違いは、' }),
647
+ makeElement(doc, 'a', { text: '情報と関係でこの記録を開いて直します', attrs: { href: correctionHref(entity.id) } }),
648
+ makeElement(doc, 'span', { text: '。' }),
649
+ );
650
+ return line;
651
+ })();
652
+ const endNote = entity.type === 'org' && (can.endMembership || !writable)
653
+ ? `${NO_DELETE_NOTE}所属する人の所属は、下の「所属する人」の各行で終えます。`
654
+ : NO_DELETE_NOTE;
655
+ return actionGroup({ label: entity.type === 'org' ? 'この会社への操作' : 'この人への操作', className: 'bb-pc-detail-actions', buttons, notes: [endNote, fix], subject, reasonId });
656
+ }
657
+
658
+ function detailFacts(entity, listed, company, ownId) {
659
+ return workspaceDefinition(doc, [
660
+ ...(entity.type === 'person' && listed ? [['区分', personStanding(listed, ownId)]] : []),
661
+ ...(entity.type === 'person' && listed && personOrgNote(listed, ownId)
662
+ ? [['記録上のメモ', `${personOrgNote(listed, ownId)}(人物の記録に文字で残っている会社名です。所属の記録ではありません)`]]
663
+ : []),
664
+ ...(entity.type === 'org' && entity.id === ownId ? [['区分', '自社']] : []),
665
+ ...(company && company.kinds.length > 0 ? [['種類', company.kinds.map((item) => {
666
+ const sources = item.sources.map((source) => ownKey(COMPANY_KIND_SOURCE_LABELS, source) ?? source).join('・');
667
+ return `${ownKey(COMPANY_KIND_LABELS, item.kind) ?? item.kind}(${sources}から)`;
668
+ }).join('、')]] : []),
669
+ ...(company?.lifecycleStage ? [['記録上の段階', company.lifecycleStage]] : []),
670
+ ['有効期間', validityLabel(entity, asOf())],
671
+ ['ID', entity.id],
672
+ ]);
673
+ }
674
+
675
+ function detailBlock(title, content) {
676
+ const block = makeElement(doc, 'section', { className: 'bb-pc-detail-block', attrs: { 'aria-label': title } });
677
+ block.append(makeElement(doc, 'h3', { text: title }));
678
+ for (const item of [content].flat()) if (item) block.append(item);
679
+ return block;
680
+ }
681
+
682
+ function detailPage() {
683
+ const detail = state.detail;
684
+ detailTop = null;
685
+ detailHead = null;
686
+ const notice = graphStateNotice(doc, detail, { onRetry: () => void controller.openEntity(detail.id) });
687
+ if (notice) {
688
+ const nav = detailNav(null);
689
+ detailTop = nav;
690
+ return [nav, notice];
691
+ }
692
+ const { entity, outgoing, incoming } = detail.payload;
693
+ const listed = state.list.state === 'ok' ? state.list.payload.people.find((person) => person.id === entity.id) : null;
694
+ const ownId = state.list.state === 'ok' ? state.list.payload.ownCompany.id : null;
695
+ const company = state.list.state === 'ok' && entity.type === 'org'
696
+ ? state.list.payload.companies.find((item) => item.id === entity.id)
697
+ : null;
698
+ const standing = entity.type === 'person' && listed
699
+ ? personStanding(listed, ownId)
700
+ : entity.type === 'org' && entity.id === ownId ? '自社' : companyKindLabel(company) || null;
701
+ const memberships = entity.type === 'org'
702
+ ? incoming.filter((edge) => edge.relation === 'member_of')
703
+ : outgoing.filter((edge) => edge.relation === 'member_of');
704
+ const page = makeElement(doc, 'article', { className: 'bb-pc-detail', attrs: { 'aria-label': `${entity.type === 'org' ? '会社' : '人'}「${entity.name}」` } });
705
+ detailTop = page;
706
+ page.append(detailNav(entity), detailHeadOf(entity, standing, company));
707
+ const actions = detailActions(entity, memberships);
708
+ if (actions) page.append(actions);
709
+ // A membership form opens right under the actions that open it.
710
+ const panel = correction.render(doc);
711
+ if (panel) page.append(panel);
712
+ // Without a rail of the host's, the facts stay on the page.
713
+ if (layout.inline) page.append(detailBlock('詳細', detailFacts(entity, listed, company, ownId)));
714
+ if (entity.type === 'org') {
715
+ page.append(
716
+ detailBlock('所属する人', edgeList(memberships, '所属する人の記録はありません。', { actions: (edge) => membershipActions(edge, entity) })),
717
+ detailBlock('この会社のプロジェクト', edgeList(incoming.filter((edge) => edge.relation === 'owned_by'), 'この会社のプロジェクトの記録はありません。')),
718
+ detailBlock('顧客・協業先として関わるプロジェクト', edgeList(outgoing.filter((edge) => edge.relation === 'related_to'), '顧客・協業先として関わるプロジェクトの記録はありません。', { roleLabel: relatedRoleLabel })),
719
+ );
720
+ if (company && company.deals.length > 0) page.append(detailBlock('提案中の案件', dealList(company.deals)));
721
+ } else {
722
+ page.append(
723
+ // A person's memberships are ended from the actions under the head.
724
+ detailBlock('所属', edgeList(memberships, '会社への所属の記録はありません。')),
725
+ detailBlock('関わるプロジェクト', edgeList(outgoing.filter((edge) => edge.relation === 'participates_in' || edge.relation === 'accountable_for'), '関わるプロジェクトの記録はありません。')),
726
+ );
727
+ }
728
+ return [page];
488
729
  }
489
730
 
490
731
  // -------------------------------------------------------------------------
@@ -528,21 +769,25 @@ export function createPeopleAndCompaniesView({
528
769
  return list;
529
770
  }
530
771
 
531
- function membershipActions(edge, entity) {
532
- if (!can.endMembership || !edge.active) return [];
772
+ function openEndMembership(edge, entity) {
533
773
  const person = entity.type === 'org' ? edge.counterpart.name : entity.name;
534
774
  const company = entity.type === 'org' ? entity.name : edge.counterpart.name;
535
- return [button(doc, '所属を終える', () => correction.openEdge(edge, {
775
+ correction.openEdge(edge, {
536
776
  mode: 'end',
537
777
  title: '所属を終える',
538
- subject: `${person}の「${company}」への所属に終了日を入れます。記録は消さずに残ります。`,
539
- }))];
778
+ subject: `${person}の「${company}」への所属に終了日を入れます。記録は消さずに残り、終了日から先は一覧に出ません。`,
779
+ });
540
780
  }
541
781
 
542
- function addMembershipButton(entity) {
543
- if (!can.addMembership) return null;
782
+ /** On a company's page, each member's row ends that membership (a person's page has it under the head). */
783
+ function membershipActions(edge, entity) {
784
+ if (!can.endMembership || !edge.active) return [];
785
+ return [button(doc, '所属を終える', () => openEndMembership(edge, entity))];
786
+ }
787
+
788
+ function openMembership(entity) {
544
789
  const label = entity.type === 'org' ? '所属する人を加える' : '所属を付ける';
545
- return sectionActions(label, () => correction.openCreate(
790
+ correction.openCreate(
546
791
  { id: entity.id, type: entity.type, name: entity.name },
547
792
  {
548
793
  relations: ['member_of'],
@@ -551,82 +796,42 @@ export function createPeopleAndCompaniesView({
551
796
  title: label,
552
797
  subject: entity.type === 'org' ? `「${entity.name}」に所属する人を選びます。` : `${entity.name}が所属する会社を選びます。`,
553
798
  },
554
- ));
799
+ );
555
800
  }
556
801
 
802
+ /** The rail: a guide while the ledgers show, the opened record's facts while its page shows. */
557
803
  function railContent() {
558
- const panel = correction.render(doc);
559
- const blocks = railBlocks();
560
- if (!panel) return blocks;
561
- // A new record's form shows first; a membership form right after the record's head.
562
- if (correction.form?.kind === 'create_entity' || !state.detail || state.detail.state !== 'ok') return [panel, ...blocks];
563
- return [blocks[0], panel, ...blocks.slice(1)];
564
- }
565
-
566
- function railBlocks() {
567
804
  const detail = state.detail;
568
805
  if (!detail) {
569
- return [workspaceDetailEmpty(doc, { mark: '人', title: '人か会社を選んでください', text: '所属と関わるプロジェクトを、有効期間と出典つきで確かめられます。' })];
806
+ return [workspaceDetailEmpty(doc, { mark: '人', title: '人か会社を選んでください', text: '一覧の行を押すと、その人・会社のページが開き、所属と関わるプロジェクト、できる操作が出ます。' })];
570
807
  }
571
- const notice = graphStateNotice(doc, detail, { onRetry: () => void controller.openEntity(detail.id) });
572
- if (notice) return [notice];
573
- const { entity, outgoing, incoming } = detail.payload;
574
- const listed = state.list.state === 'ok'
575
- ? state.list.payload.people.find((person) => person.id === entity.id)
576
- : null;
808
+ if (layout.inline || detail.state !== 'ok') return [];
809
+ const { entity } = detail.payload;
810
+ const listed = state.list.state === 'ok' ? state.list.payload.people.find((person) => person.id === entity.id) : null;
577
811
  const ownId = state.list.state === 'ok' ? state.list.payload.ownCompany.id : null;
578
812
  const company = state.list.state === 'ok' && entity.type === 'org'
579
813
  ? state.list.payload.companies.find((item) => item.id === entity.id)
580
814
  : null;
581
- const blocks = [workspaceRailHead(doc, {
582
- kicker: entity.type === 'org' ? '会社' : '人',
583
- title: entity.name,
584
- sub: entity.id,
585
- lead: textOrNull(entity.summary) ?? undefined,
586
- })];
587
- blocks.push(workspaceDefinition(doc, [
588
- ...(entity.type === 'person' && listed ? [['区分', personStanding(listed, ownId)]] : []),
589
- ...(entity.type === 'person' && listed && personOrgNote(listed, ownId)
590
- ? [['記録上のメモ', `${personOrgNote(listed, ownId)}(人物の記録に文字で残っている会社名です。所属の記録ではありません)`]]
591
- : []),
592
- ...(entity.type === 'org' && entity.id === ownId ? [['区分', '自社']] : []),
593
- ...(company && company.kinds.length > 0 ? [['種類', company.kinds.map((item) => {
594
- const sources = item.sources.map((source) => ownKey(COMPANY_KIND_SOURCE_LABELS, source) ?? source).join('・');
595
- return `${ownKey(COMPANY_KIND_LABELS, item.kind) ?? item.kind}(${sources}から)`;
596
- }).join('、')]] : []),
597
- ...(company?.lifecycleStage ? [['記録上の段階', company.lifecycleStage]] : []),
598
- ['有効期間', validityLabel(entity, asOf())],
599
- ]));
600
- if (entity.type === 'org') {
601
- blocks.push(workspaceRailBlock(doc, { title: '所属する人', content: [
602
- edgeList(incoming.filter((edge) => edge.relation === 'member_of'), '所属する人の記録はありません。', { actions: (edge) => membershipActions(edge, entity) }),
603
- addMembershipButton(entity),
604
- ] }));
605
- blocks.push(workspaceRailBlock(doc, { title: 'この会社のプロジェクト', content: edgeList(incoming.filter((edge) => edge.relation === 'owned_by'), 'この会社のプロジェクトの記録はありません。') }));
606
- blocks.push(workspaceRailBlock(doc, {
607
- title: '顧客・協業先として関わるプロジェクト',
608
- content: edgeList(outgoing.filter((edge) => edge.relation === 'related_to'), '顧客・協業先として関わるプロジェクトの記録はありません。', { roleLabel: relatedRoleLabel }),
609
- }));
610
- if (company && company.deals.length > 0) blocks.push(workspaceRailBlock(doc, { title: '提案中の案件', content: dealList(company.deals) }));
611
- } else {
612
- blocks.push(workspaceRailBlock(doc, { title: '所属', content: [
613
- edgeList(outgoing.filter((edge) => edge.relation === 'member_of'), '会社への所属の記録はありません。', { actions: (edge) => membershipActions(edge, entity) }),
614
- addMembershipButton(entity),
615
- ] }));
616
- blocks.push(workspaceRailBlock(doc, {
617
- title: '関わるプロジェクト',
618
- content: edgeList(outgoing.filter((edge) => edge.relation === 'participates_in' || edge.relation === 'accountable_for'), '関わるプロジェクトの記録はありません。'),
619
- }));
815
+ return [workspaceRailBlock(doc, { title: '詳細', content: detailFacts(entity, listed, company, ownId) })];
816
+ }
817
+
818
+ /** After a render: bring the opened record's head, or the row it was opened from, into view. */
819
+ function settleScroll() {
820
+ const target = pendingScroll;
821
+ if (!target) return;
822
+ if (target.head) {
823
+ if (!detailTop) return;
824
+ // Settled only once the record is read: a loading or failed page is shown, but the move waits.
825
+ if (detailHead) pendingScroll = null;
826
+ detailTop.scrollIntoView?.({ block: 'start' });
827
+ detailHead?.focus?.({ preventScroll: true });
828
+ return;
620
829
  }
621
- const fix = makeElement(doc, 'p', { className: 'bb-pc-fix' });
622
- fix.append(
623
- makeElement(doc, 'span', { text: writable
624
- ? '名前・別名・要約などの間違いは「情報と関係」で、この記録を開いて直します。'
625
- : '間違いは「情報と関係」で、この記録を開いて直します。' }),
626
- makeElement(doc, 'a', { text: '情報と関係で開く', attrs: { href: correctionHref(entity.id) } }),
627
- );
628
- blocks.push(fix);
629
- return blocks;
830
+ pendingScroll = null;
831
+ const key = String(target.row).replace(/["\\]/gu, '\\$&');
832
+ const row = root.querySelector?.(`[data-key="${key}"]`);
833
+ row?.scrollIntoView?.({ block: 'center' });
834
+ row?.focus?.({ preventScroll: true });
630
835
  }
631
836
 
632
837
  // -------------------------------------------------------------------------
@@ -641,6 +846,11 @@ export function createPeopleAndCompaniesView({
641
846
  },
642
847
  render() {
643
848
  layout.render(workspace(), railContent());
849
+ settleScroll();
850
+ },
851
+ /** Back from a record's page to the ledgers, at the row it was opened from. */
852
+ showList() {
853
+ backToList();
644
854
  },
645
855
  /** Reads the ledgers. `keepDetail` keeps the open record (and the shown ledgers) while reading. */
646
856
  async load({ keepDetail = false } = {}) {
@@ -652,9 +862,13 @@ export function createPeopleAndCompaniesView({
652
862
  controller.render();
653
863
  if (initialEntityId && !state.detail) await controller.openEntity(initialEntityId);
654
864
  },
655
- /** Opens a record in the rail. Choosing another record closes an open form unless `keepCorrection`. */
865
+ /**
866
+ * Opens a record's page and moves to its head. Choosing another record closes an open form
867
+ * unless `keepCorrection`; reading the same record again (after a save) stays where it is.
868
+ */
656
869
  async openEntity(id, { keepCorrection = false } = {}) {
657
870
  if (!keepCorrection && correction.form && state.detail?.id !== id) correction.close();
871
+ if (state.detail?.id !== id) pendingScroll = { head: true };
658
872
  state.detail = { id, state: 'loading' };
659
873
  controller.render();
660
874
  const result = await client.read(`/entities/${encodeURIComponent(id)}`);
@@ -1,7 +1,7 @@
1
1
  /** Compact notices are projections of the same source-read states as the full rail.
2
2
  * A task's needs_check is a record gap, never an unavailable read or failed job.
3
3
  */
4
- import { readReasonText, shortTime } from './world-work-rail.js';
4
+ import { readReasonShort, readReasonText, shortTime } from './world-work-rail.js';
5
5
 
6
6
  const READ_LABELS = Object.freeze({ tasks: '仕事', persons: '人物', decisions: '方針の決定', relations: '関係' });
7
7
  const STATE_TEXT = Object.freeze({ partial: '一部だけ読めた', failed: '読込失敗', forbidden: '権限がなく読めない', not_connected: '未接続', unavailable: '読めない' });
@@ -17,7 +17,8 @@ export function workCanvasNotes(work) {
17
17
  const read = work.reads?.[key];
18
18
  if (!read || read.state !== 'complete') {
19
19
  const state = STATE_TEXT[read?.state] ?? '状態不明';
20
- notes.push({ label: `区画の${label}`, tone: 'warning', summary: `${label}:${state}`, text: `${label}は${state}。${readReasonText(read?.reason) ?? '理由不明'}。読めていない記録は0件とは限りません。` });
20
+ const short = readReasonShort(read?.reason);
21
+ notes.push({ label: `区画の${label}`, tone: 'warning', summary: `${label}:${state}${short ? `(${short})` : ''}`, text: `${label}は${state}。${readReasonText(read?.reason) ?? '理由不明'}。読めていない記録は0件とは限りません。` });
21
22
  }
22
23
  }
23
24
  const summary = work.summary;
@@ -386,6 +386,25 @@
386
386
  border: 1px dashed #8a9096;
387
387
  }
388
388
 
389
+ /* A sign opens what it means (story-world-sign-meaning-v1); its name keeps entering the district. */
390
+ .bb-world-badge.is-pressable {
391
+ cursor: help;
392
+ }
393
+
394
+ .bb-world-badge.is-pressable:hover {
395
+ text-decoration: underline;
396
+ text-underline-offset: 2px;
397
+ }
398
+
399
+ .bb-world-label.is-enterable:has(.bb-world-badge.is-pressable:hover)::after {
400
+ content: ' 札の意味を見る ›';
401
+ }
402
+
403
+ .bb-world-sign-gaps li {
404
+ display: grid;
405
+ gap: 2px;
406
+ }
407
+
389
408
  .bb-world-label.is-site {
390
409
  font-size: 10px;
391
410
  padding: 1px 5px;