@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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/dist/client.d.mts +449 -0
  4. package/dist/client.d.ts +449 -0
  5. package/dist/client.js +7 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/client.mjs +7 -0
  8. package/dist/client.mjs.map +1 -0
  9. package/dist/index.d.mts +42 -0
  10. package/dist/index.d.ts +42 -0
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/index.mjs +2 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/dist/logger-D3krZrNK.d.mts +29 -0
  16. package/dist/logger-D3krZrNK.d.ts +29 -0
  17. package/dist/server.d.mts +1515 -0
  18. package/dist/server.d.ts +1515 -0
  19. package/dist/server.js +10 -0
  20. package/dist/server.js.map +1 -0
  21. package/dist/server.mjs +10 -0
  22. package/dist/server.mjs.map +1 -0
  23. package/dist/sse-schema-CK7cUnEo.d.ts +1986 -0
  24. package/dist/sse-schema-yl5AaSsj.d.mts +1986 -0
  25. package/dist/types-CVhwLhSN.d.mts +76 -0
  26. package/dist/types-CVhwLhSN.d.ts +76 -0
  27. package/package.json +108 -0
  28. package/src/client/components/business-day-thumb-overlay.tsx +19 -0
  29. package/src/client/components/daily-report-comment-item.tsx +81 -0
  30. package/src/client/components/daily-report-comment-section.tsx +166 -0
  31. package/src/client/components/daily-report-detail-list.tsx +676 -0
  32. package/src/client/components/daily-report-edit-form.tsx +81 -0
  33. package/src/client/components/daily-report-list.tsx +1024 -0
  34. package/src/client/components/daily-report-page.tsx +147 -0
  35. package/src/client/components/daily-report-resolved-content.tsx +139 -0
  36. package/src/client/components/unread-indicator.tsx +13 -0
  37. package/src/client/config-context.tsx +129 -0
  38. package/src/client/contexts/daily-report-action-context.tsx +910 -0
  39. package/src/client/hooks/use-daily-report-comments.ts +73 -0
  40. package/src/client/hooks/use-daily-report-sse-connection.ts +86 -0
  41. package/src/client/hooks/use-daily-report.spec.ts +155 -0
  42. package/src/client/hooks/use-daily-report.ts +426 -0
  43. package/src/client/hooks/use-dynamic-viewport-height.ts +127 -0
  44. package/src/client/route-helpers.ts +76 -0
  45. package/src/client/ui/button.tsx +42 -0
  46. package/src/client/ui/cn.ts +14 -0
  47. package/src/client/ui/input.tsx +21 -0
  48. package/src/client/ui/label.tsx +16 -0
  49. package/src/client/ui/switch.tsx +19 -0
  50. package/src/client/ui/tabs.tsx +40 -0
  51. package/src/client/ui/textarea.tsx +19 -0
  52. package/src/client/utils/constants.ts +29 -0
  53. package/src/client.ts +21 -0
  54. package/src/index.ts +10 -0
  55. package/src/server/cache.ts +165 -0
  56. package/src/server/etag.ts +18 -0
  57. package/src/server/external-source.ts +60 -0
  58. package/src/server/handlers.ts +543 -0
  59. package/src/server/ports.ts +68 -0
  60. package/src/server/response.ts +37 -0
  61. package/src/server/schema.ts +266 -0
  62. package/src/server/service.spec.ts +97 -0
  63. package/src/server/service.ts +1308 -0
  64. package/src/server/sse-reader.spec.ts +55 -0
  65. package/src/server/sse-reader.ts +223 -0
  66. package/src/server.ts +83 -0
  67. package/src/shared/business-date.spec.ts +61 -0
  68. package/src/shared/business-date.ts +84 -0
  69. package/src/shared/comment-adapter.ts +78 -0
  70. package/src/shared/logger.ts +47 -0
  71. package/src/shared/sse-schema.ts +147 -0
  72. package/src/shared/text-utils.ts +57 -0
  73. package/src/shared/types.ts +76 -0
@@ -0,0 +1,1024 @@
1
+ /**
2
+ * Virtual-scrolling summary list for daily reports driven by fetcher-based detail hooks.
3
+ * useFetcher ベースの詳細取得フックで日報サマリーを仮想スクロール表示。
4
+ */
5
+ import { SwipeCloseGuardContext, useSwipeGuardState, useSwipeToDismissOverlay, useTapClick, useTransitionDisplay } from "@aiquants/swipe-overlay"
6
+ import { type ScrollBarThumbOverlayRenderProps, VirtualScroll, type VirtualScrollHandle } from "@aiquants/virtualscroll"
7
+ import { Check, Star, Trash2 } from "lucide-react"
8
+ import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"
9
+ import { fallbackText, formatToIsoDate } from "../../shared/text-utils"
10
+ import type { DailyReportDetail, DailyReportItem } from "../../shared/types"
11
+ import { useDailyReportConfig } from "../config-context"
12
+ import { useDailyReportActionContext } from "../contexts/daily-report-action-context"
13
+ import { useDailyReportDetail } from "../hooks/use-daily-report"
14
+ import { useDailyReportComments } from "../hooks/use-daily-report-comments"
15
+ import { useDynamicViewportHeight } from "../hooks/use-dynamic-viewport-height"
16
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
17
+ import {
18
+ DEFAULT_ITEM_HEIGHT,
19
+ EMPTY_DETAIL,
20
+ EMPTY_ITEM,
21
+ FAST_SCROLL_INERTIA_OPTIONS,
22
+ FAST_SCROLL_WHEEL_MULTIPLIER,
23
+ ITEM_CONTAINER_HEIGHT,
24
+ ITEM_VERTICAL_PADDING,
25
+ MAX_PREVIEW_LINES,
26
+ SCROLL_BAR_WIDTH,
27
+ TAP_SCROLL_CIRCLE_OPTIONS,
28
+ VIRTUAL_SCROLL_OVERSCAN_COUNT,
29
+ WHEEL_RESET_TIMEOUT_MS,
30
+ } from "../utils/constants"
31
+ import { BusinessDayThumbOverlay } from "./business-day-thumb-overlay"
32
+ import { DailyReportCommentForm, DailyReportCommentList } from "./daily-report-comment-section"
33
+ import { DailyReportEditForm } from "./daily-report-edit-form"
34
+ import { UnreadIndicator } from "./unread-indicator"
35
+
36
+ type TestableVirtualScrollHandle = VirtualScrollHandle & {
37
+ findReportIndex: (id: number) => number
38
+ getReportIds: (limit?: number) => number[]
39
+ }
40
+
41
+ type DailyReportListProps = {
42
+ dailyReportItems: DailyReportItem[]
43
+ initialSelectedBusinessDate?: string | null
44
+ initialSelectedReportHubId?: number | null
45
+ autoMarkRead?: boolean
46
+ selectedReportHubId: number | null
47
+ onSelectItem: (reportHubId: number | null) => void
48
+ userId?: string | null
49
+ }
50
+
51
+ /**
52
+ * Manages mobile overlay state, history navigation, and scroll locking.
53
+ * モバイルオーバーレイの状態、履歴ナビゲーション、スクロールロックを管理するフック。
54
+ */
55
+ const useMobileOverlayManager = (isMobileLayout: boolean, selectedReportHubId: number | null, dailyReportItems: DailyReportItem[], onSelectItem: (reportHubId: number | null) => void) => {
56
+ const [isMobileDetailOpen, setIsMobileDetailOpen] = useState(false)
57
+ const [isOverlayMounted, setIsOverlayMounted] = useState(false)
58
+ const overlayHistoryKeyRef = useRef<string | null>(null)
59
+ const lastClosedItemIdRef = useRef<number | null>(null)
60
+ const bodyOverflowRef = useRef<string | null>(null)
61
+
62
+ const selectedItem = useMemo(() => (selectedReportHubId === null ? null : (dailyReportItems.find((e) => e.reportHubId === selectedReportHubId) ?? null)), [dailyReportItems, selectedReportHubId])
63
+ const shouldShowOverlay = isMobileLayout && isMobileDetailOpen && selectedItem !== null
64
+
65
+ /**
66
+ * Manages the mounting state of the mobile overlay for animation purposes.
67
+ * アニメーション用にモバイルオーバーレイのマウント状態を管理する副作用。
68
+ *
69
+ * Purpose: To delay unmounting until the close animation completes.
70
+ * Dependencies: [shouldShowOverlay]
71
+ * Cleanup: Clears the timeout.
72
+ */
73
+ useEffect(() => {
74
+ if (shouldShowOverlay) {
75
+ setIsOverlayMounted(true)
76
+ } else {
77
+ const timer = setTimeout(() => setIsOverlayMounted(false), 300)
78
+ return () => clearTimeout(timer)
79
+ }
80
+ }, [shouldShowOverlay])
81
+
82
+ /**
83
+ * Manages mobile history state and body scroll locking.
84
+ * モバイル履歴状態と Body スクロールロックを管理する副作用。
85
+ *
86
+ * Purpose: To support browser back button for closing overlay and prevent background scrolling.
87
+ * Dependencies: [isMobileLayout, isMobileDetailOpen, selectedReportHubId]
88
+ * Cleanup: Restores body overflow style.
89
+ */
90
+ useEffect(() => {
91
+ if (typeof window === "undefined") return
92
+
93
+ // History Management
94
+ if (isMobileLayout && isMobileDetailOpen && selectedReportHubId !== null && !overlayHistoryKeyRef.current) {
95
+ const key = `overlay-${Date.now()}`
96
+ window.history.pushState({ ...window.history.state, dailyReportOverlay: key }, "", window.location.href)
97
+ overlayHistoryKeyRef.current = key
98
+ } else if (!(isMobileLayout && isMobileDetailOpen) && overlayHistoryKeyRef.current) {
99
+ overlayHistoryKeyRef.current = null
100
+ }
101
+
102
+ // Scroll Lock
103
+ if (isMobileDetailOpen) {
104
+ bodyOverflowRef.current = document.body.style.overflow
105
+ document.body.style.overflow = "hidden"
106
+ } else if (bodyOverflowRef.current !== null) {
107
+ document.body.style.overflow = bodyOverflowRef.current
108
+ bodyOverflowRef.current = null
109
+ }
110
+
111
+ return () => {
112
+ if (bodyOverflowRef.current !== null) document.body.style.overflow = bodyOverflowRef.current
113
+ }
114
+ }, [isMobileLayout, isMobileDetailOpen, selectedReportHubId])
115
+
116
+ /**
117
+ * Handles browser back navigation (popstate).
118
+ * ブラウザの戻る操作 (popstate) を処理する副作用。
119
+ *
120
+ * Purpose: To close the mobile overlay when the user presses the back button.
121
+ * Dependencies: [selectedReportHubId]
122
+ * Cleanup: Removes popstate listener.
123
+ */
124
+ useEffect(() => {
125
+ if (typeof window === "undefined") return
126
+ const onPopState = () => {
127
+ if (overlayHistoryKeyRef.current) {
128
+ lastClosedItemIdRef.current = selectedReportHubId
129
+ overlayHistoryKeyRef.current = null
130
+ setIsMobileDetailOpen(false)
131
+ onSelectItem(null)
132
+ }
133
+ }
134
+ window.addEventListener("popstate", onPopState)
135
+ return () => window.removeEventListener("popstate", onPopState)
136
+ }, [selectedReportHubId, onSelectItem])
137
+
138
+ const handleOverlayClose = useCallback(() => {
139
+ if (!isMobileLayout) return
140
+ lastClosedItemIdRef.current = selectedReportHubId
141
+ setIsMobileDetailOpen(false)
142
+ onSelectItem(null)
143
+ if (overlayHistoryKeyRef.current && typeof window !== "undefined") {
144
+ overlayHistoryKeyRef.current = null
145
+ window.history.back()
146
+ }
147
+ }, [isMobileLayout, selectedReportHubId, onSelectItem])
148
+
149
+ return {
150
+ isMobileDetailOpen,
151
+ setIsMobileDetailOpen,
152
+ isOverlayMounted,
153
+ shouldShowOverlay,
154
+ handleOverlayClose,
155
+ lastClosedItemIdRef,
156
+ selectedItem,
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Manages auto-read logic and read status toggling.
162
+ * 自動既読ロジックと既読状態の切り替えを管理するフック。
163
+ */
164
+ const useAutoRead = (report: DailyReportDetail | null, isLoading: boolean, autoMarkRead: boolean, isActive: boolean, toggleRead: (reportHubId: number, isRead: boolean, date: string) => Promise<void>) => {
165
+ const ignoreAutoReadForReportHubId = useRef<number | null>(null)
166
+ const lastReportHubIdRef = useRef<number | undefined>(undefined)
167
+
168
+ if (lastReportHubIdRef.current !== report?.reportHubId) {
169
+ lastReportHubIdRef.current = report?.reportHubId
170
+ ignoreAutoReadForReportHubId.current = null
171
+ }
172
+
173
+ /**
174
+ * Resets suppression flag when inactive.
175
+ * 非アクティブ時に抑制フラグをリセットする副作用。
176
+ *
177
+ * Purpose: To re-enable auto-read when the report is reopened.
178
+ * Dependencies: [isActive]
179
+ * Cleanup: None.
180
+ */
181
+ useEffect(() => {
182
+ if (!isActive) ignoreAutoReadForReportHubId.current = null
183
+ }, [isActive])
184
+
185
+ /**
186
+ * Handles the read status toggle logic.
187
+ * 既読状態の切り替え処理を行うコールバック。
188
+ *
189
+ * Purpose: To toggle read status and manage auto-read suppression.
190
+ * Dependencies: [report, toggleRead]
191
+ */
192
+ const handleToggleRead = useCallback(
193
+ async (isAuto: boolean = false) => {
194
+ if (!report) return
195
+ if (!isAuto) ignoreAutoReadForReportHubId.current = report.reportHubId
196
+
197
+ const nextValue = !report.isRead
198
+ try {
199
+ await toggleRead(report.reportHubId, nextValue, report.date ?? "")
200
+ } catch (_e) {
201
+ // Error handled in context
202
+ }
203
+ },
204
+ [report, toggleRead],
205
+ )
206
+
207
+ /**
208
+ * Automatically marks the report as read after a delay.
209
+ * 一定時間後にレポートを自動的に既読にする副作用。
210
+ *
211
+ * Purpose: To mark reports as read when viewed for a sufficient duration.
212
+ * Dependencies: [report, isLoading, autoMarkRead, handleToggleRead]
213
+ * Cleanup: Clears the timeout.
214
+ */
215
+ useEffect(() => {
216
+ if (autoMarkRead && report && !report.isRead && !isLoading) {
217
+ if (ignoreAutoReadForReportHubId.current === report.reportHubId) return
218
+ const timer = setTimeout(() => handleToggleRead(true), 500)
219
+ return () => clearTimeout(timer)
220
+ }
221
+ }, [report, isLoading, autoMarkRead, handleToggleRead])
222
+
223
+ return { handleToggleRead }
224
+ }
225
+
226
+ /**
227
+ * Daily report list cards with key metadata and preview content via virtual scrolling.
228
+ * 主なメタデータと内容プレビューを仮想スクロール表示する日報リスト。
229
+ */
230
+ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate = null, initialSelectedReportHubId = null, autoMarkRead = false, selectedReportHubId, onSelectItem, userId }: DailyReportListProps) => {
231
+ const scrollContainerRef = useRef<HTMLDivElement | null>(null)
232
+ const virtualScrollRef = useRef<VirtualScrollHandle>(null)
233
+ const { viewportHeight, updateViewportHeight } = useDynamicViewportHeight(scrollContainerRef)
234
+ const [overlayLabel, setOverlayLabel] = useState<string>("")
235
+ const [isWheelScrollActive, setIsWheelScrollActive] = useState(false)
236
+ const [isMobileLayout, setIsMobileLayout] = useState(false)
237
+ const wheelResetTimerRef = useRef<number | null>(null)
238
+ const appliedInitialSignatureRef = useRef<string | null>(null)
239
+ const itemCount = dailyReportItems.length
240
+ const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
241
+
242
+ const { isMobileDetailOpen, setIsMobileDetailOpen, isOverlayMounted, shouldShowOverlay, handleOverlayClose, lastClosedItemIdRef, selectedItem } = useMobileOverlayManager(isMobileLayout, selectedReportHubId, dailyReportItems, onSelectItem)
243
+
244
+ const columnHeightStyle = useMemo<CSSProperties | undefined>(() => (Number.isFinite(viewportHeight) && viewportHeight > 0 ? { height: viewportHeight } : undefined), [viewportHeight])
245
+
246
+ /**
247
+ * Manages global event listeners (Media Query, Wheel, Resize).
248
+ * メディアクエリ、ホイール、リサイズなどのグローバルイベントリスナーを管理する副作用。
249
+ *
250
+ * Purpose: To handle responsive layout changes and scroll interactions.
251
+ * Dependencies: [updateViewportHeight]
252
+ * Cleanup: Removes all event listeners and clears timers.
253
+ */
254
+ useEffect(() => {
255
+ if (typeof window === "undefined") return
256
+
257
+ const mq = window.matchMedia("(max-width: 639px)")
258
+ const onMediaChange = (e: MediaQueryListEvent) => setIsMobileLayout(e.matches)
259
+ setIsMobileLayout(mq.matches)
260
+ mq.addEventListener("change", onMediaChange)
261
+
262
+ const onResize = () => requestAnimationFrame(updateViewportHeight)
263
+ window.addEventListener("resize", onResize)
264
+ updateViewportHeight()
265
+
266
+ const el = scrollContainerRef.current
267
+ const onWheel = () => {
268
+ setIsWheelScrollActive(true)
269
+ if (wheelResetTimerRef.current) clearTimeout(wheelResetTimerRef.current)
270
+ wheelResetTimerRef.current = window.setTimeout(() => {
271
+ setIsWheelScrollActive(false)
272
+ wheelResetTimerRef.current = null
273
+ }, WHEEL_RESET_TIMEOUT_MS)
274
+ }
275
+ el?.addEventListener("wheel", onWheel, { passive: true })
276
+
277
+ return () => {
278
+ mq.removeEventListener("change", onMediaChange)
279
+ window.removeEventListener("resize", onResize)
280
+ el?.removeEventListener("wheel", onWheel)
281
+ if (wheelResetTimerRef.current) clearTimeout(wheelResetTimerRef.current)
282
+ }
283
+ }, [updateViewportHeight])
284
+
285
+ /**
286
+ * Synchronizes initial selection from props when data changes.
287
+ * データ変更時にプロパティからの初期選択状態を同期する副作用。
288
+ *
289
+ * Purpose: To respect deep-linked selection (ID or Date) when items are loaded.
290
+ * Dependencies: [dailyReportItems, initialSelectedBusinessDate, initialSelectedReportHubId, selectedReportHubId, itemCount, onSelectItem]
291
+ * Cleanup: None.
292
+ */
293
+ useEffect(() => {
294
+ if (!itemCount) {
295
+ appliedInitialSignatureRef.current = null
296
+ if (selectedReportHubId !== null) onSelectItem(null)
297
+ return
298
+ }
299
+
300
+ const signature = `${initialSelectedReportHubId ?? ""}|${initialSelectedBusinessDate ?? ""}`
301
+ const hasInitial = (initialSelectedReportHubId ?? null) !== null || (initialSelectedBusinessDate ?? null) !== null
302
+
303
+ if (!hasInitial) appliedInitialSignatureRef.current = null
304
+
305
+ if (hasInitial && appliedInitialSignatureRef.current !== signature) {
306
+ const match = (initialSelectedReportHubId && dailyReportItems.find((i) => i.reportHubId === initialSelectedReportHubId)) || (initialSelectedBusinessDate && dailyReportItems.find((i) => i.businessDate === initialSelectedBusinessDate))
307
+ if (match) {
308
+ appliedInitialSignatureRef.current = signature
309
+ if (selectedReportHubId !== match.reportHubId) onSelectItem(match.reportHubId)
310
+ return
311
+ }
312
+ }
313
+ }, [dailyReportItems, initialSelectedBusinessDate, initialSelectedReportHubId, selectedReportHubId, itemCount, onSelectItem])
314
+
315
+ /**
316
+ * Restores focus after closing overlay (Desktop only).
317
+ * オーバーレイを閉じた後のフォーカス復帰 (デスクトップのみ) を行う副作用。
318
+ *
319
+ * Purpose: To maintain keyboard focus context for accessibility.
320
+ * Dependencies: [isMobileDetailOpen, isMobileLayout, dailyReportItems]
321
+ * Cleanup: None.
322
+ */
323
+ useEffect(() => {
324
+ if (isMobileLayout || isMobileDetailOpen || lastClosedItemIdRef.current === null) return
325
+ const targetId = lastClosedItemIdRef.current
326
+ lastClosedItemIdRef.current = null
327
+
328
+ const index = dailyReportItems.findIndex((item) => item.reportHubId === targetId)
329
+ if (index >= 0) {
330
+ requestAnimationFrame(() => virtualScrollRef.current?.focusItemAtIndex(index))
331
+ }
332
+ }, [isMobileDetailOpen, isMobileLayout, dailyReportItems, lastClosedItemIdRef])
333
+
334
+ /**
335
+ * Syncs overlay label with the first item's date.
336
+ * オーバーレイラベルを先頭アイテムの日付と同期する副作用。
337
+ *
338
+ * Purpose: To provide a default label for the scroll thumb overlay.
339
+ * Dependencies: [dailyReportItems, itemCount]
340
+ * Cleanup: None.
341
+ */
342
+ useEffect(() => {
343
+ if (itemCount > 0)
344
+ setOverlayLabel((prev) => {
345
+ const next = formatToIsoDate(dailyReportItems[0]?.businessDate)
346
+ return prev === next ? prev : next
347
+ })
348
+ }, [dailyReportItems, itemCount])
349
+
350
+ const dailyReportItemsRef = useRef(dailyReportItems)
351
+ dailyReportItemsRef.current = dailyReportItems
352
+
353
+ useEffect(() => {
354
+ if (virtualScrollRef.current) {
355
+ const el = document.querySelector(".test-daily-report-list") as (Element & { __virtualScroll?: TestableVirtualScrollHandle }) | null
356
+ if (el) {
357
+ el.__virtualScroll = {
358
+ ...virtualScrollRef.current,
359
+ findReportIndex: (id: number) => dailyReportItemsRef.current.findIndex((item) => item.reportHubId === id),
360
+ getReportIds: (limit = 20) => dailyReportItemsRef.current.slice(0, limit).map((item) => item.reportHubId),
361
+ }
362
+ }
363
+ }
364
+ })
365
+
366
+ const handleItemSelect = useCallback(
367
+ (id: number) => {
368
+ if (id <= 0) return
369
+ onSelectItem(id)
370
+ if (isMobileLayout) setIsMobileDetailOpen(true)
371
+ },
372
+ [isMobileLayout, onSelectItem, setIsMobileDetailOpen],
373
+ )
374
+
375
+ const getItem = useCallback((index: number) => (index < itemCount ? dailyReportItems[index] : EMPTY_ITEM), [dailyReportItems, itemCount])
376
+ const getItemHeight = useCallback(() => DEFAULT_ITEM_HEIGHT, [])
377
+
378
+ const handleRangeChange = useCallback(
379
+ (range: { visibleStartIndex: number }) => {
380
+ const item = getItem(range.visibleStartIndex)
381
+ if (item?.reportHubId > 0)
382
+ setOverlayLabel((prev) => {
383
+ const next = formatToIsoDate(item.businessDate)
384
+ return prev === next ? prev : next
385
+ })
386
+ },
387
+ [getItem],
388
+ )
389
+
390
+ const handleItemFocus = useCallback(
391
+ (index: number) => {
392
+ const item = getItem(index)
393
+ if (item && item.reportHubId > 0) {
394
+ if (item.reportHubId === selectedReportHubId) return
395
+ onSelectItem(item.reportHubId)
396
+ }
397
+ },
398
+ [getItem, onSelectItem, selectedReportHubId],
399
+ )
400
+
401
+ const handleScroll = useCallback(() => {
402
+ const container = scrollContainerRef.current
403
+ if (!container) return
404
+
405
+ if (!container.classList.contains("is-scrolling")) {
406
+ container.classList.add("is-scrolling")
407
+ }
408
+
409
+ if (scrollEndTimerRef.current) {
410
+ clearTimeout(scrollEndTimerRef.current)
411
+ }
412
+
413
+ scrollEndTimerRef.current = setTimeout(() => {
414
+ if (container) {
415
+ container.classList.remove("is-scrolling")
416
+ }
417
+ scrollEndTimerRef.current = null
418
+ }, 150)
419
+ }, [])
420
+
421
+ const renderThumbOverlay = useCallback((props: ScrollBarThumbOverlayRenderProps) => <BusinessDayThumbOverlay {...props} label={overlayLabel} isWheelScrollActive={isWheelScrollActive} />, [overlayLabel, isWheelScrollActive])
422
+
423
+ return (
424
+ <>
425
+ <div className="flex h-full min-h-0 gap-4">
426
+ <div ref={scrollContainerRef} className="flex min-h-0 w-full sm:w-90" style={columnHeightStyle}>
427
+ <VirtualScroll
428
+ ref={virtualScrollRef}
429
+ itemCount={itemCount}
430
+ getItem={getItem}
431
+ getItemHeight={getItemHeight}
432
+ viewportSize={viewportHeight}
433
+ overscanCount={VIRTUAL_SCROLL_OVERSCAN_COUNT}
434
+ scrollBarOptions={{
435
+ width: SCROLL_BAR_WIDTH,
436
+ tapScrollCircleOptions: TAP_SCROLL_CIRCLE_OPTIONS,
437
+ renderThumbOverlay: renderThumbOverlay,
438
+ }}
439
+ behaviorOptions={{
440
+ inertiaOptions: FAST_SCROLL_INERTIA_OPTIONS,
441
+ wheelSpeedMultiplier: FAST_SCROLL_WHEEL_MULTIPLIER,
442
+ }}
443
+ onRangeChange={handleRangeChange}
444
+ onScroll={handleScroll}
445
+ onItemFocus={handleItemFocus}
446
+ contentInsets={{ top: ITEM_VERTICAL_PADDING, bottom: ITEM_VERTICAL_PADDING }}
447
+ className="test-daily-report-list relative w-full">
448
+ {(item) => <ListReportItem key={item.reportHubId} item={item} isActive={item.reportHubId === selectedReportHubId} onSelect={handleItemSelect} />}
449
+ </VirtualScroll>
450
+ </div>
451
+ <DailyReportSidePane dailyReportItems={dailyReportItems} selectedItem={selectedItem} heightStyle={columnHeightStyle} autoMarkRead={!isMobileLayout && autoMarkRead} isActive={selectedReportHubId !== null} userId={userId} />
452
+ </div>
453
+ {itemCount === 0 && (
454
+ <div className="py-8 text-center">
455
+ <p className="text-slate-500">データがありません</p>
456
+ </div>
457
+ )}
458
+ {isOverlayMounted && <DailyReportMobileOverlay isOpen={shouldShowOverlay} selectedItem={selectedItem} onClose={handleOverlayClose} autoMarkRead={isMobileLayout && autoMarkRead} userId={userId} />}
459
+ </>
460
+ )
461
+ }
462
+
463
+ /**
464
+ * Loading skeleton representation for daily report list items.
465
+ * 日報リストアイテム用読み込み中スケルトン表示。
466
+ */
467
+ const ListSkeletonItem = () => (
468
+ <div className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
469
+ <div className="flex h-full animate-pulse flex-col justify-between rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
470
+ <div className="mb-3 flex flex-wrap items-center gap-3">
471
+ <div className="h-4 w-24 rounded bg-slate-200"></div>
472
+ <div className="h-4 w-24 rounded bg-slate-200"></div>
473
+ <div className="h-4 w-24 rounded bg-slate-200"></div>
474
+ </div>
475
+ <div className="space-y-2">
476
+ <div className="h-4 w-full rounded bg-slate-200"></div>
477
+ <div className="h-4 w-11/12 rounded bg-slate-200"></div>
478
+ <div className="h-4 w-5/6 rounded bg-slate-200"></div>
479
+ </div>
480
+ </div>
481
+ </div>
482
+ )
483
+
484
+ type DailyReportSidePaneTab = "article" | "relations"
485
+
486
+ /**
487
+ * Side pane that exposes quick tabs for the selected report summary, editing, and correlation views.
488
+ * 選択した日報の概要表示・編集・相関ビューを切り替え表示するサイドペイン。
489
+ */
490
+ const DailyReportSidePane = ({
491
+ dailyReportItems,
492
+ selectedItem,
493
+ heightStyle,
494
+ autoMarkRead = false,
495
+ isActive = true,
496
+ userId,
497
+ }: {
498
+ dailyReportItems?: DailyReportItem[] // Optional for mobile overlay usage
499
+ selectedItem: DailyReportItem | null
500
+ heightStyle?: CSSProperties
501
+ autoMarkRead?: boolean
502
+ isActive?: boolean
503
+ userId?: string | null
504
+ }) => {
505
+ const [activeTab, setActiveTab] = useState<DailyReportSidePaneTab>("article")
506
+ const [isEditing, setIsEditing] = useState(false)
507
+ const { draftLabelName, fieldLabels } = useDailyReportConfig()
508
+ const { report, error, isLoading } = useDailyReportDetail(selectedItem?.reportHubId ?? -1, selectedItem?.businessDate ?? null)
509
+
510
+ const scrollContainerRef = useRef<HTMLDivElement>(null)
511
+ const lastReportIdRef = useRef<number | null>(null)
512
+ // 自動編集モード (ドラフト検出による自動遷移) かどうかを追跡
513
+ const autoEditRef = useRef(false)
514
+
515
+ // レポート選択時のエディタモード自動制御
516
+ // - 新しいレポート選択: ドラフトならエディタモード、公開済みならビューモード
517
+ // - 同一レポートの SSE 更新: ドラフト→公開遷移時に自動編集のみ解除
518
+ useEffect(() => {
519
+ if (!report) return
520
+
521
+ const isDraft = report.labels.some((l) => l.name === draftLabelName)
522
+
523
+ if (report.reportHubId !== lastReportIdRef.current) {
524
+ // 選択レポートが変更された
525
+ lastReportIdRef.current = report.reportHubId
526
+ if (isDraft) {
527
+ setIsEditing(true)
528
+ autoEditRef.current = true
529
+ } else {
530
+ setIsEditing(false)
531
+ autoEditRef.current = false
532
+ }
533
+ } else if (!isDraft && isEditing && autoEditRef.current) {
534
+ // SSE で同一レポートが下書き→公開に遷移: 自動編集モードのみ解除
535
+ // ユーザーがダブルクリックで手動編集中の場合は解除しない
536
+ setIsEditing(false)
537
+ autoEditRef.current = false
538
+ }
539
+ }, [report, isEditing, draftLabelName])
540
+
541
+ const isMine = !!(userId && report?.userId === userId)
542
+
543
+ const scrollToBottom = useCallback(() => {
544
+ setTimeout(() => {
545
+ if (scrollContainerRef.current) {
546
+ scrollContainerRef.current.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" })
547
+ }
548
+ }, 100)
549
+ }, [])
550
+
551
+ if (dailyReportItems && (dailyReportItems.length === 0 || !selectedItem)) {
552
+ return (
553
+ <aside className="hidden min-h-0 min-w-0 flex-1 gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:block dark:border-slate-700 dark:bg-slate-900/70" style={heightStyle}>
554
+ <div className="flex flex-1 items-center justify-center rounded-xl border border-slate-300 border-dashed bg-slate-50 p-4 text-center text-slate-500 text-sm dark:border-slate-600 dark:bg-slate-900/40 dark:text-slate-300">
555
+ <p>左側の一覧から日報を選択してください。</p>
556
+ </div>
557
+ </aside>
558
+ )
559
+ }
560
+
561
+ const headerDate = formatToIsoDate(report?.date ?? selectedItem?.businessDate)
562
+ const headerCreator = report?.employeeName || report?.author ? fallbackText(report.employeeName ?? report.author) : ""
563
+ const showHeaderSkeleton = isLoading || !report
564
+
565
+ const content = (
566
+ <>
567
+ <div className="space-y-1">
568
+ <div className="font-semibold text-slate-500 text-xs uppercase tracking-wide dark:text-slate-300">{fieldLabels.businessDate}</div>
569
+ <div className="text-slate-900 text-sm dark:text-slate-100">{headerDate}</div>
570
+ <div className="flex min-h-4 items-center gap-1 text-slate-500 text-xs dark:text-slate-300">
571
+ <span>{fieldLabels.author}:</span>
572
+ {showHeaderSkeleton ? <div className="h-3 w-24 animate-pulse rounded bg-slate-200 dark:bg-slate-700" /> : <span>{headerCreator}</span>}
573
+ </div>
574
+ </div>
575
+ <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as DailyReportSidePaneTab)} className="flex min-h-0 flex-1 flex-col">
576
+ <TabsList className="grid grid-cols-2 gap-1 rounded-md bg-slate-100 p-1 text-xs dark:bg-slate-800">
577
+ {["article", "relations"].map((tab) => (
578
+ <TabsTrigger
579
+ key={tab}
580
+ value={tab}
581
+ className="rounded-md px-2 py-1 font-medium text-slate-600 transition-colors data-[state=active]:bg-white data-[state=active]:text-slate-900 data-[state=active]:shadow-sm dark:text-slate-200 dark:data-[state=active]:bg-slate-900/70 dark:data-[state=active]:text-slate-100">
582
+ {tab === "article" ? fieldLabels.tabArticle : fieldLabels.tabRelations}
583
+ </TabsTrigger>
584
+ ))}
585
+ </TabsList>
586
+ <TabsContent ref={scrollContainerRef} value="article" className="scrollbar-thin flex-1 touch-pan-y overflow-y-auto overflow-x-hidden pt-3 text-sm">
587
+ {isEditing ? (
588
+ report ? (
589
+ <DailyReportEditForm report={report} onCancel={() => setIsEditing(false)} onPublishSuccess={() => setIsEditing(false)} />
590
+ ) : (
591
+ <p className="rounded-lg border border-slate-300 border-dashed bg-slate-50 p-4 text-slate-600 dark:border-slate-600 dark:bg-slate-900/40 dark:text-slate-300">読み込み中...</p>
592
+ )
593
+ ) : (
594
+ selectedItem && (
595
+ <DailyReportDisplayPane
596
+ report={report}
597
+ isLoading={isLoading}
598
+ error={error}
599
+ selectedItem={selectedItem}
600
+ autoMarkRead={autoMarkRead}
601
+ isActive={isActive}
602
+ onCommentAdded={scrollToBottom}
603
+ userId={userId}
604
+ onDoubleClick={isMine ? () => setIsEditing(true) : undefined}
605
+ />
606
+ )
607
+ )}
608
+ </TabsContent>
609
+ <TabsContent value="relations" className="scrollbar-thin flex-1 touch-pan-y overflow-y-auto pt-3 text-sm">
610
+ <p className="rounded-lg border border-slate-300 border-dashed bg-slate-50 p-4 text-slate-600 dark:border-slate-600 dark:bg-slate-900/40 dark:text-slate-300">{fieldLabels.relationsEmpty}</p>
611
+ </TabsContent>
612
+ </Tabs>
613
+ </>
614
+ )
615
+
616
+ // If rendered as a standalone pane (desktop)
617
+ if (dailyReportItems) {
618
+ return (
619
+ <aside data-report-id={selectedItem?.reportHubId} className="test-daily-report-side-pane hidden min-h-0 min-w-0 flex-1 flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:flex dark:border-slate-700 dark:bg-slate-900/70" style={heightStyle}>
620
+ {content}
621
+ </aside>
622
+ )
623
+ }
624
+ // If rendered inside mobile overlay
625
+ return (
626
+ <div data-report-id={selectedItem?.reportHubId} className="test-daily-report-side-pane flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
627
+ {content}
628
+ </div>
629
+ )
630
+ }
631
+
632
+ /**
633
+ * Shows the selected report details in the display tab, including content and metadata.
634
+ * 表示タブ内で選択した日報の詳細とメタデータを表示するコンポーネント。
635
+ */
636
+ const DailyReportDisplayPane = ({
637
+ report,
638
+ isLoading,
639
+ error,
640
+ autoMarkRead = false,
641
+ isActive = true,
642
+ onCommentAdded,
643
+ userId,
644
+ onDoubleClick,
645
+ }: {
646
+ report: DailyReportDetail | null
647
+ isLoading: boolean
648
+ error: Error | null
649
+ selectedItem: DailyReportItem
650
+ autoMarkRead?: boolean
651
+ isActive?: boolean
652
+ onCommentAdded?: () => void
653
+ userId?: string | null
654
+ onDoubleClick?: () => void
655
+ }) => {
656
+ // 表示中ユーザーはアクションコンテキストから取得する (root loader 非依存)
657
+ const { toggleStar, toggleRead, deleteReport, user } = useDailyReportActionContext()
658
+ const { fieldLabels } = useDailyReportConfig()
659
+
660
+ // Use custom hook for comments
661
+ const { uiComments, handleAddComment, handleDeleteComment, pendingDeleteId, setPendingDeleteId, resolvedIdMap } = useDailyReportComments(report, user, userId)
662
+ const { handleToggleRead } = useAutoRead(report, isLoading, autoMarkRead, isActive, toggleRead)
663
+
664
+ // Auto-scroll to bottom when comments are added (e.g. via SSE)
665
+ const prevReportHubIdRef = useRef<number | null>(null)
666
+ const prevCommentsLengthRef = useRef(uiComments.length)
667
+
668
+ useEffect(() => {
669
+ const currentReportHubId = report?.reportHubId ?? null
670
+
671
+ // Reset tracking when switching reports
672
+ if (currentReportHubId !== prevReportHubIdRef.current) {
673
+ prevReportHubIdRef.current = currentReportHubId
674
+ prevCommentsLengthRef.current = uiComments.length
675
+ return
676
+ }
677
+
678
+ // Scroll if comments increased
679
+ if (uiComments.length > prevCommentsLengthRef.current) {
680
+ onCommentAdded?.()
681
+ }
682
+ prevCommentsLengthRef.current = uiComments.length
683
+ }, [uiComments.length, report?.reportHubId, onCommentAdded])
684
+
685
+ const handleAddCommentWrapper = async (content: string) => {
686
+ await handleAddComment(content)
687
+ }
688
+
689
+ const displayContent = useMemo(() => {
690
+ if (!report?.content) return null
691
+ return report.content
692
+ .replace(/<br\s*\/?\s*>/gi, "\n")
693
+ .replace(/<\/p>/gi, "\n")
694
+ .replace(/<[^>]+>/g, "")
695
+ }, [report])
696
+
697
+ const handleToggleStar = async () => {
698
+ if (!report) return
699
+ const nextValue = !report.isStarred
700
+ try {
701
+ await toggleStar(report.reportHubId, nextValue, report.date ?? "")
702
+ } catch (_e) {
703
+ // Error handled in context
704
+ }
705
+ }
706
+
707
+ const handleDelete = async () => {
708
+ if (!report) return
709
+ if (!confirm("本当に削除しますか?")) return
710
+ try {
711
+ await deleteReport(report.reportHubId, report.date ?? "")
712
+ } catch (_e) {
713
+ alert("削除に失敗しました")
714
+ }
715
+ }
716
+
717
+ if (error) return <p className="text-red-500">{error.message}</p>
718
+
719
+ const showSkeleton = !report
720
+ const isStarred = report?.isStarred ?? false
721
+ const isRead = report?.isRead ?? false
722
+ const isMine = !!(userId && report?.userId === userId)
723
+ const hasComments = uiComments.length > 0
724
+
725
+ const SkeletonText = ({ className = "w-32" }: { className?: string }) => <div className={`h-4 animate-pulse rounded bg-slate-200 dark:bg-slate-700 ${className}`} />
726
+
727
+ return (
728
+ // biome-ignore lint/a11y/noStaticElementInteractions: Double click to edit feature
729
+ <div className="relative space-y-4 text-slate-700 dark:text-slate-200" onDoubleClick={onDoubleClick}>
730
+ {!showSkeleton && report && (
731
+ <div className="absolute top-0 right-2 z-10 flex items-center justify-end gap-3">
732
+ <button type="button" onClick={() => handleToggleRead(false)} className="text-lg leading-none transition-transform focus:outline-none active:scale-110" title={isRead ? "未読にする" : "既読にする"} data-detail-read-button={report.reportHubId}>
733
+ {isRead ? <span className="text-slate-300 hover:text-blue-500">✓</span> : <UnreadIndicator className="hover:text-blue-600" />}
734
+ </button>
735
+ <button type="button" onClick={handleToggleStar} className="text-lg leading-none transition-transform focus:outline-none active:scale-110" title={isStarred ? "スターを外す" : "スターを付ける"} data-detail-star-button={report.reportHubId}>
736
+ {isStarred ? <span className="text-yellow-400">★</span> : <span className="text-slate-300 hover:text-yellow-400">☆</span>}
737
+ </button>
738
+ {isMine && (
739
+ <button type="button" onClick={handleDelete} data-detail-delete-button={report.reportHubId} className="text-lg leading-none transition-transform focus:outline-none active:scale-110" title="削除する">
740
+ <Trash2 className="h-4 w-4 text-slate-300 hover:text-red-500" />
741
+ </button>
742
+ )}
743
+ </div>
744
+ )}
745
+
746
+ <div className="flex items-baseline">
747
+ <span className="w-24 shrink-0 font-semibold text-slate-500 text-xs uppercase tracking-wide dark:text-slate-300">{fieldLabels.subject}</span>
748
+ <div className="flex-1 pr-20">{showSkeleton ? <SkeletonText className="w-3/4" /> : <span className="fade-in animate-in text-sm duration-500">{fallbackText(report?.subject)}</span>}</div>
749
+ </div>
750
+ <div className="flex items-baseline">
751
+ <span className="w-24 shrink-0 font-semibold text-slate-500 text-xs uppercase tracking-wide dark:text-slate-300">{fieldLabels.customer}</span>
752
+ <div className="flex-1">{showSkeleton ? <SkeletonText className="w-1/2" /> : <span className="fade-in animate-in text-sm duration-500">{fallbackText(report?.customerName)}</span>}</div>
753
+ </div>
754
+ <div className="space-y-2">
755
+ <div className="font-semibold text-slate-500 text-xs uppercase tracking-wide dark:text-slate-300">{fieldLabels.content}</div>
756
+ <div className="min-h-22 rounded-lg bg-slate-50 p-3 text-sm leading-relaxed dark:bg-slate-800/40">
757
+ {showSkeleton ? (
758
+ <div className="space-y-2">
759
+ <SkeletonText className="w-full" />
760
+ <SkeletonText className="w-11/12" />
761
+ <SkeletonText className="w-4/5" />
762
+ </div>
763
+ ) : displayContent ? (
764
+ <div className="fade-in animate-in whitespace-pre-wrap duration-500">{displayContent}</div>
765
+ ) : (
766
+ <p className="fade-in animate-in text-slate-500 duration-500 dark:text-slate-400">内容は登録されていません。</p>
767
+ )}
768
+ </div>
769
+ </div>
770
+ <div className="flex items-baseline">
771
+ <span className="w-24 shrink-0 font-semibold text-slate-500 text-xs uppercase tracking-wide dark:text-slate-300">{fieldLabels.visitTime}</span>
772
+ <div className="flex-1">
773
+ {showSkeleton ? (
774
+ <SkeletonText className="w-1/3" />
775
+ ) : (
776
+ <span className="fade-in animate-in text-sm duration-500">
777
+ {fallbackText(report?.visitTimeFrom)} {report?.visitTimeFrom && report?.visitTimeTo ? "-" : ""} {fallbackText(report?.visitTimeTo)}
778
+ </span>
779
+ )}
780
+ </div>
781
+ </div>
782
+ {report && (
783
+ <div className="fade-in animate-in space-y-2 border-slate-100 border-t pt-4 duration-500 dark:border-slate-800">
784
+ <div className="font-semibold text-slate-500 text-xs uppercase tracking-wide dark:text-slate-300">{fieldLabels.comments}</div>
785
+ {hasComments && (
786
+ <DailyReportCommentList
787
+ comments={uiComments}
788
+ reportHubId={report.reportHubId}
789
+ businessDate={report.date ?? ""}
790
+ onDeleteComment={handleDeleteComment}
791
+ pendingDeleteId={pendingDeleteId}
792
+ onPendingDeleteIdChange={setPendingDeleteId}
793
+ resolvedIdMap={resolvedIdMap}
794
+ />
795
+ )}
796
+ <DailyReportCommentForm reportHubId={report.reportHubId} businessDate={report.date ?? ""} onAddComment={handleAddCommentWrapper} />
797
+ </div>
798
+ )}
799
+ </div>
800
+ )
801
+ }
802
+
803
+ /**
804
+ * Mobile-only overlay that slides in with the selected daily report details.
805
+ * 選択された日報の詳細をモバイル向けにスライド表示し選択ガードを切り替え可能なオーバーレイ。
806
+ */
807
+ const DailyReportMobileOverlay = ({ isOpen, selectedItem, onClose, autoMarkRead = false, userId }: { isOpen: boolean; selectedItem: DailyReportItem | null; onClose: () => void; autoMarkRead?: boolean; userId?: string | null }) => {
808
+ const containerRef = useRef<HTMLDivElement>(null)
809
+ const closeButtonRef = useRef<HTMLButtonElement | null>(null)
810
+
811
+ const { isBlocked: isGuardBlocked, requestBlock, reset: resetGuard } = useSwipeGuardState()
812
+
813
+ const { report, error, isLoading } = useDailyReportDetail(selectedItem?.reportHubId ?? -1, selectedItem?.businessDate ?? null)
814
+ const displayData = useTransitionDisplay(isOpen, selectedItem ? { item: selectedItem, detail: { report, error, isLoading } } : null)
815
+ const displayItem = displayData?.item ?? null
816
+ const _displayDetail = displayData?.detail ?? EMPTY_DETAIL
817
+ _displayDetail
818
+ const { overlayRef, overlayStyle, attemptClose, isSwiping } = useSwipeToDismissOverlay({
819
+ isOpen,
820
+ onClose,
821
+ isCloseBlocked: useCallback(() => isGuardBlocked, [isGuardBlocked]),
822
+ skipWhenTextSelected: true,
823
+ })
824
+
825
+ const tapHandlers = useTapClick(() => attemptClose())
826
+
827
+ /**
828
+ * Manages focus and guard state when overlay opens/closes.
829
+ * オーバーレイの開閉時にフォーカスとガード状態を管理する副作用。
830
+ *
831
+ * Purpose: To set focus on close button for accessibility and reset swipe guard.
832
+ * Dependencies: [isOpen, resetGuard]
833
+ * Cleanup: None.
834
+ */
835
+ useEffect(() => {
836
+ if (isOpen) {
837
+ requestAnimationFrame(() => closeButtonRef.current?.focus())
838
+ } else {
839
+ if (containerRef.current?.contains(document.activeElement)) {
840
+ ;(document.activeElement as HTMLElement).blur()
841
+ }
842
+ resetGuard()
843
+ }
844
+ }, [isOpen, resetGuard])
845
+
846
+ if (!displayItem) return null
847
+
848
+ return (
849
+ <div ref={containerRef} className={`fixed inset-0 z-50 flex touch-manipulation transition-[visibility] duration-200 sm:hidden ${isOpen ? "visible" : "invisible"} ${isOpen && !isSwiping ? "pointer-events-auto" : "pointer-events-none"}`} aria-hidden={!isOpen}>
850
+ <button type="button" aria-label="背景をタップして閉じる" className={`absolute inset-0 touch-manipulation bg-slate-900/50 transition-opacity duration-200 focus:outline-none ${isOpen ? "opacity-100" : "opacity-0"}`} {...tapHandlers}></button>
851
+ <aside
852
+ role="dialog"
853
+ aria-modal="true"
854
+ aria-label="日報詳細"
855
+ className={`relative ml-auto flex h-full w-full max-w-full transform touch-none select-none bg-white shadow-xl transition-transform duration-200 ease-out dark:bg-slate-900/95 ${isOpen ? "pointer-events-auto" : "pointer-events-none"}`}
856
+ ref={overlayRef}
857
+ onKeyDown={(e) => {
858
+ if (e.key === "Escape") {
859
+ e.preventDefault()
860
+ attemptClose()
861
+ }
862
+ }}
863
+ style={overlayStyle}>
864
+ <div className="flex h-full w-full flex-col gap-3 p-4">
865
+ <SwipeCloseGuardContext.Provider value={{ isBlocked: isGuardBlocked, requestBlock }}>
866
+ <DailyReportSidePane selectedItem={displayItem} autoMarkRead={autoMarkRead} isActive={isOpen} userId={userId} />
867
+ </SwipeCloseGuardContext.Provider>
868
+ <div className="mt-2 flex justify-start">
869
+ <button
870
+ type="button"
871
+ ref={closeButtonRef}
872
+ data-overlay-interactive="true"
873
+ className="touch-manipulation rounded-full border border-slate-300 px-4 py-2 font-medium text-slate-600 text-sm shadow-sm transition-colors hover:bg-slate-100 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800"
874
+ onClick={() => attemptClose()}>
875
+ 閉じる
876
+ </button>
877
+ </div>
878
+ </div>
879
+ </aside>
880
+ </div>
881
+ )
882
+ }
883
+
884
+ /**
885
+ * Up to three non-empty plain-text lines extracted from HTML content.
886
+ * HTML コンテンツから抽出する最大三つの非空プレーンテキスト行。
887
+ */
888
+ const extractPreviewLines = (content: string | null): string[] => {
889
+ if (!content) return []
890
+ return content
891
+ .replace(/<br\s*\/?\s*>/gi, "\n")
892
+ .replace(/<\/p>/gi, "\n")
893
+ .replace(/<[^>]+>/g, "")
894
+ .split(/\r?\n/)
895
+ .map((l) => l.replace(/\u3000/g, " ").trim())
896
+ .filter((l) => l.length > 0)
897
+ .slice(0, MAX_PREVIEW_LINES)
898
+ }
899
+
900
+ /**
901
+ * Daily report summary card rendered inside the list tab.
902
+ * List タブ内で表示する日報サマリーカード。
903
+ */
904
+ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; isActive: boolean; onSelect: (id: number) => void }) => {
905
+ const { report, error, isLoading, isRefetching } = useDailyReportDetail(item.reportHubId, item.businessDate)
906
+ const { toggleStar, toggleRead, removeStaleItem } = useDailyReportActionContext()
907
+
908
+ // フェッチが一度でも試行されたかを追跡する ref
909
+ const hasEverFetched = useRef(false)
910
+ if (isRefetching) hasEverFetched.current = true
911
+
912
+ // フェッチ完了後に report が null → stale アイテムとしてリストから除去する安全弁
913
+ // 外部リソース同期: useDailyReportDetail のフェッチ結果に反応する
914
+ useEffect(() => {
915
+ if (!(report || isLoading || isRefetching || error) && hasEverFetched.current && item.reportHubId > 0) {
916
+ removeStaleItem(item.reportHubId)
917
+ }
918
+ }, [report, isLoading, isRefetching, error, item.reportHubId, removeStaleItem])
919
+
920
+ const tapHandlers = useTapClick(() => onSelect(item.reportHubId))
921
+
922
+ if (error) {
923
+ return (
924
+ <div className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
925
+ <div className="flex h-full items-center justify-center rounded-2xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm">
926
+ <p>日報 {item.reportHubId.toLocaleString()} の概要読み込みに失敗しました。</p>
927
+ </div>
928
+ </div>
929
+ )
930
+ }
931
+
932
+ if (isLoading) return <ListSkeletonItem />
933
+
934
+ if (!report) {
935
+ return (
936
+ <div className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
937
+ <div className="flex h-full items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 p-4 text-slate-500 text-sm dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-400">
938
+ <p>日報が見つかりません</p>
939
+ </div>
940
+ </div>
941
+ )
942
+ }
943
+
944
+ const previewLines = extractPreviewLines(report.content).map((line, i) => ({
945
+ line,
946
+ key: `${item.reportHubId}-line-${i}`,
947
+ }))
948
+ const creator = fallbackText(report.employeeName ?? report.author)
949
+ const date = formatToIsoDate(report.date)
950
+
951
+ const handleToggleStar = async (e: React.MouseEvent) => {
952
+ e.stopPropagation()
953
+ const nextValue = !report.isStarred
954
+ try {
955
+ await toggleStar(report.reportHubId, nextValue, report.date ?? "")
956
+ } catch (_e) {
957
+ // Error handled in context
958
+ }
959
+ }
960
+
961
+ const handleToggleRead = async (e: React.MouseEvent) => {
962
+ e.stopPropagation()
963
+ const nextValue = !report.isRead
964
+ try {
965
+ await toggleRead(report.reportHubId, nextValue, report.date ?? "")
966
+ } catch (_e) {
967
+ // Error handled in context
968
+ }
969
+ }
970
+
971
+ return (
972
+ <div className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
973
+ {/* biome-ignore lint/a11y/useSemanticElements: Card contains nested interactive elements (Star/Read buttons) */}
974
+ <div
975
+ data-daily-report-button={item.reportHubId}
976
+ className={`flex h-full w-full in-[.is-scrolling]:transform-none cursor-pointer select-none flex-col rounded-2xl border in-[.is-scrolling]:border-slate-200 bg-white p-4 text-left in-[.is-scrolling]:shadow-sm shadow-sm in-[.is-scrolling]:transition-none transition-all duration-200 hover:-translate-y-0.5 hover:border-blue-300 hover:shadow-lg dark:bg-slate-900/70 ${
977
+ isActive ? "border-blue-300 shadow-md ring-2 ring-blue-200 dark:border-blue-500/70 dark:ring-blue-500/40" : "border-slate-200 dark:border-slate-700"
978
+ }`}
979
+ style={{ touchAction: "manipulation" }}
980
+ role="button"
981
+ tabIndex={0}
982
+ aria-pressed={isActive}
983
+ {...tapHandlers}>
984
+ <div className="mb-3 flex items-center gap-3 text-slate-600 text-sm">
985
+ <span className="inline-flex shrink-0 items-center rounded-full bg-slate-100 px-3 py-1 font-semibold text-xs dark:bg-slate-800 dark:text-slate-200">
986
+ <span className="whitespace-nowrap font-medium text-slate-900 dark:text-slate-100">{date}</span>
987
+ </span>
988
+ <span className="inline-flex min-w-0 max-w-full items-center rounded-full bg-slate-100 px-3 py-1 font-semibold text-xs dark:bg-slate-800 dark:text-slate-200">
989
+ <span className="truncate font-medium text-slate-900 dark:text-slate-100">{creator}</span>
990
+ </span>
991
+ <div className="ml-auto flex items-center gap-1.5">
992
+ <button
993
+ type="button"
994
+ title={report.isStarred ? "スターを外す" : "スターを付ける"}
995
+ data-list-star-button={item.reportHubId}
996
+ onClick={handleToggleStar}
997
+ className="flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-slate-200 dark:hover:bg-slate-700">
998
+ {report.isStarred ? <Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400" /> : <Star className="h-3.5 w-3.5 text-slate-300 dark:text-slate-500" />}
999
+ </button>
1000
+ <button
1001
+ type="button"
1002
+ title={report.isRead ? "未読にする" : "既読にする"}
1003
+ data-list-read-button={item.reportHubId}
1004
+ onClick={handleToggleRead}
1005
+ className="flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-slate-200 dark:hover:bg-slate-700">
1006
+ {report.isRead ? <Check className="h-3.5 w-3.5 text-blue-500" /> : <UnreadIndicator className="text-xs" />}
1007
+ </button>
1008
+ </div>
1009
+ </div>
1010
+ <div className="space-y-1 text-slate-700 text-sm leading-relaxed dark:text-slate-200">
1011
+ {previewLines.length > 0 ? (
1012
+ previewLines.map(({ line, key }) => (
1013
+ <p key={key} className="wrap-break-word line-clamp-1">
1014
+ {line}
1015
+ </p>
1016
+ ))
1017
+ ) : (
1018
+ <p>内容は登録されていません</p>
1019
+ )}
1020
+ </div>
1021
+ </div>
1022
+ </div>
1023
+ )
1024
+ }