@unson/brainbase-mcp 0.41.9 → 0.41.11
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/canonical-task-service.d.ts +6 -0
- package/dist/canonical-task-service.js +18 -1
- package/dist/judgment-history.js +76 -3
- package/dist/meeting-minutes-http.js +19 -0
- package/dist/meeting-minutes.js +4 -0
- package/package.json +2 -2
- package/ui/judgment-history.css +155 -2
- package/ui/judgment-history.js +649 -241
- package/ui/local-web-shell.js +16 -0
- package/ui/meeting-minutes.css +238 -5
- package/ui/meeting-minutes.js +691 -124
package/ui/meeting-minutes.js
CHANGED
|
@@ -7,12 +7,27 @@
|
|
|
7
7
|
*
|
|
8
8
|
* List order and labels are a projection. Canonical records are not rewritten.
|
|
9
9
|
* Calendar links stay out until a meeting-side event id is verified.
|
|
10
|
+
* Filters and sort only narrow the loaded list; the host owns any filter that
|
|
11
|
+
* changes what it asks the server for (story-meeting-minutes-list-filters-v1).
|
|
10
12
|
*/
|
|
11
13
|
|
|
14
|
+
import { workspaceCombobox } from './workspace-kit.js';
|
|
15
|
+
|
|
12
16
|
export const MEETING_MINUTES_UI_CONTRACT_VERSION = 'brainbase.meeting-minutes-ui.v1';
|
|
13
17
|
|
|
14
18
|
const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
|
|
15
19
|
const TOKYO = 'Asia/Tokyo';
|
|
20
|
+
const SORTS = Object.freeze([
|
|
21
|
+
{ value: 'scheduled-desc', label: '会議の日時が新しい順' },
|
|
22
|
+
{ value: 'scheduled-asc', label: '会議の日時が古い順' },
|
|
23
|
+
{ value: 'updated-desc', label: '議事録の更新が新しい順' },
|
|
24
|
+
]);
|
|
25
|
+
const STATE_FILTERS = Object.freeze([
|
|
26
|
+
{ value: '', label: 'すべての状態' },
|
|
27
|
+
{ value: 'pending', label: '確認待ち' },
|
|
28
|
+
{ value: 'confirmed', label: '確認済み' },
|
|
29
|
+
]);
|
|
30
|
+
const EMPTY_FILTERS = Object.freeze({ query: '', from: '', to: '', participant: '', status: '' });
|
|
16
31
|
|
|
17
32
|
function tokyoParts(date) {
|
|
18
33
|
const parts = new Intl.DateTimeFormat('en-US', {
|
|
@@ -153,6 +168,46 @@ function httpUrl(value) {
|
|
|
153
168
|
}
|
|
154
169
|
}
|
|
155
170
|
|
|
171
|
+
// Only http(s) URLs and same-site absolute paths become links.
|
|
172
|
+
function connectionHref(value) {
|
|
173
|
+
const raw = text(value);
|
|
174
|
+
if (raw.startsWith('/') && !raw.startsWith('//') && !raw.startsWith('/\\')) return raw;
|
|
175
|
+
return httpUrl(raw);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const CONNECTION_ROWS = Object.freeze([
|
|
179
|
+
['project', 'プロジェクト'],
|
|
180
|
+
['tasks', 'タスク'],
|
|
181
|
+
['decisions', '判断'],
|
|
182
|
+
['stored', '保存先'],
|
|
183
|
+
['shared', '共有先'],
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
// A host answer is trusted only for what it states. Anything missing or
|
|
187
|
+
// malformed reads as unrecorded, never as a confirmed empty list (P9).
|
|
188
|
+
function normalizeConnectionRow(row) {
|
|
189
|
+
if (!isRecord(row)) return { status: 'unrecorded', reason: '' };
|
|
190
|
+
const reason = text(row.reason);
|
|
191
|
+
if (row.status === 'none') return { status: 'none', reason: '' };
|
|
192
|
+
if (row.status === 'unavailable') return { status: 'unavailable', reason };
|
|
193
|
+
if (row.status === 'recorded') {
|
|
194
|
+
const items = (Array.isArray(row.items) ? row.items : [])
|
|
195
|
+
.filter(isRecord)
|
|
196
|
+
.map((item) => ({ label: text(item.label), href: connectionHref(item.href), detail: text(item.detail) }))
|
|
197
|
+
.filter((item) => item.label);
|
|
198
|
+
if (items.length) return { status: 'recorded', items };
|
|
199
|
+
}
|
|
200
|
+
return { status: 'unrecorded', reason };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeConnections(value) {
|
|
204
|
+
const unreadable = !isRecord(value);
|
|
205
|
+
return Object.fromEntries(CONNECTION_ROWS.map(([key]) => [
|
|
206
|
+
key,
|
|
207
|
+
unreadable ? { status: 'unavailable', reason: '' } : normalizeConnectionRow(value[key]),
|
|
208
|
+
]));
|
|
209
|
+
}
|
|
210
|
+
|
|
156
211
|
function firstDate(values) {
|
|
157
212
|
for (const value of values) {
|
|
158
213
|
const date = parseDate(value);
|
|
@@ -239,19 +294,71 @@ function byCreatedAt(versions) {
|
|
|
239
294
|
.map((item) => item.version);
|
|
240
295
|
}
|
|
241
296
|
|
|
242
|
-
function
|
|
297
|
+
function updatedTime(entry) {
|
|
298
|
+
let latest = latestVersionTime(entry);
|
|
299
|
+
for (const documentRecord of Array.isArray(entry?.minutes) ? entry.minutes : []) {
|
|
300
|
+
const date = parseDate(documentRecord?.updated_at);
|
|
301
|
+
if (date && (!latest || date > latest)) latest = date;
|
|
302
|
+
}
|
|
303
|
+
return latest ?? importTime(entry);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function sortMeetings(meetings, sort = 'scheduled-desc') {
|
|
307
|
+
const byUpdate = sort === 'updated-desc';
|
|
308
|
+
const direction = sort === 'scheduled-asc' ? 1 : -1;
|
|
243
309
|
return meetings
|
|
244
|
-
.map((entry, index) =>
|
|
310
|
+
.map((entry, index) => {
|
|
311
|
+
const timing = meetingTiming(entry);
|
|
312
|
+
const updated = byUpdate ? updatedTime(entry) : null;
|
|
313
|
+
return {
|
|
314
|
+
entry,
|
|
315
|
+
index,
|
|
316
|
+
bottom: byUpdate ? !updated : timing.kind === 'unregistered',
|
|
317
|
+
at: byUpdate ? updated?.getTime() ?? Number.NEGATIVE_INFINITY : timing.at,
|
|
318
|
+
};
|
|
319
|
+
})
|
|
245
320
|
.sort((left, right) => {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (
|
|
249
|
-
if (left.timing.at !== right.timing.at) return right.timing.at - left.timing.at;
|
|
321
|
+
if (left.bottom !== right.bottom) return left.bottom ? 1 : -1;
|
|
322
|
+
// Meetings without a date keep the newest import first in every order.
|
|
323
|
+
if (left.at !== right.at) return left.bottom ? right.at - left.at : (left.at - right.at) * direction;
|
|
250
324
|
return left.index - right.index;
|
|
251
325
|
})
|
|
252
326
|
.map((item) => item.entry);
|
|
253
327
|
}
|
|
254
328
|
|
|
329
|
+
function searchText(value) {
|
|
330
|
+
return String(value ?? '').normalize('NFKC').toLocaleLowerCase('ja');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function tokyoDateValue(date) {
|
|
334
|
+
const { year, month, day } = tokyoParts(date);
|
|
335
|
+
return `${year}-${pad(month)}-${pad(day)}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function participantsOf(entry) {
|
|
339
|
+
const ids = entry?.meeting?.participant_ids;
|
|
340
|
+
return Array.isArray(ids) ? ids.map((id) => text(id)).filter(Boolean) : [];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function matchesFilters(entry, filters) {
|
|
344
|
+
const terms = searchText(filters.query).split(/\s+/u).filter(Boolean);
|
|
345
|
+
if (terms.length) {
|
|
346
|
+
const documents = Array.isArray(entry?.minutes) ? entry.minutes : [];
|
|
347
|
+
const haystack = searchText([entry?.meeting?.title, ...documents.map((documentRecord) => documentRecord?.title)].join('\n'));
|
|
348
|
+
if (!terms.every((term) => haystack.includes(term))) return false;
|
|
349
|
+
}
|
|
350
|
+
if (filters.from || filters.to) {
|
|
351
|
+
const timing = meetingTiming(entry);
|
|
352
|
+
if (!timing.date) return false;
|
|
353
|
+
const day = tokyoDateValue(timing.date);
|
|
354
|
+
if (filters.from && day < filters.from) return false;
|
|
355
|
+
if (filters.to && day > filters.to) return false;
|
|
356
|
+
}
|
|
357
|
+
if (filters.participant && !participantsOf(entry).includes(filters.participant)) return false;
|
|
358
|
+
if (filters.status && listState(entry)?.code !== filters.status) return false;
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
|
|
255
362
|
function idempotencyKey() {
|
|
256
363
|
const random = typeof globalThis.crypto?.randomUUID === 'function'
|
|
257
364
|
? globalThis.crypto.randomUUID()
|
|
@@ -294,9 +401,24 @@ function sourceLabel(source) {
|
|
|
294
401
|
return `${provider} / ${locator} / ${revision}`;
|
|
295
402
|
}
|
|
296
403
|
|
|
297
|
-
function
|
|
404
|
+
function sameTitle(left, right) {
|
|
405
|
+
const normalize = (value) => String(value ?? '').normalize('NFKC').replace(/\s+/gu, '');
|
|
406
|
+
const a = normalize(left);
|
|
407
|
+
return a !== '' && a === normalize(right);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// The meeting title is already the detail heading; a body that opens with the same
|
|
411
|
+
// heading would show it twice (story-meeting-minutes-edit-and-withdraw-ui-v1 AC-07).
|
|
412
|
+
function withoutRepeatedTitle(lines, title) {
|
|
413
|
+
const first = lines.findIndex((line) => line.trim() !== '');
|
|
414
|
+
if (first < 0) return lines;
|
|
415
|
+
const heading = /^#{1,6}\s+(.+)$/u.exec(lines[first]);
|
|
416
|
+
return heading && sameTitle(heading[1], title) ? lines.slice(first + 1) : lines;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function renderMarkdown(doc, body, { title } = {}) {
|
|
298
420
|
const root = makeElement(doc, 'div', { className: 'bb-minutes-markdown' });
|
|
299
|
-
const lines = String(body).replace(/\r\n/g, '\n').split('\n');
|
|
421
|
+
const lines = withoutRepeatedTitle(String(body).replace(/\r\n/g, '\n').split('\n'), title);
|
|
300
422
|
let list = null;
|
|
301
423
|
let listKind = null;
|
|
302
424
|
const closeList = () => {
|
|
@@ -342,12 +464,12 @@ function renderMarkdown(doc, body) {
|
|
|
342
464
|
return root;
|
|
343
465
|
}
|
|
344
466
|
|
|
345
|
-
function versionContent(doc, version) {
|
|
467
|
+
function versionContent(doc, version, { title } = {}) {
|
|
346
468
|
if (!isRecord(version)) return makeElement(doc, 'p', { textContent: '版の形式を確認できません。', className: 'bb-minutes-warning' });
|
|
347
469
|
if (isRecord(version.source_ref)) {
|
|
348
470
|
return makeElement(doc, 'p', { className: 'bb-minutes-source', textContent: `本文は元の記録にあります。${sourceLabel(version.source_ref)}` });
|
|
349
471
|
}
|
|
350
|
-
if (typeof version.body === 'string') return renderMarkdown(doc, version.body);
|
|
472
|
+
if (typeof version.body === 'string') return renderMarkdown(doc, version.body, { title });
|
|
351
473
|
return makeElement(doc, 'p', { className: 'bb-minutes-warning', textContent: '本文または外部参照を確認できません。' });
|
|
352
474
|
}
|
|
353
475
|
|
|
@@ -513,8 +635,12 @@ export function createMeetingMinutesUI({
|
|
|
513
635
|
basePath = '/api/meeting-minutes',
|
|
514
636
|
versionActionExtensions = [],
|
|
515
637
|
meetingLabel,
|
|
638
|
+
meetingConnections,
|
|
516
639
|
canCreateMeeting,
|
|
640
|
+
createUnavailableReason,
|
|
517
641
|
canEditMinutes,
|
|
642
|
+
hostFilters = [],
|
|
643
|
+
currentPrincipalId,
|
|
518
644
|
now = () => new Date(),
|
|
519
645
|
} = {}) {
|
|
520
646
|
if (!root) throw new TypeError('root is required');
|
|
@@ -533,9 +659,20 @@ export function createMeetingMinutesUI({
|
|
|
533
659
|
loading: false,
|
|
534
660
|
listError: null,
|
|
535
661
|
detailError: null,
|
|
662
|
+
filters: { ...EMPTY_FILTERS },
|
|
663
|
+
sort: SORTS[0].value,
|
|
664
|
+
// Which document is being edited or about to be withdrawn; both reset on every detail load.
|
|
665
|
+
editingMinutesId: null,
|
|
666
|
+
withdrawingMinutesId: null,
|
|
667
|
+
// { meetingId, rows } where rows is null while the host is answering.
|
|
668
|
+
connections: null,
|
|
536
669
|
};
|
|
537
670
|
let listRequestGeneration = 0;
|
|
538
671
|
let detailRequestGeneration = 0;
|
|
672
|
+
let createReasonSequence = 0;
|
|
673
|
+
let connectionsSlot = null;
|
|
674
|
+
let railProjectSlot = null;
|
|
675
|
+
let railProjectMeeting = null;
|
|
539
676
|
|
|
540
677
|
const screen = makeElement(doc, 'div', { className: 'bb-minutes-screen', attrs: { 'data-contract-version': MEETING_MINUTES_UI_CONTRACT_VERSION } });
|
|
541
678
|
const heading = makeElement(doc, 'header', { className: 'bb-minutes-heading' });
|
|
@@ -545,6 +682,10 @@ export function createMeetingMinutesUI({
|
|
|
545
682
|
makeElement(doc, 'p', { className: 'bb-minutes-lead', textContent: '新しい会議から読み、確認待ちを見つけて確認します。' }),
|
|
546
683
|
);
|
|
547
684
|
const status = makeElement(doc, 'p', { className: 'bb-minutes-status', attrs: { role: 'status', 'aria-live': 'polite' } });
|
|
685
|
+
const toolbar = makeElement(doc, 'div', {
|
|
686
|
+
className: 'bb-minutes-toolbar',
|
|
687
|
+
attrs: { role: 'search', 'aria-label': '議事録の絞り込みと並べ替え' },
|
|
688
|
+
});
|
|
548
689
|
const columns = makeElement(doc, 'div', { className: 'bb-minutes-columns' });
|
|
549
690
|
const listPane = makeElement(doc, 'section', {
|
|
550
691
|
className: 'bb-minutes-list-pane',
|
|
@@ -552,9 +693,127 @@ export function createMeetingMinutesUI({
|
|
|
552
693
|
});
|
|
553
694
|
const detailPane = makeElement(doc, 'section', { className: 'bb-minutes-detail-pane', attrs: { 'aria-label': '議事録詳細' } });
|
|
554
695
|
columns.append(listPane, detailPane);
|
|
555
|
-
screen.append(heading, status, columns);
|
|
696
|
+
screen.append(heading, status, toolbar, columns);
|
|
556
697
|
root.replaceChildren(screen);
|
|
557
698
|
|
|
699
|
+
function filterField(label, control, className = '') {
|
|
700
|
+
const field = makeElement(doc, 'div', { className: `bb-minutes-filter-field${className ? ` ${className}` : ''}` });
|
|
701
|
+
field.append(makeElement(doc, 'span', { className: 'bb-minutes-label', textContent: label }), control);
|
|
702
|
+
return field;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function filterCombobox(label, options, value, onChange) {
|
|
706
|
+
return workspaceCombobox(doc, { label, options, value, className: 'bb-minutes-filter-combobox', onChange });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const keyword = makeElement(doc, 'input', {
|
|
710
|
+
className: 'bb-minutes-input bb-minutes-filter-keyword',
|
|
711
|
+
type: 'search',
|
|
712
|
+
attrs: { 'aria-label': '会議名・議事録名で絞り込む', placeholder: '会議名・議事録名', autocomplete: 'off' },
|
|
713
|
+
});
|
|
714
|
+
keyword.addEventListener('input', () => { state.filters.query = keyword.value; renderList(); });
|
|
715
|
+
const fromInput = makeElement(doc, 'input', { className: 'bb-minutes-input', type: 'date', attrs: { 'aria-label': '期間の始め' } });
|
|
716
|
+
const toInput = makeElement(doc, 'input', { className: 'bb-minutes-input', type: 'date', attrs: { 'aria-label': '期間の終わり' } });
|
|
717
|
+
fromInput.addEventListener('change', () => { state.filters.from = fromInput.value; renderList(); });
|
|
718
|
+
toInput.addEventListener('change', () => { state.filters.to = toInput.value; renderList(); });
|
|
719
|
+
const period = makeElement(doc, 'span', { className: 'bb-minutes-filter-period' });
|
|
720
|
+
period.append(fromInput, makeElement(doc, 'span', { className: 'bb-minutes-filter-period-sep', textContent: '〜', attrs: { 'aria-hidden': 'true' } }), toInput);
|
|
721
|
+
const participantBox = filterCombobox('参加者', [{ value: '', label: 'すべての参加者' }], '', (value) => { state.filters.participant = value; renderList(); });
|
|
722
|
+
const statusBox = filterCombobox('確認の状態', STATE_FILTERS, '', (value) => { state.filters.status = value; renderList(); });
|
|
723
|
+
const sortBox = filterCombobox('並べ替え', SORTS, state.sort, (value) => { state.sort = value; renderList(); });
|
|
724
|
+
const hostSlot = makeElement(doc, 'div', { className: 'bb-minutes-filter-host' });
|
|
725
|
+
const participantField = filterField('参加者', participantBox.root);
|
|
726
|
+
const statusField = filterField('確認の状態', statusBox.root);
|
|
727
|
+
const reset = button(doc, '条件を外す', () => clearFilters(), { className: 'bb-minutes-button bb-minutes-filter-reset' });
|
|
728
|
+
toolbar.append(
|
|
729
|
+
filterField('キーワード', keyword, 'is-keyword'),
|
|
730
|
+
hostSlot,
|
|
731
|
+
filterField('期間', period, 'is-period'),
|
|
732
|
+
participantField,
|
|
733
|
+
statusField,
|
|
734
|
+
filterField('並べ替え', sortBox.root),
|
|
735
|
+
reset,
|
|
736
|
+
);
|
|
737
|
+
const hostBoxes = new Map();
|
|
738
|
+
|
|
739
|
+
function currentHostFilters() {
|
|
740
|
+
let filters = hostFilters;
|
|
741
|
+
if (typeof filters === 'function') {
|
|
742
|
+
try { filters = filters(); } catch { filters = []; }
|
|
743
|
+
}
|
|
744
|
+
return (Array.isArray(filters) ? filters : []).filter((filter) => isRecord(filter) && text(filter.id));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function clientFiltersActive() {
|
|
748
|
+
return Object.keys(EMPTY_FILTERS).some((key) => Boolean(state.filters[key]));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function filtersActive() {
|
|
752
|
+
return clientFiltersActive() || currentHostFilters().some((filter) => text(filter.value));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function clearFilters() {
|
|
756
|
+
state.filters = { ...EMPTY_FILTERS };
|
|
757
|
+
keyword.value = '';
|
|
758
|
+
fromInput.value = '';
|
|
759
|
+
toInput.value = '';
|
|
760
|
+
for (const filter of currentHostFilters()) {
|
|
761
|
+
if (text(filter.value) && typeof filter.onChange === 'function') filter.onChange('');
|
|
762
|
+
}
|
|
763
|
+
renderList();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function renderToolbar(meetings) {
|
|
767
|
+
const hosts = currentHostFilters();
|
|
768
|
+
const seen = new Set();
|
|
769
|
+
for (const filter of hosts) {
|
|
770
|
+
const id = text(filter.id);
|
|
771
|
+
seen.add(id);
|
|
772
|
+
const options = Array.isArray(filter.options) ? filter.options : [];
|
|
773
|
+
const value = text(filter.value);
|
|
774
|
+
let entry = hostBoxes.get(id);
|
|
775
|
+
if (!entry) {
|
|
776
|
+
const box = workspaceCombobox(doc, {
|
|
777
|
+
label: text(filter.label, id),
|
|
778
|
+
options,
|
|
779
|
+
value,
|
|
780
|
+
placeholder: text(filter.placeholder, '選択してください'),
|
|
781
|
+
className: 'bb-minutes-filter-combobox',
|
|
782
|
+
onChange: (next) => {
|
|
783
|
+
const current = currentHostFilters().find((candidate) => text(candidate.id) === id);
|
|
784
|
+
if (typeof current?.onChange === 'function') current.onChange(next);
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
entry = { box, field: filterField(text(filter.label, id), box.root) };
|
|
788
|
+
hostBoxes.set(id, entry);
|
|
789
|
+
}
|
|
790
|
+
entry.box.update({ options, value, disabled: filter.disabled === true });
|
|
791
|
+
}
|
|
792
|
+
for (const [id, entry] of hostBoxes) {
|
|
793
|
+
if (seen.has(id)) continue;
|
|
794
|
+
entry.box.destroy();
|
|
795
|
+
hostBoxes.delete(id);
|
|
796
|
+
}
|
|
797
|
+
hostSlot.replaceChildren(...hosts.map((filter) => hostBoxes.get(text(filter.id)).field));
|
|
798
|
+
hostSlot.hidden = hosts.length === 0;
|
|
799
|
+
|
|
800
|
+
// Offer only the conditions the loaded records can answer (P9).
|
|
801
|
+
const participants = [...new Set(meetings.flatMap(participantsOf))]
|
|
802
|
+
.sort((left, right) => left.localeCompare(right, 'ja', { numeric: true }));
|
|
803
|
+
if (state.filters.participant && !participants.includes(state.filters.participant)) state.filters.participant = '';
|
|
804
|
+
participantBox.update({
|
|
805
|
+
options: [{ value: '', label: 'すべての参加者' }, ...participants.map((participant) => ({ value: participant, label: participant }))],
|
|
806
|
+
value: state.filters.participant,
|
|
807
|
+
});
|
|
808
|
+
participantField.hidden = participants.length === 0;
|
|
809
|
+
const hasStates = meetings.some((entry) => listState(entry));
|
|
810
|
+
if (!hasStates) state.filters.status = '';
|
|
811
|
+
statusBox.update({ value: state.filters.status });
|
|
812
|
+
statusField.hidden = !hasStates;
|
|
813
|
+
sortBox.update({ value: state.sort });
|
|
814
|
+
reset.hidden = !filtersActive();
|
|
815
|
+
}
|
|
816
|
+
|
|
558
817
|
function clock() {
|
|
559
818
|
try {
|
|
560
819
|
const value = typeof now === 'function' ? now() : new Date();
|
|
@@ -608,6 +867,34 @@ export function createMeetingMinutesUI({
|
|
|
608
867
|
return canCreateMeeting !== false;
|
|
609
868
|
}
|
|
610
869
|
|
|
870
|
+
/** One short reason from the host, shown beside a create button that cannot be pressed. */
|
|
871
|
+
function createReason() {
|
|
872
|
+
if (typeof createUnavailableReason === 'function') {
|
|
873
|
+
try {
|
|
874
|
+
return text(createUnavailableReason());
|
|
875
|
+
} catch {
|
|
876
|
+
return '';
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return text(createUnavailableReason);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function appendCreateUnavailable(parent) {
|
|
883
|
+
const reason = createReason();
|
|
884
|
+
if (!reason) return;
|
|
885
|
+
const id = `bb-minutes-create-reason-${++createReasonSequence}`;
|
|
886
|
+
parent.append(
|
|
887
|
+
makeElement(doc, 'button', {
|
|
888
|
+
className: 'bb-minutes-button is-primary',
|
|
889
|
+
textContent: '会議を作成',
|
|
890
|
+
type: 'button',
|
|
891
|
+
disabled: true,
|
|
892
|
+
attrs: { type: 'button', 'aria-describedby': id },
|
|
893
|
+
}),
|
|
894
|
+
makeElement(doc, 'span', { className: 'bb-minutes-create-reason', textContent: reason, attrs: { id } }),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
611
898
|
function canEdit() {
|
|
612
899
|
if (typeof canEditMinutes === 'function') {
|
|
613
900
|
try {
|
|
@@ -619,6 +906,21 @@ export function createMeetingMinutesUI({
|
|
|
619
906
|
return canEditMinutes !== false;
|
|
620
907
|
}
|
|
621
908
|
|
|
909
|
+
function viewerId() {
|
|
910
|
+
try {
|
|
911
|
+
const value = typeof currentPrincipalId === 'function' ? currentPrincipalId() : currentPrincipalId;
|
|
912
|
+
return text(value);
|
|
913
|
+
} catch {
|
|
914
|
+
return '';
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// The server decides; the screen only hides the button from people it already knows it will refuse.
|
|
919
|
+
function mayWithdraw(documentRecord) {
|
|
920
|
+
const viewer = viewerId();
|
|
921
|
+
return !viewer || viewer === text(documentRecord?.created_actor_id);
|
|
922
|
+
}
|
|
923
|
+
|
|
622
924
|
function stateChip(versionStatus, { detailed = false } = {}) {
|
|
623
925
|
const label = detailed && versionStatus.detail ? versionStatus.detail : versionStatus.label;
|
|
624
926
|
return makeElement(doc, 'span', {
|
|
@@ -632,28 +934,122 @@ export function createMeetingMinutesUI({
|
|
|
632
934
|
line.append(node);
|
|
633
935
|
}
|
|
634
936
|
|
|
937
|
+
// One gray notice per detail, only when something cannot be done here.
|
|
635
938
|
function appendReadOnlyNotice(parent, version) {
|
|
636
939
|
const source = isRecord(version?.source_ref) ? version.source_ref : null;
|
|
940
|
+
const editable = canEdit();
|
|
941
|
+
if (editable && !source) return false;
|
|
637
942
|
const url = httpUrl(source?.locator);
|
|
638
943
|
const provider = text(source?.provider, '元の記録');
|
|
639
944
|
const notice = makeElement(doc, 'div', { className: 'bb-minutes-edit-guard is-info' });
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
945
|
+
if (!editable) {
|
|
946
|
+
notice.append(makeElement(doc, 'p', {
|
|
947
|
+
textContent: 'この画面では読むことだけができます。確認はプロジェクトの編集権限がある人が行います。',
|
|
948
|
+
}));
|
|
949
|
+
}
|
|
645
950
|
if (url) {
|
|
646
|
-
const line = makeElement(doc, 'p', { textContent: '
|
|
951
|
+
const line = makeElement(doc, 'p', { textContent: '本文は元の記録にあります。本文を直すときは、元の記録で編集してください。' });
|
|
647
952
|
line.append(makeElement(doc, 'a', {
|
|
648
953
|
className: 'bb-minutes-source-link',
|
|
649
954
|
textContent: `元の記録を開く(${provider})`,
|
|
650
955
|
attrs: { href: url, target: '_blank', rel: 'noopener noreferrer' },
|
|
651
956
|
}));
|
|
652
957
|
notice.append(line);
|
|
653
|
-
} else {
|
|
958
|
+
} else if (source) {
|
|
654
959
|
notice.append(makeElement(doc, 'p', { textContent: '本文を直すときは、議事録を取り込んだ元で編集してください。' }));
|
|
960
|
+
} else {
|
|
961
|
+
notice.append(makeElement(doc, 'p', { textContent: '本文を直すのも、プロジェクトの編集権限がある人です。' }));
|
|
655
962
|
}
|
|
656
963
|
parent.append(notice);
|
|
964
|
+
return true;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function hasConnections() {
|
|
968
|
+
return typeof meetingConnections === 'function';
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function currentConnections(meeting) {
|
|
972
|
+
const value = state.connections;
|
|
973
|
+
return value && value.meetingId === meeting?.meeting_id ? value.rows : null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function connectionLink(item, className) {
|
|
977
|
+
if (!item.href) return makeElement(doc, 'span', { className, textContent: item.label });
|
|
978
|
+
const external = /^https?:/iu.test(item.href);
|
|
979
|
+
return makeElement(doc, 'a', {
|
|
980
|
+
className,
|
|
981
|
+
textContent: item.label,
|
|
982
|
+
attrs: {
|
|
983
|
+
href: item.href,
|
|
984
|
+
target: external ? '_blank' : undefined,
|
|
985
|
+
rel: external ? 'noopener noreferrer' : undefined,
|
|
986
|
+
},
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function connectionValue(row) {
|
|
991
|
+
if (!row) return makeElement(doc, 'p', { className: 'bb-minutes-connection-note', textContent: '読み込んでいます' });
|
|
992
|
+
if (row.status === 'recorded') {
|
|
993
|
+
const list = makeElement(doc, 'ul', { className: 'bb-minutes-connection-items' });
|
|
994
|
+
for (const item of row.items) {
|
|
995
|
+
const entry = makeElement(doc, 'li');
|
|
996
|
+
entry.append(connectionLink(item, 'bb-minutes-connection-link'));
|
|
997
|
+
if (item.detail) entry.append(makeElement(doc, 'span', { className: 'bb-minutes-connection-detail', textContent: item.detail }));
|
|
998
|
+
list.append(entry);
|
|
999
|
+
}
|
|
1000
|
+
return list;
|
|
1001
|
+
}
|
|
1002
|
+
const label = row.status === 'none' ? 'なし' : row.status === 'unavailable' ? '読み込めません' : '記録なし';
|
|
1003
|
+
return makeElement(doc, 'p', {
|
|
1004
|
+
className: `bb-minutes-connection-note is-${row.status}`,
|
|
1005
|
+
textContent: row.reason ? `${label}(${row.reason})` : label,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function fillConnections(slot, rows) {
|
|
1010
|
+
slot.replaceChildren(makeElement(doc, 'h3', { className: 'bb-minutes-connections-title', textContent: 'この会議から' }));
|
|
1011
|
+
const list = makeElement(doc, 'div', { className: 'bb-minutes-connection-list' });
|
|
1012
|
+
for (const [key, label] of CONNECTION_ROWS) {
|
|
1013
|
+
const row = makeElement(doc, 'div', { className: 'bb-minutes-connection-row', attrs: { 'data-connection': key } });
|
|
1014
|
+
row.append(
|
|
1015
|
+
makeElement(doc, 'span', { className: 'bb-minutes-connection-label', textContent: label }),
|
|
1016
|
+
connectionValue(rows ? rows[key] : null),
|
|
1017
|
+
);
|
|
1018
|
+
list.append(row);
|
|
1019
|
+
}
|
|
1020
|
+
slot.append(list);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function fillRailProject(slot, meeting) {
|
|
1024
|
+
const project = currentConnections(meeting)?.project;
|
|
1025
|
+
const item = project?.status === 'recorded' ? project.items.find((candidate) => candidate.href) : null;
|
|
1026
|
+
if (item) {
|
|
1027
|
+
slot.textContent = '';
|
|
1028
|
+
slot.replaceChildren(connectionLink(item, 'bb-minutes-source-link'));
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const label = meetingLabelText(meetingLabel, meeting);
|
|
1032
|
+
slot.replaceChildren();
|
|
1033
|
+
slot.textContent = label || 'プロジェクトは記録されていません';
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async function loadConnections(detail, isCurrentRequest) {
|
|
1037
|
+
const meetingId = detail.meeting.meeting_id;
|
|
1038
|
+
state.connections = { meetingId, rows: null };
|
|
1039
|
+
let rows;
|
|
1040
|
+
try {
|
|
1041
|
+
rows = normalizeConnections(await meetingConnections({
|
|
1042
|
+
meeting: detail.meeting,
|
|
1043
|
+
minutes: detail.minutes,
|
|
1044
|
+
versions: detail.versions,
|
|
1045
|
+
}));
|
|
1046
|
+
} catch {
|
|
1047
|
+
rows = normalizeConnections(null);
|
|
1048
|
+
}
|
|
1049
|
+
if (!isCurrentRequest() || state.connections?.meetingId !== meetingId) return;
|
|
1050
|
+
state.connections = { meetingId, rows };
|
|
1051
|
+
if (connectionsSlot) fillConnections(connectionsSlot, rows);
|
|
1052
|
+
if (railProjectSlot && railProjectMeeting?.meeting_id === meetingId) fillRailProject(railProjectSlot, railProjectMeeting);
|
|
657
1053
|
}
|
|
658
1054
|
|
|
659
1055
|
function appendLoadError(parent, message, retry) {
|
|
@@ -666,7 +1062,7 @@ export function createMeetingMinutesUI({
|
|
|
666
1062
|
parent.append(notice);
|
|
667
1063
|
}
|
|
668
1064
|
|
|
669
|
-
function renderMeetingRow(entry) {
|
|
1065
|
+
function renderMeetingRow(entry, { flat = false } = {}) {
|
|
670
1066
|
const meeting = entry.meeting;
|
|
671
1067
|
const timing = meetingTiming(entry);
|
|
672
1068
|
const item = makeElement(doc, 'button', {
|
|
@@ -680,10 +1076,19 @@ export function createMeetingMinutesUI({
|
|
|
680
1076
|
}));
|
|
681
1077
|
const line = makeElement(doc, 'span', { className: 'bb-minutes-item-line' });
|
|
682
1078
|
if (timing.kind === 'scheduled') {
|
|
683
|
-
|
|
1079
|
+
// Without date headings the row carries its own date.
|
|
1080
|
+
const when = flat ? `${formatMonthDay(timing.date)} ${formatClock(timing.date)}` : formatClock(timing.date);
|
|
1081
|
+
appendLinePart(line, makeElement(doc, 'span', { textContent: when }));
|
|
684
1082
|
} else if (timing.kind === 'fallback' || (timing.kind === 'unregistered' && timing.date)) {
|
|
685
1083
|
appendLinePart(line, makeElement(doc, 'span', { textContent: unscheduledLabel(timing) }));
|
|
686
1084
|
}
|
|
1085
|
+
if (flat) {
|
|
1086
|
+
const updated = updatedTime(entry);
|
|
1087
|
+
appendLinePart(line, makeElement(doc, 'span', {
|
|
1088
|
+
className: 'bb-minutes-item-updated',
|
|
1089
|
+
textContent: updated ? `更新 ${formatMonthDay(updated)} ${formatClock(updated)}` : '更新日時は未記録',
|
|
1090
|
+
}));
|
|
1091
|
+
}
|
|
687
1092
|
const label = meetingLabelText(meetingLabel, meeting);
|
|
688
1093
|
if (label) appendLinePart(line, makeElement(doc, 'span', { className: 'bb-minutes-meeting-label', textContent: label }));
|
|
689
1094
|
const rowState = listState(entry);
|
|
@@ -701,17 +1106,19 @@ export function createMeetingMinutesUI({
|
|
|
701
1106
|
clear(listPane);
|
|
702
1107
|
const head = makeElement(doc, 'div', { className: 'bb-minutes-pane-head' });
|
|
703
1108
|
const meetings = state.meetings.filter((entry) => isRecord(entry?.meeting) && typeof entry.meeting.meeting_id === 'string');
|
|
1109
|
+
renderToolbar(meetings);
|
|
1110
|
+
const visible = sortMeetings(meetings.filter((entry) => matchesFilters(entry, state.filters)), state.sort);
|
|
704
1111
|
if (canCreate()) {
|
|
705
1112
|
head.append(button(doc, '会議を作成', () => renderCreateForm(), { className: 'bb-minutes-button is-primary' }));
|
|
1113
|
+
} else {
|
|
1114
|
+
appendCreateUnavailable(head);
|
|
706
1115
|
}
|
|
707
|
-
|
|
1116
|
+
const narrowed = clientFiltersActive();
|
|
1117
|
+
head.append(makeElement(doc, 'p', {
|
|
1118
|
+
className: 'bb-minutes-sort-note',
|
|
1119
|
+
textContent: narrowed ? `${meetings.length}件中${visible.length}件` : `${meetings.length}件`,
|
|
1120
|
+
}));
|
|
708
1121
|
listPane.append(head);
|
|
709
|
-
if (!canCreate()) {
|
|
710
|
-
listPane.append(makeElement(doc, 'p', {
|
|
711
|
-
className: 'bb-minutes-create-guard',
|
|
712
|
-
textContent: '会議を作成する権限がありません。作成はプロジェクトの編集権限がある人が行います。',
|
|
713
|
-
}));
|
|
714
|
-
}
|
|
715
1122
|
if (state.listError) {
|
|
716
1123
|
appendLoadError(listPane, `会議一覧を読み込めませんでした。理由: ${state.listError}`, () => loadMeetings({ restoreFocus: true }));
|
|
717
1124
|
}
|
|
@@ -719,14 +1126,27 @@ export function createMeetingMinutesUI({
|
|
|
719
1126
|
if (state.listError) return;
|
|
720
1127
|
listPane.append(makeElement(doc, 'p', {
|
|
721
1128
|
className: 'bb-minutes-empty',
|
|
722
|
-
textContent:
|
|
1129
|
+
textContent: filtersActive()
|
|
1130
|
+
? '条件に合う会議はありません。'
|
|
1131
|
+
: 'まだ議事録はありません。会議を取り込むと、ここに新しい順で並びます。',
|
|
723
1132
|
}));
|
|
1133
|
+
if (filtersActive()) listPane.append(button(doc, '条件を外す', () => clearFilters()));
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (visible.length === 0) {
|
|
1137
|
+
listPane.append(makeElement(doc, 'p', { className: 'bb-minutes-empty', textContent: '条件に合う会議はありません。' }));
|
|
1138
|
+
listPane.append(button(doc, '条件を外す', () => clearFilters()));
|
|
724
1139
|
return;
|
|
725
1140
|
}
|
|
726
1141
|
const list = makeElement(doc, 'div', { className: 'bb-minutes-meeting-list' });
|
|
1142
|
+
if (state.sort === 'updated-desc') {
|
|
1143
|
+
for (const entry of visible) list.append(renderMeetingRow(entry, { flat: true }));
|
|
1144
|
+
listPane.append(list);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
727
1147
|
const today = clock();
|
|
728
1148
|
let currentKey = null;
|
|
729
|
-
for (const entry of
|
|
1149
|
+
for (const entry of visible) {
|
|
730
1150
|
const timing = meetingTiming(entry);
|
|
731
1151
|
if (timing.kind === 'unregistered') continue;
|
|
732
1152
|
const key = dateKey(timing.date);
|
|
@@ -739,7 +1159,7 @@ export function createMeetingMinutesUI({
|
|
|
739
1159
|
}
|
|
740
1160
|
list.append(renderMeetingRow(entry));
|
|
741
1161
|
}
|
|
742
|
-
const unregistered =
|
|
1162
|
+
const unregistered = visible.filter((entry) => meetingTiming(entry).kind === 'unregistered');
|
|
743
1163
|
if (unregistered.length) {
|
|
744
1164
|
list.append(makeElement(doc, 'h3', {
|
|
745
1165
|
className: 'bb-minutes-date-heading',
|
|
@@ -753,6 +1173,8 @@ export function createMeetingMinutesUI({
|
|
|
753
1173
|
function renderRail(version, documentRecord, meeting) {
|
|
754
1174
|
if (!rail) return;
|
|
755
1175
|
clear(rail);
|
|
1176
|
+
railProjectSlot = null;
|
|
1177
|
+
railProjectMeeting = null;
|
|
756
1178
|
const block = makeElement(doc, 'section', { className: 'bb-minutes-rail-block' });
|
|
757
1179
|
block.append(makeElement(doc, 'h2', { textContent: '関連' }));
|
|
758
1180
|
if (!meeting && !version) {
|
|
@@ -763,8 +1185,10 @@ export function createMeetingMinutesUI({
|
|
|
763
1185
|
block.append(makeElement(doc, 'h3', { textContent: '予定' }));
|
|
764
1186
|
block.append(makeElement(doc, 'p', { className: 'bb-minutes-rail-note', textContent: '予定とのつながりは未対応です。' }));
|
|
765
1187
|
block.append(makeElement(doc, 'h3', { textContent: 'プロジェクト' }));
|
|
766
|
-
|
|
767
|
-
|
|
1188
|
+
railProjectSlot = makeElement(doc, 'p', { className: 'bb-minutes-rail-project' });
|
|
1189
|
+
railProjectMeeting = meeting;
|
|
1190
|
+
fillRailProject(railProjectSlot, meeting);
|
|
1191
|
+
block.append(railProjectSlot);
|
|
768
1192
|
appendIdentifiers(doc, block, {
|
|
769
1193
|
meetingId: meeting?.meeting_id,
|
|
770
1194
|
minutesId: documentRecord?.minutes_id,
|
|
@@ -773,6 +1197,11 @@ export function createMeetingMinutesUI({
|
|
|
773
1197
|
rail.append(block);
|
|
774
1198
|
}
|
|
775
1199
|
|
|
1200
|
+
function rerender(detail, documentRecord, version) {
|
|
1201
|
+
clear(detailPane);
|
|
1202
|
+
renderDetail(detail, documentRecord, version);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
776
1205
|
function renderVersion(detail, documentRecord, selectedVersion, noticeState) {
|
|
777
1206
|
const versions = detailVersions(detail, documentRecord.minutes_id);
|
|
778
1207
|
const latest = versions.find((version) => version.version_id === documentRecord.current_version_id) ?? versions[versions.length - 1] ?? null;
|
|
@@ -782,105 +1211,143 @@ export function createMeetingMinutesUI({
|
|
|
782
1211
|
const selectedState = versionState(selected, versions, latest);
|
|
783
1212
|
const latestState = versionState(latest, versions, latest);
|
|
784
1213
|
const viewingOld = Boolean(selected && latest && selected.version_id !== latest.version_id);
|
|
1214
|
+
const withdrawal = isRecord(documentRecord.withdrawal) ? documentRecord.withdrawal : null;
|
|
1215
|
+
const native = isRecord(selected) && typeof selected.body === 'string' && !isRecord(selected.source_ref);
|
|
1216
|
+
const editing = state.editingMinutesId === documentRecord.minutes_id && native && !viewingOld && !withdrawal && canEdit();
|
|
785
1217
|
const section = makeElement(doc, 'section', { className: 'bb-minutes-document' });
|
|
1218
|
+
if (withdrawal) {
|
|
1219
|
+
const when = formatStamp(withdrawal.withdrawn_at);
|
|
1220
|
+
const who = text(withdrawal.actor_id);
|
|
1221
|
+
section.append(makeElement(doc, 'p', {
|
|
1222
|
+
className: 'bb-minutes-withdrawn',
|
|
1223
|
+
textContent: `取り下げ済み(${[who, when].filter(Boolean).join('・') || '取り下げた人と日時は未記録'})。プロジェクトの議事録の一覧には出ません。`,
|
|
1224
|
+
}));
|
|
1225
|
+
}
|
|
786
1226
|
if (viewingOld) {
|
|
787
1227
|
const banner = makeElement(doc, 'div', { className: 'bb-minutes-old-version' });
|
|
788
1228
|
const latestName = latestState.code === 'confirmed' ? '確認済み' : latestState.label;
|
|
789
1229
|
banner.append(makeElement(doc, 'p', {
|
|
790
1230
|
textContent: `古い版を表示しています。最新は${versionLabel(versions, latest)}(${latestName})です。`,
|
|
791
1231
|
}));
|
|
792
|
-
banner.append(button(doc, '最新の版を見る', () =>
|
|
793
|
-
clear(detailPane);
|
|
794
|
-
renderDetail(detail, documentRecord, latest);
|
|
795
|
-
}));
|
|
1232
|
+
banner.append(button(doc, '最新の版を見る', () => rerender(detail, documentRecord, latest)));
|
|
796
1233
|
section.append(banner);
|
|
797
1234
|
}
|
|
798
|
-
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1235
|
+
// State and actions come first; which version this is goes below the body.
|
|
1236
|
+
const actions = makeElement(doc, 'div', { className: 'bb-minutes-version-actions' });
|
|
1237
|
+
actions.append(stateChip(selectedState, { detailed: true }));
|
|
1238
|
+
if (!withdrawal && !editing) {
|
|
1239
|
+
if (selected && canEdit() && !isRecord(selected.confirmation) && selectedState.code !== 'unknown' && selectedState.code !== 'creating') {
|
|
1240
|
+
const confirm = button(doc, 'この版を確認済みにする', async () => {
|
|
1241
|
+
if (!canEdit()) {
|
|
1242
|
+
rerender(detail, documentRecord, selected);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
confirm.disabled = true;
|
|
1246
|
+
try {
|
|
1247
|
+
await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}/confirm`, {
|
|
1248
|
+
version_id: selected.version_id,
|
|
1249
|
+
expected_revision: documentRecord.revision,
|
|
1250
|
+
});
|
|
1251
|
+
await loadDetail(detail.meeting.meeting_id);
|
|
1252
|
+
if (!state.detailError) setStatus(`${selectedLabel}を確認済みにしました。`, 'success');
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
confirm.disabled = false;
|
|
1255
|
+
setStatus(error instanceof Error ? error.message : '確認を保存できませんでした。', 'error');
|
|
1256
|
+
}
|
|
1257
|
+
}, { className: 'bb-minutes-button is-primary' });
|
|
1258
|
+
actions.append(confirm);
|
|
1259
|
+
}
|
|
1260
|
+
if (native && !viewingOld && canEdit()) {
|
|
1261
|
+
actions.append(button(doc, '編集', () => {
|
|
1262
|
+
state.editingMinutesId = documentRecord.minutes_id;
|
|
1263
|
+
state.withdrawingMinutesId = null;
|
|
1264
|
+
rerender(detail, documentRecord, selected);
|
|
1265
|
+
}));
|
|
1266
|
+
}
|
|
1267
|
+
if (canEdit() && mayWithdraw(documentRecord)) {
|
|
1268
|
+
actions.append(button(doc, '取り下げる', () => {
|
|
1269
|
+
state.withdrawingMinutesId = documentRecord.minutes_id;
|
|
1270
|
+
state.editingMinutesId = null;
|
|
1271
|
+
rerender(detail, documentRecord, selected);
|
|
1272
|
+
}, { className: 'bb-minutes-button is-quiet' }));
|
|
1273
|
+
}
|
|
815
1274
|
}
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
const itemState = versionState(version, versions, latest);
|
|
823
|
-
const isLatest = Boolean(latest) && version.version_id === latest.version_id;
|
|
824
|
-
const line1 = makeElement(doc, 'span', { className: 'bb-minutes-version-line' });
|
|
825
|
-
line1.append(
|
|
826
|
-
makeElement(doc, 'strong', { textContent: `${versionLabel(versions, version)}${isLatest ? '(最新)' : ''}` }),
|
|
827
|
-
stateChip(itemState),
|
|
828
|
-
makeElement(doc, 'span', { className: 'bb-minutes-version-stamp', textContent: historyStamp(version, clock()) }),
|
|
829
|
-
);
|
|
830
|
-
versionButton.append(
|
|
831
|
-
line1,
|
|
832
|
-
makeElement(doc, 'span', {
|
|
833
|
-
className: 'bb-minutes-version-line is-secondary',
|
|
834
|
-
textContent: historySecondLine(version, versions, latest),
|
|
835
|
-
}),
|
|
836
|
-
);
|
|
837
|
-
versionButton.addEventListener('click', () => {
|
|
838
|
-
clear(detailPane);
|
|
839
|
-
renderDetail(detail, documentRecord, version);
|
|
840
|
-
});
|
|
841
|
-
history.append(versionButton);
|
|
1275
|
+
section.append(actions);
|
|
1276
|
+
if (!withdrawal && canEdit() && !mayWithdraw(documentRecord)) {
|
|
1277
|
+
section.append(makeElement(doc, 'p', { className: 'bb-minutes-withdraw-guard', textContent: '取り下げられるのは、この議事録を作った人だけです。' }));
|
|
1278
|
+
}
|
|
1279
|
+
if (!withdrawal && state.withdrawingMinutesId === documentRecord.minutes_id && canEdit() && mayWithdraw(documentRecord)) {
|
|
1280
|
+
section.append(renderWithdrawConfirm(detail, documentRecord, selected));
|
|
842
1281
|
}
|
|
843
|
-
|
|
1282
|
+
if (!noticeState.placed && appendReadOnlyNotice(section, selected)) noticeState.placed = true;
|
|
844
1283
|
const content = makeElement(doc, 'div', { className: 'bb-minutes-version-content' });
|
|
845
1284
|
const bodyRoot = makeElement(doc, 'div', {
|
|
846
1285
|
className: 'bb-minutes-external-body-slot',
|
|
847
1286
|
attrs: { 'data-minutes-external-body': 'true' },
|
|
848
1287
|
});
|
|
849
|
-
|
|
1288
|
+
if (editing) {
|
|
1289
|
+
content.append(renderEditForm(detail, documentRecord, selected, versions));
|
|
1290
|
+
} else {
|
|
1291
|
+
content.append(versionContent(doc, selected, { title: detail.meeting?.title }), bodyRoot);
|
|
1292
|
+
}
|
|
850
1293
|
section.append(content);
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1294
|
+
const meta = makeElement(doc, 'div', { className: 'bb-minutes-version-meta' });
|
|
1295
|
+
const latestMark = selected && latest && selected.version_id === latest.version_id ? '(最新)' : '';
|
|
1296
|
+
meta.append(makeElement(doc, 'span', {
|
|
1297
|
+
className: 'bb-minutes-item-meta',
|
|
1298
|
+
textContent: [`${selectedLabel}${latestMark}`, historyStamp(selected, clock()), creatorFact(selected)].join('・'),
|
|
1299
|
+
}));
|
|
1300
|
+
section.append(meta);
|
|
1301
|
+
if (versions.length >= 2) {
|
|
1302
|
+
const history = makeElement(doc, 'div', { className: 'bb-minutes-version-history is-collapsed', attrs: { 'aria-label': '版の履歴' } });
|
|
1303
|
+
const historyButton = button(doc, `版の履歴(${versions.length}件)`, () => {
|
|
1304
|
+
const collapsed = history.className.includes('is-collapsed');
|
|
1305
|
+
history.className = collapsed ? 'bb-minutes-version-history' : 'bb-minutes-version-history is-collapsed';
|
|
1306
|
+
historyButton.textContent = collapsed ? `版の履歴を閉じる(${versions.length}件)` : `版の履歴(${versions.length}件)`;
|
|
1307
|
+
});
|
|
1308
|
+
meta.append(historyButton);
|
|
1309
|
+
for (const version of [...versions].reverse()) {
|
|
1310
|
+
const versionButton = makeElement(doc, 'button', {
|
|
1311
|
+
className: `bb-minutes-version${version.version_id === selected?.version_id ? ' is-selected' : ''}`,
|
|
1312
|
+
type: 'button',
|
|
1313
|
+
attrs: { type: 'button' },
|
|
1314
|
+
});
|
|
1315
|
+
const itemState = versionState(version, versions, latest);
|
|
1316
|
+
const isLatest = Boolean(latest) && version.version_id === latest.version_id;
|
|
1317
|
+
const line1 = makeElement(doc, 'span', { className: 'bb-minutes-version-line' });
|
|
1318
|
+
line1.append(
|
|
1319
|
+
makeElement(doc, 'strong', { textContent: `${versionLabel(versions, version)}${isLatest ? '(最新)' : ''}` }),
|
|
1320
|
+
stateChip(itemState),
|
|
1321
|
+
makeElement(doc, 'span', { className: 'bb-minutes-version-stamp', textContent: historyStamp(version, clock()) }),
|
|
1322
|
+
);
|
|
1323
|
+
versionButton.append(
|
|
1324
|
+
line1,
|
|
1325
|
+
makeElement(doc, 'span', {
|
|
1326
|
+
className: 'bb-minutes-version-line is-secondary',
|
|
1327
|
+
textContent: historySecondLine(version, versions, latest),
|
|
1328
|
+
}),
|
|
1329
|
+
);
|
|
1330
|
+
versionButton.addEventListener('click', () => {
|
|
1331
|
+
state.editingMinutesId = null;
|
|
1332
|
+
rerender(detail, documentRecord, version);
|
|
1333
|
+
});
|
|
1334
|
+
history.append(versionButton);
|
|
1335
|
+
}
|
|
1336
|
+
section.append(history);
|
|
1337
|
+
}
|
|
1338
|
+
if (!editing) {
|
|
1339
|
+
appendVersionActionExtensions(doc, section, {
|
|
1340
|
+
extensions: versionActionExtensions,
|
|
1341
|
+
detail,
|
|
1342
|
+
documentRecord,
|
|
1343
|
+
version: selected,
|
|
1344
|
+
request: call,
|
|
1345
|
+
mutate,
|
|
1346
|
+
refresh: () => loadDetail(detail.meeting.meeting_id),
|
|
1347
|
+
setStatus,
|
|
1348
|
+
bodyRoot,
|
|
1349
|
+
});
|
|
872
1350
|
}
|
|
873
|
-
appendVersionActionExtensions(doc, section, {
|
|
874
|
-
extensions: versionActionExtensions,
|
|
875
|
-
detail,
|
|
876
|
-
documentRecord,
|
|
877
|
-
version: selected,
|
|
878
|
-
request: call,
|
|
879
|
-
mutate,
|
|
880
|
-
refresh: () => loadDetail(detail.meeting.meeting_id),
|
|
881
|
-
setStatus,
|
|
882
|
-
bodyRoot,
|
|
883
|
-
});
|
|
884
1351
|
appendIdentifiers(doc, section, {
|
|
885
1352
|
meetingId: detail.meeting?.meeting_id,
|
|
886
1353
|
minutesId: documentRecord.minutes_id,
|
|
@@ -890,8 +1357,97 @@ export function createMeetingMinutesUI({
|
|
|
890
1357
|
return section;
|
|
891
1358
|
}
|
|
892
1359
|
|
|
1360
|
+
// Editing writes a new version; the earlier versions stay (story-meeting-minutes-edit-and-withdraw-ui-v1 AC-01).
|
|
1361
|
+
function renderEditForm(detail, documentRecord, selected, versions) {
|
|
1362
|
+
const form = makeElement(doc, 'form', { className: 'bb-minutes-edit-form' });
|
|
1363
|
+
const field = labelInput(doc, '本文', { type: 'textarea', name: 'body', value: selected.body });
|
|
1364
|
+
field.input.value = selected.body;
|
|
1365
|
+
field.input.rows = 16;
|
|
1366
|
+
form.append(
|
|
1367
|
+
field.wrapper,
|
|
1368
|
+
makeElement(doc, 'p', { className: 'bb-minutes-edit-note', textContent: `保存すると第${versions.length + 1}版になります。前の版は残ります。` }),
|
|
1369
|
+
makeElement(doc, 'p', { className: 'bb-minutes-edit-note', textContent: '公開したときにGitへ書き出した分とSlackの知らせは、前の版のまま変わりません。' }),
|
|
1370
|
+
);
|
|
1371
|
+
const save = makeElement(doc, 'button', { className: 'bb-minutes-button is-primary', textContent: '新しい版として保存', type: 'submit' });
|
|
1372
|
+
const cancel = button(doc, 'やめる', () => {
|
|
1373
|
+
state.editingMinutesId = null;
|
|
1374
|
+
rerender(detail, documentRecord, selected);
|
|
1375
|
+
});
|
|
1376
|
+
const row = makeElement(doc, 'div', { className: 'bb-minutes-actions' });
|
|
1377
|
+
row.append(save, cancel);
|
|
1378
|
+
form.append(row);
|
|
1379
|
+
form.addEventListener('submit', async (event) => {
|
|
1380
|
+
event.preventDefault();
|
|
1381
|
+
if (!canEdit()) {
|
|
1382
|
+
state.editingMinutesId = null;
|
|
1383
|
+
rerender(detail, documentRecord, selected);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
save.disabled = true;
|
|
1387
|
+
try {
|
|
1388
|
+
await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}`, {
|
|
1389
|
+
body: field.input.value,
|
|
1390
|
+
expected_revision: documentRecord.revision,
|
|
1391
|
+
});
|
|
1392
|
+
state.editingMinutesId = null;
|
|
1393
|
+
await loadDetail(detail.meeting.meeting_id);
|
|
1394
|
+
if (!state.detailError) setStatus(`第${versions.length + 1}版として保存しました。確認待ちです。`, 'success');
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
save.disabled = false;
|
|
1397
|
+
setStatus(error instanceof Error ? error.message : '新しい版を保存できませんでした。', 'error');
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
return form;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// Withdrawal takes the document back without deleting it (台帳 D9, story-meeting-minutes-withdraw-v1).
|
|
1404
|
+
function renderWithdrawConfirm(detail, documentRecord, selected) {
|
|
1405
|
+
const panel = makeElement(doc, 'div', { className: 'bb-minutes-withdraw-confirm', attrs: { role: 'group', 'aria-label': '取り下げの確認' } });
|
|
1406
|
+
panel.append(makeElement(doc, 'p', { className: 'bb-minutes-withdraw-title', textContent: 'この議事録を取り下げますか?' }));
|
|
1407
|
+
const facts = makeElement(doc, 'ul', { className: 'bb-minutes-md-list' });
|
|
1408
|
+
for (const line of [
|
|
1409
|
+
'プロジェクトの議事録の一覧から外れます。',
|
|
1410
|
+
'版と本文は消えず、取り下げた人と日時が残ります。',
|
|
1411
|
+
'Gitへ書き出した分と、Slackで送った知らせは取り消せません。',
|
|
1412
|
+
'取り下げは元に戻せません。もう一度公開すると、新しい議事録として作られます。',
|
|
1413
|
+
]) facts.append(makeElement(doc, 'li', { textContent: line }));
|
|
1414
|
+
panel.append(facts);
|
|
1415
|
+
const row = makeElement(doc, 'div', { className: 'bb-minutes-actions' });
|
|
1416
|
+
const confirm = button(doc, '取り下げる', async () => {
|
|
1417
|
+
if (!canEdit() || !mayWithdraw(documentRecord)) {
|
|
1418
|
+
state.withdrawingMinutesId = null;
|
|
1419
|
+
rerender(detail, documentRecord, selected);
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
confirm.disabled = true;
|
|
1423
|
+
try {
|
|
1424
|
+
await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}/withdraw`, {
|
|
1425
|
+
expected_revision: documentRecord.revision,
|
|
1426
|
+
});
|
|
1427
|
+
state.withdrawingMinutesId = null;
|
|
1428
|
+
await loadMeetings();
|
|
1429
|
+
await loadDetail(detail.meeting.meeting_id);
|
|
1430
|
+
if (!state.detailError) setStatus('議事録を取り下げました。', 'success');
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
confirm.disabled = false;
|
|
1433
|
+
const denied = error?.status === 403;
|
|
1434
|
+
setStatus(denied
|
|
1435
|
+
? '取り下げられませんでした。取り下げられるのは、この議事録を作った人だけです。'
|
|
1436
|
+
: error instanceof Error ? error.message : '取り下げられませんでした。', 'error');
|
|
1437
|
+
}
|
|
1438
|
+
}, { className: 'bb-minutes-button is-danger' });
|
|
1439
|
+
const cancel = button(doc, 'やめる', () => {
|
|
1440
|
+
state.withdrawingMinutesId = null;
|
|
1441
|
+
rerender(detail, documentRecord, selected);
|
|
1442
|
+
});
|
|
1443
|
+
row.append(confirm, cancel);
|
|
1444
|
+
panel.append(row);
|
|
1445
|
+
return panel;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
893
1448
|
function renderDetail(detail, documentRecord = null, selectedVersion = null) {
|
|
894
1449
|
clear(detailPane);
|
|
1450
|
+
connectionsSlot = null;
|
|
895
1451
|
if (state.detailError) {
|
|
896
1452
|
const retry = state.meetingId === null ? loadMeetings : () => loadDetail(state.meetingId);
|
|
897
1453
|
appendLoadError(detailPane, state.detailError.startsWith('会議詳細を読み込めませんでした。')
|
|
@@ -924,6 +1480,14 @@ export function createMeetingMinutesUI({
|
|
|
924
1480
|
textContent: participants ? `${when} / ${participants}` : when,
|
|
925
1481
|
}));
|
|
926
1482
|
detailPane.append(header);
|
|
1483
|
+
if (hasConnections()) {
|
|
1484
|
+
connectionsSlot = makeElement(doc, 'section', {
|
|
1485
|
+
className: 'bb-minutes-connections',
|
|
1486
|
+
attrs: { 'aria-label': 'この会議から' },
|
|
1487
|
+
});
|
|
1488
|
+
fillConnections(connectionsSlot, currentConnections(meeting));
|
|
1489
|
+
detailPane.append(connectionsSlot);
|
|
1490
|
+
}
|
|
927
1491
|
const documents = Array.isArray(detail.minutes) ? detail.minutes : [];
|
|
928
1492
|
const noticeState = { placed: false };
|
|
929
1493
|
if (documents.length === 0) {
|
|
@@ -941,14 +1505,17 @@ export function createMeetingMinutesUI({
|
|
|
941
1505
|
}
|
|
942
1506
|
}
|
|
943
1507
|
|
|
1508
|
+
function renderCreateUnavailable() {
|
|
1509
|
+
clear(detailPane);
|
|
1510
|
+
detailPane.append(makeElement(doc, 'p', { className: 'bb-minutes-empty', textContent: '議事録を選ぶと、ここに本文が出ます。' }));
|
|
1511
|
+
const reason = createReason();
|
|
1512
|
+
if (reason) detailPane.append(makeElement(doc, 'p', { className: 'bb-minutes-create-reason', textContent: reason }));
|
|
1513
|
+
}
|
|
1514
|
+
|
|
944
1515
|
function renderCreateForm() {
|
|
945
1516
|
clear(detailPane);
|
|
946
1517
|
if (!canCreate()) {
|
|
947
|
-
|
|
948
|
-
detailPane.append(makeElement(doc, 'p', {
|
|
949
|
-
className: 'bb-minutes-create-guard',
|
|
950
|
-
textContent: '会議を作成する権限がありません。作成はプロジェクトの編集権限がある人が行います。',
|
|
951
|
-
}));
|
|
1518
|
+
renderCreateUnavailable();
|
|
952
1519
|
return;
|
|
953
1520
|
}
|
|
954
1521
|
const form = makeElement(doc, 'form', { className: 'bb-minutes-create-form' });
|
|
@@ -966,12 +1533,7 @@ export function createMeetingMinutesUI({
|
|
|
966
1533
|
if (!canCreate()) {
|
|
967
1534
|
save.disabled = true;
|
|
968
1535
|
renderList();
|
|
969
|
-
|
|
970
|
-
detailPane.append(makeElement(doc, 'p', { className: 'bb-minutes-empty', textContent: '議事録を選ぶと、ここに本文が出ます。' }));
|
|
971
|
-
detailPane.append(makeElement(doc, 'p', {
|
|
972
|
-
className: 'bb-minutes-create-guard',
|
|
973
|
-
textContent: '会議を作成する権限がありません。作成はプロジェクトの編集権限がある人が行います。',
|
|
974
|
-
}));
|
|
1536
|
+
renderCreateUnavailable();
|
|
975
1537
|
return;
|
|
976
1538
|
}
|
|
977
1539
|
save.disabled = true;
|
|
@@ -1058,6 +1620,8 @@ export function createMeetingMinutesUI({
|
|
|
1058
1620
|
state.meetingId = meetingId;
|
|
1059
1621
|
state.minutesId = null;
|
|
1060
1622
|
state.detailError = null;
|
|
1623
|
+
state.editingMinutesId = null;
|
|
1624
|
+
state.withdrawingMinutesId = null;
|
|
1061
1625
|
renderList();
|
|
1062
1626
|
setStatus('会議詳細を読み込んでいます。');
|
|
1063
1627
|
try {
|
|
@@ -1066,8 +1630,10 @@ export function createMeetingMinutesUI({
|
|
|
1066
1630
|
if (!isRecord(detail) || !isRecord(detail.meeting) || !Array.isArray(detail.minutes) || !Array.isArray(detail.versions)) throw new Error('会議詳細の形式を確認できません。');
|
|
1067
1631
|
state.detail = detail;
|
|
1068
1632
|
state.detailError = null;
|
|
1633
|
+
if (hasConnections()) state.connections = { meetingId, rows: null };
|
|
1069
1634
|
renderDetail(detail);
|
|
1070
1635
|
setStatus('会議詳細を読み込みました。');
|
|
1636
|
+
if (hasConnections()) void loadConnections(detail, isCurrentRequest);
|
|
1071
1637
|
} catch (error) {
|
|
1072
1638
|
if (!isCurrentRequest()) return state.detail;
|
|
1073
1639
|
const preserved = preserveLoadedStateOnFailure(error) && state.detail?.meeting?.meeting_id === meetingId
|
|
@@ -1081,6 +1647,7 @@ export function createMeetingMinutesUI({
|
|
|
1081
1647
|
return state.detail;
|
|
1082
1648
|
}
|
|
1083
1649
|
|
|
1650
|
+
renderToolbar([]);
|
|
1084
1651
|
renderDetail(null);
|
|
1085
1652
|
void loadMeetings();
|
|
1086
1653
|
return {
|