@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,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
|
+
}
|