@aiquants/daily-report 0.1.1
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/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/client.d.mts +449 -0
- package/dist/client.d.ts +449 -0
- package/dist/client.js +7 -0
- package/dist/client.js.map +1 -0
- package/dist/client.mjs +7 -0
- package/dist/client.mjs.map +1 -0
- package/dist/index.d.mts +42 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/dist/logger-D3krZrNK.d.mts +29 -0
- package/dist/logger-D3krZrNK.d.ts +29 -0
- package/dist/server.d.mts +1515 -0
- package/dist/server.d.ts +1515 -0
- package/dist/server.js +10 -0
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +10 -0
- package/dist/server.mjs.map +1 -0
- package/dist/sse-schema-CK7cUnEo.d.ts +1986 -0
- package/dist/sse-schema-yl5AaSsj.d.mts +1986 -0
- package/dist/types-CVhwLhSN.d.mts +76 -0
- package/dist/types-CVhwLhSN.d.ts +76 -0
- package/package.json +108 -0
- package/src/client/components/business-day-thumb-overlay.tsx +19 -0
- package/src/client/components/daily-report-comment-item.tsx +81 -0
- package/src/client/components/daily-report-comment-section.tsx +166 -0
- package/src/client/components/daily-report-detail-list.tsx +676 -0
- package/src/client/components/daily-report-edit-form.tsx +81 -0
- package/src/client/components/daily-report-list.tsx +1024 -0
- package/src/client/components/daily-report-page.tsx +147 -0
- package/src/client/components/daily-report-resolved-content.tsx +139 -0
- package/src/client/components/unread-indicator.tsx +13 -0
- package/src/client/config-context.tsx +129 -0
- package/src/client/contexts/daily-report-action-context.tsx +910 -0
- package/src/client/hooks/use-daily-report-comments.ts +73 -0
- package/src/client/hooks/use-daily-report-sse-connection.ts +86 -0
- package/src/client/hooks/use-daily-report.spec.ts +155 -0
- package/src/client/hooks/use-daily-report.ts +426 -0
- package/src/client/hooks/use-dynamic-viewport-height.ts +127 -0
- package/src/client/route-helpers.ts +76 -0
- package/src/client/ui/button.tsx +42 -0
- package/src/client/ui/cn.ts +14 -0
- package/src/client/ui/input.tsx +21 -0
- package/src/client/ui/label.tsx +16 -0
- package/src/client/ui/switch.tsx +19 -0
- package/src/client/ui/tabs.tsx +40 -0
- package/src/client/ui/textarea.tsx +19 -0
- package/src/client/utils/constants.ts +29 -0
- package/src/client.ts +21 -0
- package/src/index.ts +10 -0
- package/src/server/cache.ts +165 -0
- package/src/server/etag.ts +18 -0
- package/src/server/external-source.ts +60 -0
- package/src/server/handlers.ts +543 -0
- package/src/server/ports.ts +68 -0
- package/src/server/response.ts +37 -0
- package/src/server/schema.ts +266 -0
- package/src/server/service.spec.ts +97 -0
- package/src/server/service.ts +1308 -0
- package/src/server/sse-reader.spec.ts +55 -0
- package/src/server/sse-reader.ts +223 -0
- package/src/server.ts +83 -0
- package/src/shared/business-date.spec.ts +61 -0
- package/src/shared/business-date.ts +84 -0
- package/src/shared/comment-adapter.ts +78 -0
- package/src/shared/logger.ts +47 -0
- package/src/shared/sse-schema.ts +147 -0
- package/src/shared/text-utils.ts +57 -0
- package/src/shared/types.ts +76 -0
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action context providing optimistic CRUD + SSE synchronization for daily reports.
|
|
3
|
+
* 日報の楽観的 CRUD と SSE 同期を提供するアクションコンテキスト。
|
|
4
|
+
*/
|
|
5
|
+
import { type Context, createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react"
|
|
6
|
+
import { createLogger, LogLevel } from "../../shared/logger"
|
|
7
|
+
import type { DailyReportSseMessage } from "../../shared/sse-schema"
|
|
8
|
+
import type { DailyReportCommentItem, DailyReportDetail, DailyReportItem, DailyReportUser } from "../../shared/types"
|
|
9
|
+
import { useDailyReportConfig } from "../config-context"
|
|
10
|
+
import { acquireMutationLock, applyDailyReportServerUpdates, deleteDailyReportCache, getCachedReport, registerRecentDeletion, releaseMutationLock, subscribeCacheReady, updateDailyReportCache, writeCache } from "../hooks/use-daily-report"
|
|
11
|
+
import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
|
|
12
|
+
|
|
13
|
+
const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
|
|
14
|
+
|
|
15
|
+
type ActionResponse = {
|
|
16
|
+
status: string
|
|
17
|
+
reportHubId: number
|
|
18
|
+
newComment?: DailyReportCommentItem
|
|
19
|
+
updatedStatus?: { isStarred?: boolean; isRead?: boolean }
|
|
20
|
+
deletedCommentId?: string
|
|
21
|
+
updatedAt?: string // ISO string from server
|
|
22
|
+
report?: DailyReportDetail // Full report for creation/updates
|
|
23
|
+
clientTempId?: string // Validation token for optimistic updates
|
|
24
|
+
[key: string]: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type DailyReportActionContextType = {
|
|
28
|
+
/** 表示中ユーザー (楽観的コメントの作者名等に使用)。 */
|
|
29
|
+
user?: DailyReportUser
|
|
30
|
+
/** reportHubId -> Map<tempCommentId, content> (追加コメントの一時IDと内容) */
|
|
31
|
+
pendingAddCommentIds: React.MutableRefObject<Map<number, Map<number, string>>>
|
|
32
|
+
/** reportHubId -> Set<tempCommentId> (削除された一時ID) */
|
|
33
|
+
pendingDeleteCommentIds: React.MutableRefObject<Map<number, Set<number>>>
|
|
34
|
+
/** reportHubId -> boolean (スターの保留状態) */
|
|
35
|
+
pendingStarUpdates: React.MutableRefObject<Map<number, boolean>>
|
|
36
|
+
/** reportHubId -> boolean (既読の保留状態) */
|
|
37
|
+
pendingReadUpdates: React.MutableRefObject<Map<number, boolean>>
|
|
38
|
+
/** tempId -> realId (解決されたID) */
|
|
39
|
+
resolvedIdMap: React.MutableRefObject<Map<number, number>>
|
|
40
|
+
/** 状態変更通知用バージョン */
|
|
41
|
+
version: number
|
|
42
|
+
/** 日報リスト */
|
|
43
|
+
items: DailyReportItem[]
|
|
44
|
+
/** SSE購読が有効かどうか */
|
|
45
|
+
isSseEnabled: boolean
|
|
46
|
+
/** SSE購読の有効/無効を切り替える */
|
|
47
|
+
toggleSse: () => void
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* コメントを追加します。
|
|
51
|
+
* @param reportHubId 日報HubID
|
|
52
|
+
* @param content コメント内容
|
|
53
|
+
* @param businessDate 営業日
|
|
54
|
+
*/
|
|
55
|
+
addComment: (reportHubId: number, content: string, businessDate: string) => Promise<void>
|
|
56
|
+
/**
|
|
57
|
+
* コメントを削除します。
|
|
58
|
+
* @param reportHubId 日報HubID
|
|
59
|
+
* @param commentId コメントID (一時IDまたは実ID)
|
|
60
|
+
* @param businessDate 営業日
|
|
61
|
+
*/
|
|
62
|
+
deleteComment: (reportHubId: number, commentId: number, businessDate: string) => Promise<void>
|
|
63
|
+
/**
|
|
64
|
+
* スターの状態を切り替えます。
|
|
65
|
+
* @param reportHubId 日報HubID
|
|
66
|
+
* @param isStarred 新しいスター状態
|
|
67
|
+
* @param businessDate 営業日
|
|
68
|
+
*/
|
|
69
|
+
toggleStar: (reportHubId: number, isStarred: boolean, businessDate: string) => Promise<void>
|
|
70
|
+
/**
|
|
71
|
+
* 既読の状態を切り替えます。
|
|
72
|
+
* @param reportHubId 日報HubID
|
|
73
|
+
* @param isRead 新しい既読状態
|
|
74
|
+
* @param businessDate 営業日
|
|
75
|
+
*/
|
|
76
|
+
toggleRead: (reportHubId: number, isRead: boolean, businessDate: string) => Promise<void>
|
|
77
|
+
createReport: (businessDate: string) => Promise<number>
|
|
78
|
+
updateReport: (reportHubId: number, data: { title?: string; content?: string }, businessDate: string) => Promise<void>
|
|
79
|
+
publishReport: (reportHubId: number, businessDate: string) => Promise<void>
|
|
80
|
+
deleteReport: (reportHubId: number, businessDate: string) => Promise<void>
|
|
81
|
+
/** stale アイテムをリストから除去する安全弁 */
|
|
82
|
+
removeStaleItem: (reportHubId: number) => void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// HMR / dual-bundle (ESM+CJS) でもコンテキスト identity を一意に保つため、
|
|
86
|
+
// グローバルへ登録した単一インスタンスを常に使用する。
|
|
87
|
+
declare global {
|
|
88
|
+
var __DailyReportActionContext: Context<DailyReportActionContextType | null> | undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const DailyReportActionContext: Context<DailyReportActionContextType | null> = globalThis.__DailyReportActionContext ?? createContext<DailyReportActionContextType | null>(null)
|
|
92
|
+
globalThis.__DailyReportActionContext = DailyReportActionContext
|
|
93
|
+
|
|
94
|
+
export const useDailyReportActionContext = () => {
|
|
95
|
+
const context = useContext(DailyReportActionContext)
|
|
96
|
+
if (!context) {
|
|
97
|
+
throw new Error("useDailyReportActionContext must be used within a DailyReportActionProvider")
|
|
98
|
+
}
|
|
99
|
+
return context
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const getUserDisplayName = (user: DailyReportUser | undefined): string => {
|
|
103
|
+
if (!user) return "Unknown"
|
|
104
|
+
if (user.displayName?.trim()) return user.displayName
|
|
105
|
+
if (user.name) {
|
|
106
|
+
const fullName = `${user.name.familyName ?? ""} ${user.name.givenName ?? ""}`.trim()
|
|
107
|
+
if (fullName) return fullName
|
|
108
|
+
}
|
|
109
|
+
return user.emails?.[0]?.value ?? "Unknown"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Provides a context for daily report actions including comments, stars, leads, and basic CRUD operations.
|
|
114
|
+
* Also manages optimistic UI updates and real-time synchronization via SSE.
|
|
115
|
+
* 日報のアクション(コメント追加・削除、スター、既読)を管理するプロバイダー。
|
|
116
|
+
* 楽観的UI更新とAPI通信の抽象化を提供します。
|
|
117
|
+
*/
|
|
118
|
+
export const DailyReportActionProvider = ({ children, user, initialItems = [], userId }: { children: ReactNode; user?: DailyReportUser; initialItems?: DailyReportItem[]; userId?: string | null }) => {
|
|
119
|
+
const { apiBasePath, draftLabelName } = useDailyReportConfig()
|
|
120
|
+
const pendingAddCommentIds = useRef<Map<number, Map<number, string>>>(new Map())
|
|
121
|
+
const pendingDeleteCommentIds = useRef<Map<number, Set<number>>>(new Map())
|
|
122
|
+
const pendingStarUpdates = useRef<Map<number, boolean>>(new Map())
|
|
123
|
+
const pendingReadUpdates = useRef<Map<number, boolean>>(new Map())
|
|
124
|
+
const resolvedIdMap = useRef<Map<number, number>>(new Map())
|
|
125
|
+
const [version, setVersion] = useState(0)
|
|
126
|
+
const [items, setItems] = useState<DailyReportItem[]>(initialItems)
|
|
127
|
+
const [isSseEnabled, setIsSseEnabled] = useState(!!userId)
|
|
128
|
+
const pendingAddPromises = useRef<Map<number, Promise<number>>>(new Map())
|
|
129
|
+
const manuallyTrackingIds = useRef<Set<number>>(new Set())
|
|
130
|
+
/** 削除済み ID を記録し、マージロジックでの復活を防止する */
|
|
131
|
+
const removedIds = useRef<Set<number>>(new Set())
|
|
132
|
+
/** 自分が発行したアクションのID(SSEエコー無視用) */
|
|
133
|
+
const processedClientTempIds = useRef<Set<string>>(new Set())
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Queue for SSE messages that arrived before the target report was cached.
|
|
137
|
+
* キャッシュ未取得時に到着した SSE メッセージの待機キュー。
|
|
138
|
+
* key: reportHubId, value: { messages, unsubscribe, timer }
|
|
139
|
+
*/
|
|
140
|
+
const pendingSseQueue = useRef<
|
|
141
|
+
Map<
|
|
142
|
+
number,
|
|
143
|
+
{
|
|
144
|
+
messages: Exclude<DailyReportSseMessage, { type: "connected" }>[]
|
|
145
|
+
unsubscribe: () => void
|
|
146
|
+
timer: ReturnType<typeof setTimeout>
|
|
147
|
+
}
|
|
148
|
+
>
|
|
149
|
+
>(new Map())
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Ref to always access the latest handleSseMessage for queue replay.
|
|
153
|
+
* キューの再処理時に常に最新の handleSseMessage を参照するための ref。
|
|
154
|
+
*/
|
|
155
|
+
const handleSseMessageRef = useRef<(payload: Exclude<DailyReportSseMessage, { type: "connected" }>) => void>(() => {})
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Enqueue an SSE message for a report that isn't cached yet.
|
|
159
|
+
* Subscribes to cache-ready events and replays queued messages when the cache becomes available.
|
|
160
|
+
*
|
|
161
|
+
* キャッシュ未取得のレポートへの SSE メッセージをキューに追加する。
|
|
162
|
+
* キャッシュ書き込みを監視し、書き込まれたらキューのメッセージを再処理する。
|
|
163
|
+
*/
|
|
164
|
+
const enqueuePendingSseMessage = useCallback((reportHubId: number, message: Exclude<DailyReportSseMessage, { type: "connected" }>) => {
|
|
165
|
+
const queue = pendingSseQueue.current
|
|
166
|
+
const existing = queue.get(reportHubId)
|
|
167
|
+
|
|
168
|
+
if (existing) {
|
|
169
|
+
// 既存キューにメッセージを追加するだけ
|
|
170
|
+
existing.messages.push(message)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 新規キューを作成
|
|
175
|
+
const entry = {
|
|
176
|
+
messages: [message],
|
|
177
|
+
unsubscribe: () => {},
|
|
178
|
+
timer: setTimeout(() => {
|
|
179
|
+
// 30秒経過してもキャッシュが来なければキューを破棄
|
|
180
|
+
const e = queue.get(reportHubId)
|
|
181
|
+
if (e) {
|
|
182
|
+
e.unsubscribe()
|
|
183
|
+
queue.delete(reportHubId)
|
|
184
|
+
logger.warn(`[SSE Queue] Expired: reportHubId=${reportHubId}, dropped=${e.messages.length} messages`)
|
|
185
|
+
}
|
|
186
|
+
}, 30_000),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// キャッシュ書き込みを監視
|
|
190
|
+
entry.unsubscribe = subscribeCacheReady(reportHubId, () => {
|
|
191
|
+
const e = queue.get(reportHubId)
|
|
192
|
+
if (!e) return
|
|
193
|
+
|
|
194
|
+
// キャッシュが書き込まれたか確認
|
|
195
|
+
if (!getCachedReport(reportHubId, { ignoreExpiry: true })) return
|
|
196
|
+
|
|
197
|
+
// キューをクリーンアップ
|
|
198
|
+
e.unsubscribe()
|
|
199
|
+
clearTimeout(e.timer)
|
|
200
|
+
queue.delete(reportHubId)
|
|
201
|
+
|
|
202
|
+
// キューのメッセージを最新の handleSseMessage で再処理
|
|
203
|
+
logger.info(`[SSE Queue] Replaying ${e.messages.length} messages for reportHubId=${reportHubId}`)
|
|
204
|
+
for (const msg of e.messages) {
|
|
205
|
+
handleSseMessageRef.current(msg)
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
queue.set(reportHubId, entry)
|
|
210
|
+
}, [])
|
|
211
|
+
|
|
212
|
+
const addProcessedId = (id: string) => {
|
|
213
|
+
processedClientTempIds.current.add(id)
|
|
214
|
+
// Auto-remove after 30 seconds to prevent memory leaks
|
|
215
|
+
// 30秒後に自動削除(メモリリーク防止)
|
|
216
|
+
setTimeout(() => {
|
|
217
|
+
processedClientTempIds.current.delete(id)
|
|
218
|
+
}, 30000)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const isProcessedId = useCallback((id: string | undefined): boolean => {
|
|
222
|
+
return !!id && processedClientTempIds.current.has(id)
|
|
223
|
+
}, [])
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Upserts a report item into the list while keeping businessDate-desc order.
|
|
227
|
+
*
|
|
228
|
+
* レポートアイテムをリストへ upsert し、businessDate 降順を維持します。
|
|
229
|
+
*/
|
|
230
|
+
const upsertItem = useCallback((reportHubId: number, businessDate: string | null | undefined) => {
|
|
231
|
+
setItems((prev) => {
|
|
232
|
+
const nextBusinessDate = businessDate ?? prev.find((item) => item.reportHubId === reportHubId)?.businessDate ?? null
|
|
233
|
+
const exists = prev.some((item) => item.reportHubId === reportHubId)
|
|
234
|
+
const merged = exists ? prev.map((item) => (item.reportHubId === reportHubId ? { ...item, businessDate: nextBusinessDate } : item)) : [{ reportHubId, businessDate: nextBusinessDate }, ...prev]
|
|
235
|
+
|
|
236
|
+
return merged.sort((a, b) => {
|
|
237
|
+
const left = a.businessDate ?? ""
|
|
238
|
+
const right = b.businessDate ?? ""
|
|
239
|
+
return right.localeCompare(left)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
}, [])
|
|
243
|
+
|
|
244
|
+
// 未登録ユーザーは SSE を有効化できない
|
|
245
|
+
const toggleSse = () => {
|
|
246
|
+
if (!userId) return
|
|
247
|
+
setIsSseEnabled((prev) => !prev)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Remove a stale item from the list as a safety net.
|
|
252
|
+
* フェッチ後にレポートが見つからなかった場合の安全弁としてアイテムを除去する。
|
|
253
|
+
*/
|
|
254
|
+
const removeStaleItem = useCallback((reportHubId: number) => {
|
|
255
|
+
removedIds.current.add(reportHubId)
|
|
256
|
+
setItems((prev) => prev.filter((item) => item.reportHubId !== reportHubId))
|
|
257
|
+
}, [])
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Merge initialItems with prevItems instead of replacing.
|
|
261
|
+
* Items already in the list are kept even if temporarily absent from the API response.
|
|
262
|
+
* Removal is handled exclusively by the report-delete SSE handler.
|
|
263
|
+
*
|
|
264
|
+
* initialItems と prevItems をマージする(全置換ではない)。
|
|
265
|
+
* API レスポンスから一時的に欠落した ID も prevItems に残っていれば保持する。
|
|
266
|
+
* 削除は report-delete SSE ハンドラーでのみ行う。
|
|
267
|
+
*/
|
|
268
|
+
// initialItems と prevItems のマージ — 削除済み ID の復活を防止する
|
|
269
|
+
// 外部リソース同期: React Router loader の initialItems 更新に反応する
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
// initialItems に含まれない removedIds エントリは不要なのでクリーンアップ
|
|
272
|
+
const initialIds = new Set(initialItems.map((item) => item.reportHubId))
|
|
273
|
+
// Set のイテレーション中の delete は JS 仕様で安全 (visited は再訪問しない)
|
|
274
|
+
for (const id of removedIds.current) {
|
|
275
|
+
if (!initialIds.has(id)) {
|
|
276
|
+
removedIds.current.delete(id)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
setItems((prevItems) => {
|
|
281
|
+
// initialItems をベースにマップを構築(removedIds を除外)
|
|
282
|
+
const mergedMap = new Map<number, DailyReportItem>()
|
|
283
|
+
for (const item of initialItems) {
|
|
284
|
+
if (!removedIds.current.has(item.reportHubId)) {
|
|
285
|
+
mergedMap.set(item.reportHubId, item)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// prevItems にのみ存在する ID を保持(削除は report-delete SSE で行う)
|
|
290
|
+
for (const item of prevItems) {
|
|
291
|
+
if (!(mergedMap.has(item.reportHubId) || removedIds.current.has(item.reportHubId))) {
|
|
292
|
+
mergedMap.set(item.reportHubId, item)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// businessDate 降順でソート
|
|
297
|
+
return Array.from(mergedMap.values()).sort((a, b) => {
|
|
298
|
+
const dateA = a.businessDate ? new Date(a.businessDate).getTime() : 0
|
|
299
|
+
const dateB = b.businessDate ? new Date(b.businessDate).getTime() : 0
|
|
300
|
+
return dateB - dateA
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
}, [initialItems])
|
|
304
|
+
|
|
305
|
+
const handleSseMessage = useCallback(
|
|
306
|
+
(payload: Exclude<DailyReportSseMessage, { type: "connected" }>) => {
|
|
307
|
+
// Ignore echo of own actions
|
|
308
|
+
// 自分が発行したアクションのエコーは無視する
|
|
309
|
+
// clientTempId は connected 以外すべてのメッセージに存在する(型で保証済み)
|
|
310
|
+
if (isProcessedId(payload.clientTempId)) {
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
switch (payload.type) {
|
|
315
|
+
case "comment-add": {
|
|
316
|
+
const { comment, reportHubId } = payload
|
|
317
|
+
const cached = getCachedReport(reportHubId, { ignoreExpiry: true })
|
|
318
|
+
if (cached) {
|
|
319
|
+
// Check for duplicates
|
|
320
|
+
// 重複チェック
|
|
321
|
+
if (cached.commentItems.some((c) => c.id === comment.id)) return
|
|
322
|
+
|
|
323
|
+
// Check if this comment matches a pending optimistic comment
|
|
324
|
+
let isMyComment = false
|
|
325
|
+
const pendingMap = pendingAddCommentIds.current.get(reportHubId)
|
|
326
|
+
if (pendingMap) {
|
|
327
|
+
for (const [tempId, content] of pendingMap.entries()) {
|
|
328
|
+
if (content === comment.content) {
|
|
329
|
+
pendingMap.delete(tempId)
|
|
330
|
+
resolvedIdMap.current.set(tempId, comment.id)
|
|
331
|
+
isMyComment = true
|
|
332
|
+
break
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const newComment: DailyReportCommentItem = {
|
|
338
|
+
...comment,
|
|
339
|
+
userName: comment.userName || (isMyComment ? getUserDisplayName(user) : "Unknown"),
|
|
340
|
+
createdAt: comment.createdAt || new Date().toISOString(),
|
|
341
|
+
isMine: isMyComment, // Use the detected ownership
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const updatedReport = {
|
|
345
|
+
...cached,
|
|
346
|
+
commentItems: [...cached.commentItems, newComment].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
|
|
347
|
+
}
|
|
348
|
+
applyDailyReportServerUpdates(reportHubId, updatedReport)
|
|
349
|
+
// Update mutation time to prevent stale fetch overwrite
|
|
350
|
+
// 古いフェッチによる上書きを防ぐために変更時刻を更新
|
|
351
|
+
acquireMutationLock(reportHubId)
|
|
352
|
+
releaseMutationLock(reportHubId)
|
|
353
|
+
setVersion((v) => v + 1) // UI更新トリガー
|
|
354
|
+
} else {
|
|
355
|
+
// キャッシュ未取得 → フェッチ完了後にリプレイ
|
|
356
|
+
enqueuePendingSseMessage(reportHubId, payload)
|
|
357
|
+
}
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
case "comment-delete": {
|
|
361
|
+
const { commentId, reportHubId } = payload
|
|
362
|
+
const cached = getCachedReport(reportHubId, { ignoreExpiry: true })
|
|
363
|
+
if (cached) {
|
|
364
|
+
const updatedReport = {
|
|
365
|
+
...cached,
|
|
366
|
+
commentItems: cached.commentItems.filter((c) => c.id !== commentId),
|
|
367
|
+
}
|
|
368
|
+
applyDailyReportServerUpdates(reportHubId, updatedReport)
|
|
369
|
+
// Update mutation time to prevent stale fetch overwrite
|
|
370
|
+
// 古いフェッチによる上書きを防ぐために変更時刻を更新
|
|
371
|
+
acquireMutationLock(reportHubId)
|
|
372
|
+
releaseMutationLock(reportHubId)
|
|
373
|
+
setVersion((v) => v + 1)
|
|
374
|
+
} else {
|
|
375
|
+
enqueuePendingSseMessage(reportHubId, payload)
|
|
376
|
+
}
|
|
377
|
+
break
|
|
378
|
+
}
|
|
379
|
+
case "status-update": {
|
|
380
|
+
const { statusType, value, reportHubId } = payload
|
|
381
|
+
const cached = getCachedReport(reportHubId, { ignoreExpiry: true })
|
|
382
|
+
|
|
383
|
+
if (statusType === "star") {
|
|
384
|
+
if (pendingStarUpdates.current.get(reportHubId) === value) {
|
|
385
|
+
pendingStarUpdates.current.delete(reportHubId)
|
|
386
|
+
}
|
|
387
|
+
if (cached) {
|
|
388
|
+
const updatedReport = { ...cached, isStarred: value }
|
|
389
|
+
applyDailyReportServerUpdates(reportHubId, updatedReport)
|
|
390
|
+
setVersion((v) => v + 1)
|
|
391
|
+
} else {
|
|
392
|
+
enqueuePendingSseMessage(reportHubId, payload)
|
|
393
|
+
}
|
|
394
|
+
} else if (statusType === "read") {
|
|
395
|
+
if (pendingReadUpdates.current.get(reportHubId) === value) {
|
|
396
|
+
pendingReadUpdates.current.delete(reportHubId)
|
|
397
|
+
}
|
|
398
|
+
if (cached) {
|
|
399
|
+
const updatedReport = { ...cached, isRead: value }
|
|
400
|
+
applyDailyReportServerUpdates(reportHubId, updatedReport)
|
|
401
|
+
setVersion((v) => v + 1)
|
|
402
|
+
} else {
|
|
403
|
+
enqueuePendingSseMessage(reportHubId, payload)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
break
|
|
407
|
+
}
|
|
408
|
+
case "report-delete": {
|
|
409
|
+
const { reportHubId } = payload
|
|
410
|
+
manuallyTrackingIds.current.delete(reportHubId)
|
|
411
|
+
// マージロジックでの復活を防止するために削除済み ID を記録
|
|
412
|
+
removedIds.current.add(reportHubId)
|
|
413
|
+
|
|
414
|
+
// 削除されたレポートへの待機中 SSE メッセージを破棄
|
|
415
|
+
const pendingEntry = pendingSseQueue.current.get(reportHubId)
|
|
416
|
+
if (pendingEntry) {
|
|
417
|
+
pendingEntry.unsubscribe()
|
|
418
|
+
clearTimeout(pendingEntry.timer)
|
|
419
|
+
pendingSseQueue.current.delete(reportHubId)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const cached = getCachedReport(reportHubId, { ignoreExpiry: true })
|
|
423
|
+
if (cached?.date) {
|
|
424
|
+
deleteDailyReportCache(reportHubId, cached.date)
|
|
425
|
+
setVersion((v) => v + 1)
|
|
426
|
+
}
|
|
427
|
+
setItems((prev) => prev.filter((item) => item.reportHubId !== reportHubId))
|
|
428
|
+
break
|
|
429
|
+
}
|
|
430
|
+
case "report-create":
|
|
431
|
+
case "report-update":
|
|
432
|
+
case "report-publish": {
|
|
433
|
+
// report はスキーマで必須なのでバリデーション通過後は必ず存在する(if ガード不要)
|
|
434
|
+
const { report } = payload
|
|
435
|
+
applyDailyReportServerUpdates(report.reportHubId, report)
|
|
436
|
+
if (payload.type === "report-create" || payload.type === "report-publish") {
|
|
437
|
+
manuallyTrackingIds.current.add(report.reportHubId)
|
|
438
|
+
upsertItem(report.reportHubId, report.date)
|
|
439
|
+
}
|
|
440
|
+
setVersion((v) => v + 1)
|
|
441
|
+
break
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
[isProcessedId, upsertItem, user, enqueuePendingSseMessage],
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
// 常に最新の handleSseMessage をキューのリプレイから参照できるようにする
|
|
449
|
+
handleSseMessageRef.current = handleSseMessage
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Cleanup all pending SSE queues on unmount.
|
|
453
|
+
* アンマウント時に全キューをクリーンアップする。
|
|
454
|
+
*/
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
return () => {
|
|
457
|
+
for (const entry of pendingSseQueue.current.values()) {
|
|
458
|
+
entry.unsubscribe()
|
|
459
|
+
clearTimeout(entry.timer)
|
|
460
|
+
}
|
|
461
|
+
pendingSseQueue.current.clear()
|
|
462
|
+
}
|
|
463
|
+
}, [])
|
|
464
|
+
|
|
465
|
+
useDailyReportSseConnection({ isSseEnabled, onMessage: handleSseMessage })
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Common helper function to execute API actions.
|
|
469
|
+
* APIアクションを実行する共通ヘルパー関数。
|
|
470
|
+
*/
|
|
471
|
+
const executeAction = async <T,>(
|
|
472
|
+
reportHubId: number,
|
|
473
|
+
intent: string,
|
|
474
|
+
businessDate: string,
|
|
475
|
+
params: Record<string, string>,
|
|
476
|
+
callbacks: {
|
|
477
|
+
onOptimistic?: () => void
|
|
478
|
+
onSuccess?: (data: ActionResponse) => Promise<T> | T
|
|
479
|
+
onFailure?: () => void
|
|
480
|
+
},
|
|
481
|
+
options?: {
|
|
482
|
+
keepLock?: boolean
|
|
483
|
+
skipLock?: boolean
|
|
484
|
+
},
|
|
485
|
+
): Promise<T> => {
|
|
486
|
+
if (!options?.skipLock) acquireMutationLock(reportHubId)
|
|
487
|
+
callbacks.onOptimistic?.()
|
|
488
|
+
|
|
489
|
+
// Generate or use provided clientTempId
|
|
490
|
+
const clientTempId = params.clientTempId || crypto.randomUUID()
|
|
491
|
+
addProcessedId(clientTempId)
|
|
492
|
+
|
|
493
|
+
const formData = new FormData()
|
|
494
|
+
formData.append("intent", intent)
|
|
495
|
+
formData.append("reportHubId", String(reportHubId))
|
|
496
|
+
formData.append("businessDate", businessDate)
|
|
497
|
+
formData.append("operationTimestamp", String(Date.now()))
|
|
498
|
+
formData.append("clientTempId", clientTempId)
|
|
499
|
+
|
|
500
|
+
for (const [k, v] of Object.entries(params)) {
|
|
501
|
+
if (k !== "clientTempId") {
|
|
502
|
+
formData.append(k, v)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const res = await fetch(`${apiBasePath}/action`, {
|
|
508
|
+
method: "POST",
|
|
509
|
+
body: formData,
|
|
510
|
+
headers: { Accept: "application/json" },
|
|
511
|
+
})
|
|
512
|
+
if (!res.ok) {
|
|
513
|
+
const error = new Error(`Failed to ${intent}`)
|
|
514
|
+
Object.assign(error, { status: res.status })
|
|
515
|
+
throw error
|
|
516
|
+
}
|
|
517
|
+
const data = await res.json()
|
|
518
|
+
if (data.status !== "OK" || (intent !== "create" && String(data.reportHubId) !== String(reportHubId))) {
|
|
519
|
+
throw new Error(`Invalid response for ${intent}`)
|
|
520
|
+
}
|
|
521
|
+
if (typeof data.clientTempId !== "string" || data.clientTempId !== clientTempId) {
|
|
522
|
+
throw new Error(`Invalid clientTempId for ${intent}`)
|
|
523
|
+
}
|
|
524
|
+
return (await callbacks.onSuccess?.(data)) as T
|
|
525
|
+
} catch (e) {
|
|
526
|
+
logger.error(`${intent} failed:`, e)
|
|
527
|
+
callbacks.onFailure?.()
|
|
528
|
+
throw e
|
|
529
|
+
} finally {
|
|
530
|
+
if (!(options?.keepLock || options?.skipLock)) {
|
|
531
|
+
releaseMutationLock(reportHubId)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const addComment = async (reportHubId: number, content: string, businessDate: string) => {
|
|
537
|
+
const id = Number(reportHubId)
|
|
538
|
+
const tempId = -1 * Date.now()
|
|
539
|
+
const promise = executeAction(
|
|
540
|
+
id,
|
|
541
|
+
"addComment",
|
|
542
|
+
businessDate,
|
|
543
|
+
{ content, clientTempId: String(tempId) },
|
|
544
|
+
{
|
|
545
|
+
onOptimistic: () => {
|
|
546
|
+
if (!pendingAddCommentIds.current.has(id)) pendingAddCommentIds.current.set(id, new Map())
|
|
547
|
+
pendingAddCommentIds.current.get(id)?.set(tempId, content)
|
|
548
|
+
setVersion((v) => v + 1)
|
|
549
|
+
},
|
|
550
|
+
onSuccess: (data) => {
|
|
551
|
+
if (!data.newComment) throw new Error("No new comment returned")
|
|
552
|
+
const realId = data.newComment.id
|
|
553
|
+
resolvedIdMap.current.set(tempId, realId)
|
|
554
|
+
pendingAddCommentIds.current.get(id)?.delete(tempId)
|
|
555
|
+
|
|
556
|
+
const cachedReport = getCachedReport(id, { ignoreExpiry: true })
|
|
557
|
+
if (cachedReport && !cachedReport.commentItems.some((c) => c.id === realId)) {
|
|
558
|
+
updateDailyReportCache(id, {
|
|
559
|
+
commentItems: [...cachedReport.commentItems, { ...data.newComment, id: realId, isMine: true, userName: getUserDisplayName(user) }],
|
|
560
|
+
updatedAt: data.updatedAt,
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
return realId
|
|
564
|
+
},
|
|
565
|
+
onFailure: () => {
|
|
566
|
+
pendingAddCommentIds.current.get(id)?.delete(tempId)
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
{ skipLock: true },
|
|
570
|
+
).finally(() => pendingAddPromises.current.delete(tempId))
|
|
571
|
+
|
|
572
|
+
pendingAddPromises.current.set(tempId, promise)
|
|
573
|
+
await promise
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const deleteComment = async (reportHubId: number, commentId: number, businessDate: string) => {
|
|
577
|
+
const id = Number(reportHubId)
|
|
578
|
+
// 1. Optimistic UI Update
|
|
579
|
+
if (!pendingDeleteCommentIds.current.has(id)) pendingDeleteCommentIds.current.set(id, new Set())
|
|
580
|
+
pendingDeleteCommentIds.current.get(id)?.add(commentId)
|
|
581
|
+
setVersion((v) => v + 1)
|
|
582
|
+
|
|
583
|
+
// 2. Resolve ID
|
|
584
|
+
let realId = commentId
|
|
585
|
+
if (commentId < 0) {
|
|
586
|
+
const resolved = resolvedIdMap.current.get(commentId)
|
|
587
|
+
if (resolved !== undefined) {
|
|
588
|
+
realId = resolved
|
|
589
|
+
} else {
|
|
590
|
+
const promise = pendingAddPromises.current.get(commentId)
|
|
591
|
+
if (!promise) return // 既に削除済みかエラー
|
|
592
|
+
try {
|
|
593
|
+
realId = await promise
|
|
594
|
+
} catch {
|
|
595
|
+
return // 追加失敗時は削除不要
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// 3. Server Request
|
|
601
|
+
await executeAction(
|
|
602
|
+
id,
|
|
603
|
+
"deleteComment",
|
|
604
|
+
businessDate,
|
|
605
|
+
{ commentId: String(realId) },
|
|
606
|
+
{
|
|
607
|
+
onSuccess: (data) => {
|
|
608
|
+
const deletedId = typeof data.deletedCommentId === "string" ? Number(data.deletedCommentId) : realId
|
|
609
|
+
const cachedReport = getCachedReport(id, { ignoreExpiry: true })
|
|
610
|
+
if (cachedReport) {
|
|
611
|
+
updateDailyReportCache(id, {
|
|
612
|
+
commentItems: cachedReport.commentItems.filter((c) => c.id !== deletedId),
|
|
613
|
+
updatedAt: data.updatedAt,
|
|
614
|
+
})
|
|
615
|
+
registerRecentDeletion(deletedId)
|
|
616
|
+
}
|
|
617
|
+
pendingDeleteCommentIds.current.get(id)?.delete(commentId)
|
|
618
|
+
},
|
|
619
|
+
onFailure: () => {
|
|
620
|
+
pendingDeleteCommentIds.current.get(id)?.delete(commentId)
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
{ skipLock: true },
|
|
624
|
+
)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const toggleStar = async (reportHubId: number, isStarred: boolean, businessDate: string) => {
|
|
628
|
+
const id = Number(reportHubId)
|
|
629
|
+
await executeAction(
|
|
630
|
+
id,
|
|
631
|
+
"toggleStar",
|
|
632
|
+
businessDate,
|
|
633
|
+
{ isStarred: String(isStarred) },
|
|
634
|
+
{
|
|
635
|
+
onOptimistic: () => {
|
|
636
|
+
updateDailyReportCache(id, { isStarred })
|
|
637
|
+
pendingStarUpdates.current.set(id, isStarred)
|
|
638
|
+
setVersion((v) => v + 1)
|
|
639
|
+
},
|
|
640
|
+
onSuccess: (data) => {
|
|
641
|
+
if (data.updatedStatus && typeof data.updatedStatus.isStarred === "boolean") {
|
|
642
|
+
const cached = getCachedReport(id, { ignoreExpiry: true })
|
|
643
|
+
if (cached) {
|
|
644
|
+
updateDailyReportCache(id, { isStarred: data.updatedStatus.isStarred, updatedAt: data.updatedAt })
|
|
645
|
+
setVersion((v) => v + 1)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
pendingStarUpdates.current.delete(id)
|
|
649
|
+
},
|
|
650
|
+
onFailure: () => {
|
|
651
|
+
updateDailyReportCache(id, { isStarred: !isStarred })
|
|
652
|
+
pendingStarUpdates.current.delete(id)
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
{ skipLock: true },
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const toggleReadImpl = async (reportHubId: number, isRead: boolean, businessDate: string) => {
|
|
660
|
+
const id = Number(reportHubId)
|
|
661
|
+
await executeAction(
|
|
662
|
+
id,
|
|
663
|
+
"toggleRead",
|
|
664
|
+
businessDate,
|
|
665
|
+
{ isRead: String(isRead) },
|
|
666
|
+
{
|
|
667
|
+
onOptimistic: () => {
|
|
668
|
+
updateDailyReportCache(id, { isRead })
|
|
669
|
+
pendingReadUpdates.current.set(id, isRead)
|
|
670
|
+
setVersion((v) => v + 1)
|
|
671
|
+
},
|
|
672
|
+
onSuccess: (data) => {
|
|
673
|
+
if (data.updatedStatus && typeof data.updatedStatus.isRead === "boolean") {
|
|
674
|
+
const cached = getCachedReport(id, { ignoreExpiry: true })
|
|
675
|
+
if (cached) {
|
|
676
|
+
updateDailyReportCache(id, { isRead: data.updatedStatus.isRead, updatedAt: data.updatedAt })
|
|
677
|
+
setVersion((v) => v + 1)
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
pendingReadUpdates.current.delete(id)
|
|
681
|
+
},
|
|
682
|
+
onFailure: () => {
|
|
683
|
+
updateDailyReportCache(id, { isRead: !isRead })
|
|
684
|
+
pendingReadUpdates.current.delete(id)
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
{ skipLock: true },
|
|
688
|
+
)
|
|
689
|
+
}
|
|
690
|
+
// ref 経由で最新実装を参照し、安定した関数 identity を提供する
|
|
691
|
+
// (SSE イベントによるコンテキスト再レンダリングで auto-read タイマーがリセットされるのを防止)
|
|
692
|
+
const toggleReadRef = useRef(toggleReadImpl)
|
|
693
|
+
toggleReadRef.current = toggleReadImpl
|
|
694
|
+
const toggleRead = useCallback((reportHubId: number, isRead: boolean, businessDate: string) => toggleReadRef.current(reportHubId, isRead, businessDate), [])
|
|
695
|
+
|
|
696
|
+
const createReport = async (businessDate: string) => {
|
|
697
|
+
const tempId = -1 * Date.now()
|
|
698
|
+
// Optimistic UI update
|
|
699
|
+
const now = new Date().toISOString()
|
|
700
|
+
const dummyReport: DailyReportDetail = {
|
|
701
|
+
reportHubId: tempId,
|
|
702
|
+
date: businessDate,
|
|
703
|
+
createdAt: now,
|
|
704
|
+
author: getUserDisplayName(user),
|
|
705
|
+
userId: user?.id ?? "unknown",
|
|
706
|
+
employeeName: getUserDisplayName(user),
|
|
707
|
+
updatedBy: getUserDisplayName(user),
|
|
708
|
+
updatedAt: now,
|
|
709
|
+
category: null,
|
|
710
|
+
creationCategory: null,
|
|
711
|
+
visitTimeFrom: null,
|
|
712
|
+
visitTimeTo: null,
|
|
713
|
+
customerName: null,
|
|
714
|
+
interviewers: [],
|
|
715
|
+
subject: "",
|
|
716
|
+
content: "",
|
|
717
|
+
comments: [],
|
|
718
|
+
isRead: true,
|
|
719
|
+
isStarred: false,
|
|
720
|
+
labels: [{ id: 0, name: draftLabelName, color: "#9ca3af" }],
|
|
721
|
+
commentItems: [],
|
|
722
|
+
}
|
|
723
|
+
writeCache(dummyReport, Date.now() + 300000, false)
|
|
724
|
+
|
|
725
|
+
setItems((prev) => [
|
|
726
|
+
{
|
|
727
|
+
reportHubId: tempId,
|
|
728
|
+
businessDate: businessDate,
|
|
729
|
+
},
|
|
730
|
+
...prev,
|
|
731
|
+
])
|
|
732
|
+
|
|
733
|
+
try {
|
|
734
|
+
const res = await executeAction<ActionResponse>(
|
|
735
|
+
0,
|
|
736
|
+
"create",
|
|
737
|
+
businessDate,
|
|
738
|
+
{ clientTempId: String(tempId) },
|
|
739
|
+
{
|
|
740
|
+
onSuccess: (data) => data,
|
|
741
|
+
},
|
|
742
|
+
)
|
|
743
|
+
// Validate response mapping
|
|
744
|
+
if (res.clientTempId && res.clientTempId !== String(tempId)) {
|
|
745
|
+
logger.warn(`[createReport] ID mismatch. req=${tempId}, res=${res.clientTempId}`)
|
|
746
|
+
// Mismatch implies this response is not for this specific request. Throwing here triggers revert.
|
|
747
|
+
throw new Error("Optimistic ID mismatch")
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const newId = Number(res.reportHubId)
|
|
751
|
+
|
|
752
|
+
// newId を一時的にロックし、createReport 以前に開始された背景 prefetch の
|
|
753
|
+
// cacheMany による stale データ上書きを防止する
|
|
754
|
+
acquireMutationLock(newId)
|
|
755
|
+
|
|
756
|
+
// Seed cache with server response (checking staleness)
|
|
757
|
+
if (res.report) {
|
|
758
|
+
applyDailyReportServerUpdates(newId, res.report)
|
|
759
|
+
setVersion((v) => v + 1)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
setItems((prev) => {
|
|
763
|
+
// If newId is already present (e.g. from SSE), just remove tempId to avoid duplication
|
|
764
|
+
if (prev.some((item) => item.reportHubId === newId)) {
|
|
765
|
+
return prev.filter((item) => item.reportHubId !== tempId)
|
|
766
|
+
}
|
|
767
|
+
return prev.map((item) => (item.reportHubId === tempId ? { ...item, reportHubId: newId } : item))
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
// ロック解放: lastMutations に newId が記録され、以降の cacheMany でも保護される
|
|
771
|
+
setTimeout(() => releaseMutationLock(newId), 1000)
|
|
772
|
+
|
|
773
|
+
return newId
|
|
774
|
+
} catch (e) {
|
|
775
|
+
logger.error("[createReport] Failed or mismatched", e)
|
|
776
|
+
// Revert optimistic update
|
|
777
|
+
setItems((prev) => prev.filter((item) => item.reportHubId !== tempId))
|
|
778
|
+
throw e
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const updateReport = async (reportHubId: number, data: { title?: string; content?: string }, businessDate: string) => {
|
|
783
|
+
const id = Number(reportHubId)
|
|
784
|
+
await executeAction(
|
|
785
|
+
id,
|
|
786
|
+
"update",
|
|
787
|
+
businessDate,
|
|
788
|
+
{ ...(data.title && { title: data.title }), ...(data.content && { content: data.content }) },
|
|
789
|
+
{
|
|
790
|
+
onSuccess: () => {
|
|
791
|
+
const cached = getCachedReport(id, { ignoreExpiry: true })
|
|
792
|
+
if (cached) {
|
|
793
|
+
updateDailyReportCache(id, {
|
|
794
|
+
subject: data.title ?? cached.subject,
|
|
795
|
+
content: data.content ?? cached.content,
|
|
796
|
+
})
|
|
797
|
+
setVersion((v) => v + 1)
|
|
798
|
+
}
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const publishReport = async (reportHubId: number, businessDate: string) => {
|
|
805
|
+
const id = Number(reportHubId)
|
|
806
|
+
const cached = getCachedReport(id, { ignoreExpiry: true })
|
|
807
|
+
const oldLabels = cached?.labels || []
|
|
808
|
+
|
|
809
|
+
await executeAction(
|
|
810
|
+
id,
|
|
811
|
+
"publish",
|
|
812
|
+
businessDate,
|
|
813
|
+
{},
|
|
814
|
+
{
|
|
815
|
+
onOptimistic: () => {
|
|
816
|
+
if (cached) {
|
|
817
|
+
const newLabels = cached.labels.filter((l) => l.name !== draftLabelName)
|
|
818
|
+
updateDailyReportCache(id, {
|
|
819
|
+
labels: newLabels,
|
|
820
|
+
})
|
|
821
|
+
setVersion((v) => v + 1)
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
onSuccess: (data) => {
|
|
825
|
+
// サーバーからのレスポンスを正とする
|
|
826
|
+
if (data.report) {
|
|
827
|
+
applyDailyReportServerUpdates(id, data.report)
|
|
828
|
+
setVersion((v) => v + 1)
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
onFailure: () => {
|
|
832
|
+
// ロールバック
|
|
833
|
+
updateDailyReportCache(id, {
|
|
834
|
+
labels: oldLabels,
|
|
835
|
+
})
|
|
836
|
+
setVersion((v) => v + 1)
|
|
837
|
+
},
|
|
838
|
+
},
|
|
839
|
+
{ skipLock: true },
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const deleteReport = async (reportHubId: number, businessDate: string) => {
|
|
844
|
+
const id = Number(reportHubId)
|
|
845
|
+
const targetItem = items.find((i) => i.reportHubId === id)
|
|
846
|
+
|
|
847
|
+
// マージロジックでの復活を防止するために削除済み ID を記録
|
|
848
|
+
removedIds.current.add(id)
|
|
849
|
+
// Optimistic UI update
|
|
850
|
+
setItems((prev) => prev.filter((item) => item.reportHubId !== id))
|
|
851
|
+
|
|
852
|
+
try {
|
|
853
|
+
await executeAction(
|
|
854
|
+
id,
|
|
855
|
+
"delete",
|
|
856
|
+
businessDate,
|
|
857
|
+
{},
|
|
858
|
+
{
|
|
859
|
+
onSuccess: (_data) => {
|
|
860
|
+
deleteDailyReportCache(id, businessDate)
|
|
861
|
+
// Note: Item already removed from list optimistically
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
)
|
|
865
|
+
} catch (e: unknown) {
|
|
866
|
+
const error = e as { status?: number }
|
|
867
|
+
// 既に削除済み(404)の場合は、成功とみなしてロールバックしない(キャッシュはクリアする)
|
|
868
|
+
if (error.status === 404) {
|
|
869
|
+
deleteDailyReportCache(id, businessDate)
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// それ以外のエラーの場合は、楽観的更新をロールバックする
|
|
874
|
+
removedIds.current.delete(id)
|
|
875
|
+
if (targetItem) {
|
|
876
|
+
setItems((prev) => {
|
|
877
|
+
if (prev.some((i) => i.reportHubId === id)) return prev
|
|
878
|
+
return [targetItem, ...prev].sort((a, b) => (b.businessDate ?? "").localeCompare(a.businessDate ?? ""))
|
|
879
|
+
})
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return (
|
|
885
|
+
<DailyReportActionContext.Provider
|
|
886
|
+
value={{
|
|
887
|
+
user,
|
|
888
|
+
pendingAddCommentIds,
|
|
889
|
+
pendingDeleteCommentIds,
|
|
890
|
+
pendingStarUpdates,
|
|
891
|
+
pendingReadUpdates,
|
|
892
|
+
resolvedIdMap,
|
|
893
|
+
version,
|
|
894
|
+
items,
|
|
895
|
+
isSseEnabled,
|
|
896
|
+
toggleSse,
|
|
897
|
+
addComment,
|
|
898
|
+
deleteComment,
|
|
899
|
+
toggleStar,
|
|
900
|
+
toggleRead,
|
|
901
|
+
createReport,
|
|
902
|
+
updateReport,
|
|
903
|
+
publishReport,
|
|
904
|
+
deleteReport,
|
|
905
|
+
removeStaleItem,
|
|
906
|
+
}}>
|
|
907
|
+
{children}
|
|
908
|
+
</DailyReportActionContext.Provider>
|
|
909
|
+
)
|
|
910
|
+
}
|