@unson/brainbase-mcp 0.21.1 → 0.22.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/local-web-fetch-bridge.js +1 -1
- package/dist/local-web-host.d.ts +59 -0
- package/dist/local-web-host.js +171 -4
- package/dist/meeting-minutes-http.d.ts +22 -0
- package/dist/meeting-minutes-http.js +252 -0
- package/dist/meeting-minutes-lineage-adapters.d.ts +157 -0
- package/dist/meeting-minutes-lineage-adapters.js +410 -0
- package/dist/meeting-minutes-lineage-http.d.ts +20 -0
- package/dist/meeting-minutes-lineage-http.js +221 -0
- package/dist/meeting-minutes-lineage-lifecycle.d.ts +58 -0
- package/dist/meeting-minutes-lineage-lifecycle.js +146 -0
- package/dist/meeting-minutes-lineage.d.ts +307 -0
- package/dist/meeting-minutes-lineage.js +1030 -0
- package/dist/meeting-minutes-storage-controller.d.ts +182 -0
- package/dist/meeting-minutes-storage-controller.js +366 -0
- package/dist/meeting-minutes-storage-http.d.ts +25 -0
- package/dist/meeting-minutes-storage-http.js +312 -0
- package/dist/meeting-minutes-storage.d.ts +169 -0
- package/dist/meeting-minutes-storage.js +402 -0
- package/dist/meeting-minutes.d.ts +223 -0
- package/dist/meeting-minutes.js +1172 -0
- package/dist/world-extension.js +1 -1
- package/package.json +46 -3
- package/ui/brainbase-tokens.css +35 -35
- package/ui/local-web-shell.css +20 -17
- package/ui/local-web-shell.js +34 -1
- package/ui/meeting-minutes-lineage-http.js +247 -0
- package/ui/meeting-minutes-lineage.css +177 -0
- package/ui/meeting-minutes-lineage.js +485 -0
- package/ui/meeting-minutes-storage.css +170 -0
- package/ui/meeting-minutes-storage.js +388 -0
- package/ui/meeting-minutes.css +215 -0
- package/ui/meeting-minutes.js +622 -0
- package/ui/world/world-district.js +306 -87
- package/ui/world/world-scenery.js +208 -0
- package/ui/world/world-view.css +54 -0
- package/ui/world/world-view.js +65 -205
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared meeting-minutes screen.
|
|
3
|
+
*
|
|
4
|
+
* The screen only speaks the provider-independent HTTP contract. A host
|
|
5
|
+
* supplies the fetcher, token and base path; no calendar, repository, or
|
|
6
|
+
* organization connector is assumed here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const MEETING_MINUTES_UI_CONTRACT_VERSION = 'brainbase.meeting-minutes-ui.v1';
|
|
10
|
+
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function text(value, fallback = '') {
|
|
16
|
+
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeElement(doc, tag, { className, textContent, type, value, disabled, attrs = {} } = {}) {
|
|
20
|
+
const element = doc.createElement(tag);
|
|
21
|
+
if (className) element.className = className;
|
|
22
|
+
if (textContent !== undefined) element.textContent = String(textContent);
|
|
23
|
+
if (type) element.type = type;
|
|
24
|
+
if (value !== undefined && 'value' in element) element.value = value;
|
|
25
|
+
if (disabled !== undefined) element.disabled = Boolean(disabled);
|
|
26
|
+
for (const [name, attrValue] of Object.entries(attrs)) {
|
|
27
|
+
if (attrValue === undefined || attrValue === null || attrValue === false) continue;
|
|
28
|
+
element.setAttribute(name, attrValue === true ? '' : String(attrValue));
|
|
29
|
+
}
|
|
30
|
+
return element;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function button(doc, label, onClick, options = {}) {
|
|
34
|
+
const item = makeElement(doc, 'button', {
|
|
35
|
+
className: options.className ?? 'bb-minutes-button',
|
|
36
|
+
textContent: label,
|
|
37
|
+
type: 'button',
|
|
38
|
+
disabled: options.disabled,
|
|
39
|
+
attrs: options.attrs,
|
|
40
|
+
});
|
|
41
|
+
item.addEventListener('click', onClick);
|
|
42
|
+
return item;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function labelInput(doc, label, { type = 'text', value = '', name, placeholder } = {}) {
|
|
46
|
+
const wrapper = makeElement(doc, 'label', { className: 'bb-minutes-field' });
|
|
47
|
+
wrapper.append(makeElement(doc, 'span', { className: 'bb-minutes-label', textContent: label }));
|
|
48
|
+
const input = makeElement(doc, type === 'textarea' ? 'textarea' : 'input', {
|
|
49
|
+
className: 'bb-minutes-input', type: type === 'textarea' ? undefined : type, value,
|
|
50
|
+
attrs: { name, placeholder },
|
|
51
|
+
});
|
|
52
|
+
wrapper.append(input);
|
|
53
|
+
return { wrapper, input };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function displayDate(value) {
|
|
57
|
+
if (!value) return '日時未登録';
|
|
58
|
+
const date = new Date(value);
|
|
59
|
+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('ja-JP');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function localDateTime(value) {
|
|
63
|
+
if (!value) return '';
|
|
64
|
+
const date = new Date(value);
|
|
65
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
66
|
+
const pad = (part) => String(part).padStart(2, '0');
|
|
67
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function idempotencyKey() {
|
|
71
|
+
const random = typeof globalThis.crypto?.randomUUID === 'function'
|
|
72
|
+
? globalThis.crypto.randomUUID()
|
|
73
|
+
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
74
|
+
return `meeting-minutes-${random}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createHttpError(status, payload) {
|
|
78
|
+
const error = isRecord(payload?.error) ? payload.error : {};
|
|
79
|
+
const result = new Error(text(error.message, `HTTP ${status}`));
|
|
80
|
+
result.name = 'MeetingMinutesUIHttpError';
|
|
81
|
+
result.status = status;
|
|
82
|
+
result.code = text(error.code, `http_${status}`);
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function loadErrorMessage(error, fallback) {
|
|
87
|
+
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isAccessDenied(error) {
|
|
91
|
+
const status = Number(error?.status);
|
|
92
|
+
const code = text(error?.code);
|
|
93
|
+
return status === 401 || status === 403 || status === 404
|
|
94
|
+
|| code === 'authorization_denied' || code === 'not_found';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function preserveLoadedStateOnFailure(error) {
|
|
98
|
+
if (isAccessDenied(error)) return false;
|
|
99
|
+
const status = Number(error?.status);
|
|
100
|
+
if (Number.isFinite(status)) return status === 408 || status === 429 || status >= 500;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sourceLabel(source) {
|
|
105
|
+
if (!isRecord(source)) return '';
|
|
106
|
+
const provider = text(source.provider, '外部ソース');
|
|
107
|
+
const locator = text(source.locator, '場所未確認');
|
|
108
|
+
const revision = text(source.revision, '版未確認');
|
|
109
|
+
return `${provider} / ${locator} / ${revision}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function versionContent(doc, version) {
|
|
113
|
+
if (!isRecord(version)) return makeElement(doc, 'p', { textContent: '版の形式を確認できません。', className: 'bb-minutes-warning' });
|
|
114
|
+
if (isRecord(version.source_ref)) {
|
|
115
|
+
return makeElement(doc, 'p', { className: 'bb-minutes-source', textContent: `外部参照: ${sourceLabel(version.source_ref)}` });
|
|
116
|
+
}
|
|
117
|
+
if (typeof version.body === 'string') {
|
|
118
|
+
return makeElement(doc, 'pre', { className: 'bb-minutes-body', textContent: version.body });
|
|
119
|
+
}
|
|
120
|
+
return makeElement(doc, 'p', { className: 'bb-minutes-warning', textContent: '本文または外部参照を確認できません。' });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function detailVersions(detail, minutesId) {
|
|
124
|
+
if (!isRecord(detail) || !Array.isArray(detail.versions)) return [];
|
|
125
|
+
return detail.versions.filter((version) => version?.minutes_id === minutesId);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function versionLabel(versions, version) {
|
|
129
|
+
const index = versions.findIndex((candidate) => candidate?.version_id === version?.version_id);
|
|
130
|
+
return index >= 0 ? `第${index + 1}版` : '版';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function appendIdentifiers(doc, parent, { meetingId, minutesId, versionId } = {}) {
|
|
134
|
+
const details = makeElement(doc, 'details', { className: 'bb-minutes-identifiers' });
|
|
135
|
+
details.append(makeElement(doc, 'summary', { textContent: '識別情報' }));
|
|
136
|
+
const rows = [
|
|
137
|
+
['会議ID', meetingId],
|
|
138
|
+
['議事録ID', minutesId],
|
|
139
|
+
['版ID', versionId],
|
|
140
|
+
].filter(([, value]) => typeof value === 'string' && value);
|
|
141
|
+
for (const [label, value] of rows) {
|
|
142
|
+
details.append(makeElement(doc, 'div', {
|
|
143
|
+
className: 'bb-minutes-identifier-row',
|
|
144
|
+
textContent: `${label}: ${value}`,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
parent.append(details);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function appendVersionActionExtensions(doc, section, {
|
|
151
|
+
extensions,
|
|
152
|
+
detail,
|
|
153
|
+
documentRecord,
|
|
154
|
+
version,
|
|
155
|
+
request,
|
|
156
|
+
mutate,
|
|
157
|
+
refresh,
|
|
158
|
+
setStatus,
|
|
159
|
+
bodyRoot,
|
|
160
|
+
} = {}) {
|
|
161
|
+
for (const extension of Array.isArray(extensions) ? extensions : []) {
|
|
162
|
+
if (!extension || typeof extension !== 'object' || typeof extension.mount !== 'function') continue;
|
|
163
|
+
const id = text(extension.id, 'version-action');
|
|
164
|
+
const slot = makeElement(doc, 'div', {
|
|
165
|
+
className: 'bb-minutes-version-extension',
|
|
166
|
+
attrs: { 'data-minutes-version-extension': id },
|
|
167
|
+
});
|
|
168
|
+
section.append(slot);
|
|
169
|
+
try {
|
|
170
|
+
const mounted = extension.mount({
|
|
171
|
+
root: slot,
|
|
172
|
+
document: doc,
|
|
173
|
+
detail,
|
|
174
|
+
documentRecord,
|
|
175
|
+
version,
|
|
176
|
+
request,
|
|
177
|
+
mutate,
|
|
178
|
+
refresh,
|
|
179
|
+
setStatus,
|
|
180
|
+
bodyRoot,
|
|
181
|
+
});
|
|
182
|
+
if (mounted && typeof mounted.then === 'function') {
|
|
183
|
+
mounted.catch(() => {
|
|
184
|
+
slot.replaceChildren(makeElement(doc, 'p', {
|
|
185
|
+
className: 'bb-minutes-warning',
|
|
186
|
+
textContent: '追加の保存先情報を表示できません。',
|
|
187
|
+
}));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
slot.replaceChildren(makeElement(doc, 'p', {
|
|
192
|
+
className: 'bb-minutes-warning',
|
|
193
|
+
textContent: '追加の保存先情報を表示できません。',
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function createMeetingMinutesUI({
|
|
200
|
+
root,
|
|
201
|
+
rail = null,
|
|
202
|
+
page = null,
|
|
203
|
+
document: explicitDocument,
|
|
204
|
+
fetcher,
|
|
205
|
+
token,
|
|
206
|
+
basePath = '/api/meeting-minutes',
|
|
207
|
+
versionActionExtensions = [],
|
|
208
|
+
} = {}) {
|
|
209
|
+
if (!root) throw new TypeError('root is required');
|
|
210
|
+
const doc = explicitDocument ?? (typeof document === 'undefined' ? null : document);
|
|
211
|
+
if (!doc || typeof doc.createElement !== 'function') throw new Error('document_unavailable');
|
|
212
|
+
const request = typeof fetcher === 'function'
|
|
213
|
+
? fetcher
|
|
214
|
+
: typeof globalThis.fetch === 'function' ? (path, init) => globalThis.fetch(path, init) : null;
|
|
215
|
+
const base = String(basePath).replace(/\/+$/u, '');
|
|
216
|
+
const state = {
|
|
217
|
+
meetings: [],
|
|
218
|
+
detail: null,
|
|
219
|
+
meetingId: null,
|
|
220
|
+
minutesId: null,
|
|
221
|
+
versionId: null,
|
|
222
|
+
loading: false,
|
|
223
|
+
listError: null,
|
|
224
|
+
detailError: null,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const screen = makeElement(doc, 'div', { className: 'bb-minutes-screen', attrs: { 'data-contract-version': MEETING_MINUTES_UI_CONTRACT_VERSION } });
|
|
228
|
+
const heading = makeElement(doc, 'header', { className: 'bb-minutes-heading' });
|
|
229
|
+
heading.append(
|
|
230
|
+
makeElement(doc, 'div', { className: 'bb-minutes-eyebrow', textContent: page?.source ?? '正本の議事録' }),
|
|
231
|
+
makeElement(doc, 'h1', { textContent: '議事録' }),
|
|
232
|
+
makeElement(doc, 'p', { className: 'bb-minutes-lead', textContent: '会議と議事録を保存し、版と確認履歴を同じ場所で確かめます。' }),
|
|
233
|
+
);
|
|
234
|
+
const status = makeElement(doc, 'p', { className: 'bb-minutes-status', attrs: { role: 'status', 'aria-live': 'polite' } });
|
|
235
|
+
const columns = makeElement(doc, 'div', { className: 'bb-minutes-columns' });
|
|
236
|
+
const listPane = makeElement(doc, 'section', { className: 'bb-minutes-list-pane', attrs: { 'aria-label': '会議一覧' } });
|
|
237
|
+
const detailPane = makeElement(doc, 'section', { className: 'bb-minutes-detail-pane', attrs: { 'aria-label': '会議詳細' } });
|
|
238
|
+
columns.append(listPane, detailPane);
|
|
239
|
+
screen.append(heading, status, columns);
|
|
240
|
+
root.replaceChildren(screen);
|
|
241
|
+
|
|
242
|
+
function setStatus(message, kind = '') {
|
|
243
|
+
status.textContent = message;
|
|
244
|
+
status.className = `bb-minutes-status${kind ? ` is-${kind}` : ''}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function call(path, init = {}) {
|
|
248
|
+
if (!request) throw new Error('ホストに接続できません。');
|
|
249
|
+
const headers = { Accept: 'application/json', ...(init.headers ?? {}) };
|
|
250
|
+
if (token) headers['X-Brainbase-Review-Token'] = token;
|
|
251
|
+
let response;
|
|
252
|
+
try {
|
|
253
|
+
response = await request(`${base}${path}`, { ...init, headers });
|
|
254
|
+
} catch {
|
|
255
|
+
throw new Error('ホストに接続できません。');
|
|
256
|
+
}
|
|
257
|
+
let payload = null;
|
|
258
|
+
try { payload = await response.json(); } catch { /* handled below */ }
|
|
259
|
+
if (!response.ok) throw createHttpError(response.status, payload);
|
|
260
|
+
if (payload === null) throw new Error('ホストの応答を読み取れません。');
|
|
261
|
+
return payload;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function mutate(path, body) {
|
|
265
|
+
return call(path, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey() },
|
|
268
|
+
body: JSON.stringify(body),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function clear(node) { node.replaceChildren(); return node; }
|
|
273
|
+
|
|
274
|
+
function appendLoadError(parent, message, retry) {
|
|
275
|
+
const notice = makeElement(doc, 'div', {
|
|
276
|
+
className: 'bb-minutes-load-error',
|
|
277
|
+
attrs: { role: 'alert' },
|
|
278
|
+
});
|
|
279
|
+
notice.append(makeElement(doc, 'p', { textContent: message }));
|
|
280
|
+
notice.append(button(doc, '再試行', () => { void retry(); }));
|
|
281
|
+
parent.append(notice);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function renderList() {
|
|
285
|
+
clear(listPane);
|
|
286
|
+
const head = makeElement(doc, 'div', { className: 'bb-minutes-pane-head' });
|
|
287
|
+
head.append(makeElement(doc, 'h2', { textContent: '会議一覧' }));
|
|
288
|
+
head.append(button(doc, '会議を作成', () => renderCreateForm(), { className: 'bb-minutes-button is-primary' }));
|
|
289
|
+
listPane.append(head);
|
|
290
|
+
if (state.listError) {
|
|
291
|
+
appendLoadError(listPane, `会議一覧を読み込めませんでした。理由: ${state.listError}`, loadMeetings);
|
|
292
|
+
}
|
|
293
|
+
if (state.meetings.length === 0) {
|
|
294
|
+
if (state.listError) return;
|
|
295
|
+
listPane.append(makeElement(doc, 'p', { className: 'bb-minutes-empty', textContent: '保存された会議はありません。会議を作成するとここに表示されます。' }));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const list = makeElement(doc, 'div', { className: 'bb-minutes-meeting-list' });
|
|
299
|
+
for (const entry of state.meetings) {
|
|
300
|
+
const meeting = entry?.meeting;
|
|
301
|
+
if (!isRecord(meeting) || typeof meeting.meeting_id !== 'string') continue;
|
|
302
|
+
const item = makeElement(doc, 'button', {
|
|
303
|
+
className: `bb-minutes-meeting-item${state.meetingId === meeting.meeting_id ? ' is-selected' : ''}`,
|
|
304
|
+
type: 'button',
|
|
305
|
+
attrs: { type: 'button' },
|
|
306
|
+
});
|
|
307
|
+
item.append(
|
|
308
|
+
makeElement(doc, 'strong', { textContent: text(meeting.title, '無題の会議') }),
|
|
309
|
+
makeElement(doc, 'span', { className: 'bb-minutes-item-meta', textContent: displayDate(meeting.scheduled_at) }),
|
|
310
|
+
makeElement(doc, 'span', { className: 'bb-minutes-item-meta', textContent: `${Array.isArray(entry.minutes) ? entry.minutes.length : 0}件の議事録` }),
|
|
311
|
+
);
|
|
312
|
+
item.addEventListener('click', () => void loadDetail(meeting.meeting_id));
|
|
313
|
+
list.append(item);
|
|
314
|
+
}
|
|
315
|
+
listPane.append(list);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function selectedDetail() {
|
|
319
|
+
return state.detail && state.detail.meeting?.meeting_id === state.meetingId ? state.detail : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function renderRail(version, documentRecord) {
|
|
323
|
+
if (!rail) return;
|
|
324
|
+
clear(rail);
|
|
325
|
+
const block = makeElement(doc, 'section', { className: 'bb-minutes-rail-block' });
|
|
326
|
+
block.append(makeElement(doc, 'h2', { textContent: '確認対象' }));
|
|
327
|
+
if (!version) {
|
|
328
|
+
block.append(makeElement(doc, 'p', { textContent: '版を選ぶと、本文と確認状態をここに表示します。' }));
|
|
329
|
+
} else {
|
|
330
|
+
const versions = detailVersions(state.detail, documentRecord?.minutes_id);
|
|
331
|
+
block.append(
|
|
332
|
+
makeElement(doc, 'p', { className: 'bb-minutes-rail-title', textContent: text(documentRecord?.title, '議事録') }),
|
|
333
|
+
makeElement(doc, 'p', { textContent: versionLabel(versions, version) }),
|
|
334
|
+
makeElement(doc, 'p', { textContent: version.confirmation ? `確認済み(${displayDate(version.confirmation.confirmed_at)})` : '未確認' }),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
rail.append(block);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function renderVersion(detail, documentRecord, selectedVersion) {
|
|
341
|
+
const versions = detailVersions(detail, documentRecord.minutes_id);
|
|
342
|
+
const selected = selectedVersion ?? versions.find((version) => version.version_id === documentRecord.current_version_id) ?? versions[versions.length - 1];
|
|
343
|
+
state.versionId = selected?.version_id ?? null;
|
|
344
|
+
const selectedLabel = versionLabel(versions, selected);
|
|
345
|
+
const section = makeElement(doc, 'section', { className: 'bb-minutes-document' });
|
|
346
|
+
const header = makeElement(doc, 'div', { className: 'bb-minutes-document-head' });
|
|
347
|
+
header.append(
|
|
348
|
+
makeElement(doc, 'div', { className: 'bb-minutes-document-title', textContent: text(documentRecord.title, '議事録') }),
|
|
349
|
+
makeElement(doc, 'span', { className: 'bb-minutes-revision', textContent: `現在 ${selectedLabel}` }),
|
|
350
|
+
);
|
|
351
|
+
section.append(header);
|
|
352
|
+
const history = makeElement(doc, 'div', { className: 'bb-minutes-version-history', attrs: { 'aria-label': '版履歴' } });
|
|
353
|
+
history.append(makeElement(doc, 'h4', { textContent: '版履歴' }));
|
|
354
|
+
for (const version of [...versions].reverse()) {
|
|
355
|
+
const selectedClass = version.version_id === selected?.version_id ? ' is-selected' : '';
|
|
356
|
+
const versionButton = makeElement(doc, 'button', { className: `bb-minutes-version${selectedClass}`, type: 'button', attrs: { type: 'button' } });
|
|
357
|
+
versionButton.append(
|
|
358
|
+
makeElement(doc, 'strong', { textContent: versionLabel(versions, version) }),
|
|
359
|
+
makeElement(doc, 'span', { textContent: version.confirmation ? '確認済み' : '未確認' }),
|
|
360
|
+
makeElement(doc, 'span', { className: 'bb-minutes-item-meta', textContent: displayDate(version.created_at) }),
|
|
361
|
+
);
|
|
362
|
+
versionButton.addEventListener('click', () => { clear(detailPane); renderDetail(detail, documentRecord, version); });
|
|
363
|
+
history.append(versionButton);
|
|
364
|
+
}
|
|
365
|
+
section.append(history);
|
|
366
|
+
const content = makeElement(doc, 'div', { className: 'bb-minutes-version-content' });
|
|
367
|
+
const bodyRoot = makeElement(doc, 'div', {
|
|
368
|
+
className: 'bb-minutes-external-body-slot',
|
|
369
|
+
attrs: { 'data-minutes-external-body': 'true' },
|
|
370
|
+
});
|
|
371
|
+
content.append(makeElement(doc, 'h4', { textContent: `${selectedLabel}の本文` }), versionContent(doc, selected), bodyRoot);
|
|
372
|
+
if (selected?.predecessor_version_id) {
|
|
373
|
+
const predecessor = versions.find((version) => version.version_id === selected.predecessor_version_id);
|
|
374
|
+
content.append(makeElement(doc, 'p', {
|
|
375
|
+
className: 'bb-minutes-item-meta',
|
|
376
|
+
textContent: `訂正元: ${versionLabel(versions, predecessor)}`,
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
section.append(content);
|
|
380
|
+
if (selected) {
|
|
381
|
+
const actions = makeElement(doc, 'div', { className: 'bb-minutes-actions' });
|
|
382
|
+
const confirm = button(doc, selected.confirmation ? '確認済み' : 'この版を確認', async () => {
|
|
383
|
+
if (selected.confirmation) return;
|
|
384
|
+
confirm.disabled = true;
|
|
385
|
+
try {
|
|
386
|
+
await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}/confirm`, {
|
|
387
|
+
version_id: selected.version_id,
|
|
388
|
+
expected_revision: documentRecord.revision,
|
|
389
|
+
});
|
|
390
|
+
setStatus('確認を保存しました。', 'success');
|
|
391
|
+
await loadDetail(detail.meeting.meeting_id);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
confirm.disabled = false;
|
|
394
|
+
setStatus(error instanceof Error ? error.message : '確認を保存できませんでした。', 'error');
|
|
395
|
+
}
|
|
396
|
+
}, { className: 'bb-minutes-button is-primary', disabled: Boolean(selected.confirmation) });
|
|
397
|
+
actions.append(confirm);
|
|
398
|
+
section.append(actions);
|
|
399
|
+
}
|
|
400
|
+
appendVersionActionExtensions(doc, section, {
|
|
401
|
+
extensions: versionActionExtensions,
|
|
402
|
+
detail,
|
|
403
|
+
documentRecord,
|
|
404
|
+
version: selected,
|
|
405
|
+
request: call,
|
|
406
|
+
mutate,
|
|
407
|
+
refresh: () => loadDetail(detail.meeting.meeting_id),
|
|
408
|
+
setStatus,
|
|
409
|
+
bodyRoot,
|
|
410
|
+
});
|
|
411
|
+
appendIdentifiers(doc, section, {
|
|
412
|
+
meetingId: detail.meeting?.meeting_id,
|
|
413
|
+
minutesId: documentRecord.minutes_id,
|
|
414
|
+
versionId: selected?.version_id,
|
|
415
|
+
});
|
|
416
|
+
appendSaveForm(section, detail, documentRecord);
|
|
417
|
+
renderRail(selected, documentRecord);
|
|
418
|
+
return section;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function appendSaveForm(section, detail, documentRecord) {
|
|
422
|
+
const form = makeElement(doc, 'form', { className: 'bb-minutes-save-form' });
|
|
423
|
+
form.append(makeElement(doc, 'h4', { textContent: '訂正版を保存' }));
|
|
424
|
+
const field = labelInput(doc, '本文(保存すると新しい版になります)', { type: 'textarea', name: 'body', placeholder: '議事録の本文を入力' });
|
|
425
|
+
field.input.rows = 8;
|
|
426
|
+
const save = makeElement(doc, 'button', { className: 'bb-minutes-button is-primary', textContent: '新しい版を保存', type: 'submit' });
|
|
427
|
+
form.append(field.wrapper, save);
|
|
428
|
+
form.addEventListener('submit', async (event) => {
|
|
429
|
+
event.preventDefault();
|
|
430
|
+
save.disabled = true;
|
|
431
|
+
try {
|
|
432
|
+
await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}`, {
|
|
433
|
+
body: field.input.value,
|
|
434
|
+
expected_revision: documentRecord.revision,
|
|
435
|
+
});
|
|
436
|
+
setStatus('新しい版を保存しました。', 'success');
|
|
437
|
+
await loadDetail(detail.meeting.meeting_id);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
save.disabled = false;
|
|
440
|
+
setStatus(error instanceof Error ? error.message : '版を保存できませんでした。', 'error');
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
section.append(form);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function renderDetail(detail, documentRecord = null, selectedVersion = null) {
|
|
447
|
+
clear(detailPane);
|
|
448
|
+
if (state.detailError) {
|
|
449
|
+
const retry = state.meetingId === null ? loadMeetings : () => loadDetail(state.meetingId);
|
|
450
|
+
appendLoadError(detailPane, state.detailError.startsWith('会議詳細を読み込めませんでした。')
|
|
451
|
+
? state.detailError
|
|
452
|
+
: `会議詳細を読み込めませんでした。理由: ${state.detailError}`, retry);
|
|
453
|
+
}
|
|
454
|
+
if (!detail || !isRecord(detail.meeting)) {
|
|
455
|
+
if (state.detailError) {
|
|
456
|
+
renderRail(null, null);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
detailPane.append(makeElement(doc, 'p', { className: 'bb-minutes-empty', textContent: '会議を選ぶと詳細を表示します。' }));
|
|
460
|
+
renderRail(null, null);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const meeting = detail.meeting;
|
|
464
|
+
const header = makeElement(doc, 'header', { className: 'bb-minutes-detail-head' });
|
|
465
|
+
header.append(makeElement(doc, 'h2', { textContent: text(meeting.title, '無題の会議') }));
|
|
466
|
+
header.append(makeElement(doc, 'p', { className: 'bb-minutes-item-meta', textContent: `${displayDate(meeting.scheduled_at)} / ${Array.isArray(meeting.participant_ids) && meeting.participant_ids.length ? meeting.participant_ids.join('、') : '参加者未登録'}` }));
|
|
467
|
+
detailPane.append(header);
|
|
468
|
+
const documents = Array.isArray(detail.minutes) ? detail.minutes : [];
|
|
469
|
+
if (documents.length === 0) detailPane.append(makeElement(doc, 'p', { className: 'bb-minutes-warning', textContent: '議事録文書を読み取れません。' }));
|
|
470
|
+
for (const documentRecordValue of documents) {
|
|
471
|
+
detailPane.append(renderVersion(detail, documentRecordValue, documentRecordValue.minutes_id === documentRecord?.minutes_id ? selectedVersion : null));
|
|
472
|
+
}
|
|
473
|
+
detailPane.append(renderCreateMinutesForm(detail));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function renderCreateMinutesForm(detail) {
|
|
477
|
+
const form = makeElement(doc, 'form', { className: 'bb-minutes-create-form' });
|
|
478
|
+
form.append(makeElement(doc, 'h3', { textContent: '議事録文書を追加' }));
|
|
479
|
+
const title = labelInput(doc, '議事録の名前', { name: 'title', value: '議事録' });
|
|
480
|
+
const body = labelInput(doc, '本文', { type: 'textarea', name: 'body', placeholder: '本文を入力' });
|
|
481
|
+
body.input.rows = 5;
|
|
482
|
+
const save = makeElement(doc, 'button', { className: 'bb-minutes-button', textContent: '議事録を追加', type: 'submit' });
|
|
483
|
+
form.append(title.wrapper, body.wrapper, save);
|
|
484
|
+
form.addEventListener('submit', async (event) => {
|
|
485
|
+
event.preventDefault();
|
|
486
|
+
save.disabled = true;
|
|
487
|
+
try {
|
|
488
|
+
await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes`, {
|
|
489
|
+
title: title.input.value,
|
|
490
|
+
body: body.input.value,
|
|
491
|
+
expected_revision: detail.snapshot_revision,
|
|
492
|
+
});
|
|
493
|
+
setStatus('議事録文書を追加しました。', 'success');
|
|
494
|
+
await loadMeetings();
|
|
495
|
+
await loadDetail(detail.meeting.meeting_id);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
save.disabled = false;
|
|
498
|
+
setStatus(error instanceof Error ? error.message : '議事録を追加できませんでした。', 'error');
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
return form;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function renderCreateForm() {
|
|
505
|
+
clear(detailPane);
|
|
506
|
+
const form = makeElement(doc, 'form', { className: 'bb-minutes-create-form' });
|
|
507
|
+
form.append(makeElement(doc, 'h2', { textContent: '会議を作成' }), makeElement(doc, 'p', { className: 'bb-minutes-item-meta', textContent: '外部サービスを接続しなくても、会議と議事録を保存できます。' }));
|
|
508
|
+
const title = labelInput(doc, '会議名', { name: 'title', placeholder: '例: 週次定例' });
|
|
509
|
+
const date = labelInput(doc, '日時(任意)', { type: 'datetime-local', name: 'scheduled_at' });
|
|
510
|
+
const participants = labelInput(doc, '参加者(任意・カンマ区切り)', { name: 'participant_ids' });
|
|
511
|
+
const minutesTitle = labelInput(doc, '最初の議事録名', { name: 'minutes_title', value: '議事録' });
|
|
512
|
+
const body = labelInput(doc, '本文(任意)', { type: 'textarea', name: 'initial_body', placeholder: '会議の本文を入力' });
|
|
513
|
+
body.input.rows = 8;
|
|
514
|
+
const save = makeElement(doc, 'button', { className: 'bb-minutes-button is-primary', textContent: '会議を保存', type: 'submit' });
|
|
515
|
+
form.append(title.wrapper, date.wrapper, participants.wrapper, minutesTitle.wrapper, body.wrapper, save);
|
|
516
|
+
form.addEventListener('submit', async (event) => {
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
save.disabled = true;
|
|
519
|
+
try {
|
|
520
|
+
const scheduledAt = date.input.value ? new Date(date.input.value).toISOString() : undefined;
|
|
521
|
+
const payload = {
|
|
522
|
+
title: title.input.value,
|
|
523
|
+
...(scheduledAt ? { scheduled_at: scheduledAt } : {}),
|
|
524
|
+
participant_ids: participants.input.value.split(',').map((value) => value.trim()).filter(Boolean),
|
|
525
|
+
minutes_title: minutesTitle.input.value,
|
|
526
|
+
initial_body: body.input.value,
|
|
527
|
+
};
|
|
528
|
+
const created = await mutate('', payload);
|
|
529
|
+
const id = created?.meeting?.meeting_id;
|
|
530
|
+
setStatus('会議を保存しました。', 'success');
|
|
531
|
+
await loadMeetings();
|
|
532
|
+
if (typeof id === 'string') await loadDetail(id);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
save.disabled = false;
|
|
535
|
+
setStatus(error instanceof Error ? error.message : '会議を保存できませんでした。', 'error');
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
detailPane.append(form);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function loadMeetings() {
|
|
542
|
+
state.loading = true;
|
|
543
|
+
setStatus('会議一覧を読み込んでいます。');
|
|
544
|
+
try {
|
|
545
|
+
const payload = await call('');
|
|
546
|
+
if (!isRecord(payload) || !Array.isArray(payload.meetings) || payload.absence_confirmed !== true) throw new Error('会議一覧の形式を確認できません。');
|
|
547
|
+
state.meetings = payload.meetings;
|
|
548
|
+
state.listError = null;
|
|
549
|
+
const selectedMeetingStillListed = state.meetingId !== null
|
|
550
|
+
&& state.meetings.some((entry) => entry?.meeting?.meeting_id === state.meetingId);
|
|
551
|
+
if (state.meetingId !== null && !selectedMeetingStillListed) {
|
|
552
|
+
state.detail = null;
|
|
553
|
+
state.meetingId = null;
|
|
554
|
+
state.minutesId = null;
|
|
555
|
+
state.versionId = null;
|
|
556
|
+
state.detailError = '会議詳細を読み込めませんでした。選択中の会議は現在の一覧に含まれません。アクセス権または絞り込み条件を確認して再試行してください。';
|
|
557
|
+
} else if (!state.detail && state.meetingId === null) {
|
|
558
|
+
const hadDetailError = Boolean(state.detailError);
|
|
559
|
+
state.detailError = null;
|
|
560
|
+
if (hadDetailError) renderDetail(null);
|
|
561
|
+
}
|
|
562
|
+
renderList();
|
|
563
|
+
if (state.detailError && state.detail === null) renderDetail(null);
|
|
564
|
+
setStatus(state.meetings.length ? `${state.meetings.length}件の会議を読み込みました。` : '保存された会議はありません。');
|
|
565
|
+
} catch (error) {
|
|
566
|
+
state.listError = loadErrorMessage(error, '会議一覧を読み込めませんでした。');
|
|
567
|
+
const preserve = preserveLoadedStateOnFailure(error);
|
|
568
|
+
const hadDetail = Boolean(state.detail) || state.meetingId !== null;
|
|
569
|
+
if (!preserve) {
|
|
570
|
+
state.meetings = [];
|
|
571
|
+
if (hadDetail) {
|
|
572
|
+
state.detail = null;
|
|
573
|
+
state.meetingId = null;
|
|
574
|
+
state.minutesId = null;
|
|
575
|
+
state.versionId = null;
|
|
576
|
+
state.detailError = `会議詳細を読み込めませんでした。会議一覧へのアクセスを確認できません。理由: ${state.listError}`;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
renderList();
|
|
580
|
+
if (!preserve && hadDetail) renderDetail(null);
|
|
581
|
+
setStatus(state.listError, 'error');
|
|
582
|
+
} finally {
|
|
583
|
+
state.loading = false;
|
|
584
|
+
}
|
|
585
|
+
return state.meetings;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function loadDetail(meetingId) {
|
|
589
|
+
state.meetingId = meetingId;
|
|
590
|
+
state.minutesId = null;
|
|
591
|
+
state.detailError = null;
|
|
592
|
+
renderList();
|
|
593
|
+
setStatus('会議詳細を読み込んでいます。');
|
|
594
|
+
try {
|
|
595
|
+
const detail = await call(`/${encodeURIComponent(meetingId)}`);
|
|
596
|
+
if (!isRecord(detail) || !isRecord(detail.meeting) || !Array.isArray(detail.minutes) || !Array.isArray(detail.versions)) throw new Error('会議詳細の形式を確認できません。');
|
|
597
|
+
state.detail = detail;
|
|
598
|
+
state.detailError = null;
|
|
599
|
+
renderDetail(detail);
|
|
600
|
+
setStatus('会議詳細を読み込みました。');
|
|
601
|
+
} catch (error) {
|
|
602
|
+
const preserved = preserveLoadedStateOnFailure(error) && state.detail?.meeting?.meeting_id === meetingId
|
|
603
|
+
? state.detail
|
|
604
|
+
: null;
|
|
605
|
+
state.detail = preserved;
|
|
606
|
+
state.detailError = loadErrorMessage(error, '会議詳細を読み込めませんでした。');
|
|
607
|
+
renderDetail(preserved);
|
|
608
|
+
setStatus(state.detailError, 'error');
|
|
609
|
+
}
|
|
610
|
+
return state.detail;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
void loadMeetings();
|
|
614
|
+
return {
|
|
615
|
+
contractVersion: MEETING_MINUTES_UI_CONTRACT_VERSION,
|
|
616
|
+
refresh: loadMeetings,
|
|
617
|
+
openMeeting: loadDetail,
|
|
618
|
+
state,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export default createMeetingMinutesUI;
|