@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,676 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Virtual-scroll based detailed daily report view backed by fetcher-driven data hooks.
|
|
3
|
+
* useFetcher ベースのデータフックで日報詳細を提供する仮想スクロールビュー。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps, tapScrollCircleSampleVisual, VirtualScroll, type VirtualScrollHandle } from "@aiquants/virtualscroll"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Handle type augmented with E2E test helper methods.
|
|
10
|
+
* E2E テスト用ヘルパーメソッドで拡張されたハンドル型。
|
|
11
|
+
*/
|
|
12
|
+
type DailyReportVirtualScrollHandle = VirtualScrollHandle & {
|
|
13
|
+
findReportIndex: (id: number) => number
|
|
14
|
+
getReportItem: (index: number) => DailyReportItem | undefined
|
|
15
|
+
selectItem: (id: number) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
import { Check, Star, Trash2 } from "lucide-react"
|
|
19
|
+
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
20
|
+
import { fallbackText, formatToIsoDate } from "../../shared/text-utils"
|
|
21
|
+
import type { DailyReportDetail, DailyReportItem } from "../../shared/types"
|
|
22
|
+
import { type DailyReportFieldLabels, useDailyReportConfig } from "../config-context"
|
|
23
|
+
import { useDailyReportActionContext } from "../contexts/daily-report-action-context"
|
|
24
|
+
import { useDailyReportDetail, useDailyReportPrefetch } from "../hooks/use-daily-report"
|
|
25
|
+
import { useDailyReportComments } from "../hooks/use-daily-report-comments"
|
|
26
|
+
import { useDynamicViewportHeight } from "../hooks/use-dynamic-viewport-height"
|
|
27
|
+
import { EMPTY_ITEM, FAST_SCROLL_INERTIA_OPTIONS, FAST_SCROLL_WHEEL_MULTIPLIER, PREFETCH_LOOKAHEAD, PREFETCH_MAX_DATES, VIRTUAL_SCROLL_OVERSCAN_COUNT } from "../utils/constants"
|
|
28
|
+
import { BusinessDayThumbOverlay } from "./business-day-thumb-overlay"
|
|
29
|
+
import { DailyReportCommentForm, DailyReportCommentList } from "./daily-report-comment-section"
|
|
30
|
+
import { DailyReportEditForm } from "./daily-report-edit-form"
|
|
31
|
+
import { UnreadIndicator } from "./unread-indicator"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Observes container height changes and reports measured values upstream.
|
|
35
|
+
* コンテナの高さ変化を監視し測定結果を親へ通知するフック。
|
|
36
|
+
*/
|
|
37
|
+
const useReportItemHeightObserver = (ref: RefObject<HTMLDivElement | null>, itemId: number, onHeightMeasured: (id: number, height: number) => void) => {
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const element = ref.current
|
|
40
|
+
if (!element) return
|
|
41
|
+
|
|
42
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
43
|
+
onHeightMeasured(itemId, element.offsetHeight)
|
|
44
|
+
})
|
|
45
|
+
resizeObserver.observe(element)
|
|
46
|
+
return () => resizeObserver.disconnect()
|
|
47
|
+
}, [itemId, onHeightMeasured, ref])
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Wraps a daily report item row, observing its height and providing a stable test identifier.
|
|
52
|
+
* 日報アイテム行をラップし、高さを監視するとともに安定したテスト識別子を提供するコンポーネント。
|
|
53
|
+
*/
|
|
54
|
+
const DailyReportDetailRow = ({ itemId, onHeightMeasured, children }: { itemId: number; onHeightMeasured: (id: number, height: number) => void; children: React.ReactNode }) => {
|
|
55
|
+
const elementRef = useRef<HTMLDivElement | null>(null)
|
|
56
|
+
useReportItemHeightObserver(elementRef, itemId, onHeightMeasured)
|
|
57
|
+
return (
|
|
58
|
+
<div ref={elementRef} className="px-2 pb-3" data-daily-report-button={itemId}>
|
|
59
|
+
{children}
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Formats interviewer labels with optional affiliation.
|
|
66
|
+
* 面談者情報を所属付きのラベル文字列に整形する関数。
|
|
67
|
+
*/
|
|
68
|
+
const formatInterviewerLabel = (interviewer: DailyReportDetail["interviewers"][number]): string => {
|
|
69
|
+
if (interviewer.affiliation?.trim()) {
|
|
70
|
+
return `${interviewer.name}(${interviewer.affiliation})`
|
|
71
|
+
}
|
|
72
|
+
return interviewer.name
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type DailyReportDetailListProps = {
|
|
76
|
+
dailyReportItems: DailyReportItem[]
|
|
77
|
+
userId?: string | null
|
|
78
|
+
selectedItemId?: number | null
|
|
79
|
+
onSelectItem?: (id: number | null) => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Daily report detail cards rendered with dynamic height virtual scroll.
|
|
84
|
+
* 動的高さの仮想スクロールで日報詳細カードを描画するコンポーネント。
|
|
85
|
+
*/
|
|
86
|
+
export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId, onSelectItem }: DailyReportDetailListProps) => {
|
|
87
|
+
const [itemHeights, setItemHeights] = useState<Map<number, number>>(new Map())
|
|
88
|
+
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
|
89
|
+
const virtualScrollRef = useRef<VirtualScrollHandle>(null)
|
|
90
|
+
const { viewportHeight, updateViewportHeight } = useDynamicViewportHeight(scrollContainerRef, { enableMutationObserver: true })
|
|
91
|
+
const prefetchDailyReportByDate = useDailyReportPrefetch()
|
|
92
|
+
const [overlayLabel, setOverlayLabel] = useState<string>("")
|
|
93
|
+
const [isWheelScrollActive, setIsWheelScrollActive] = useState(false)
|
|
94
|
+
const wheelResetTimerRef = useRef<number | null>(null)
|
|
95
|
+
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
96
|
+
const dailyReportItemsRef = useRef(dailyReportItems)
|
|
97
|
+
dailyReportItemsRef.current = dailyReportItems
|
|
98
|
+
|
|
99
|
+
// 同一 selectedItemId かつ同一 index への重複スクロールを防止する ref
|
|
100
|
+
const lastScrolledRef = useRef<{ id: number; index: number } | null>(null)
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (selectedItemId != null && virtualScrollRef.current) {
|
|
104
|
+
const index = dailyReportItems.findIndex((item) => item.reportHubId === selectedItemId)
|
|
105
|
+
if (index !== -1) {
|
|
106
|
+
// ID もインデックスも同じなら再スクロール不要
|
|
107
|
+
if (lastScrolledRef.current?.id === selectedItemId && lastScrolledRef.current?.index === index) return
|
|
108
|
+
virtualScrollRef.current.scrollToIndex(index, { align: "top" })
|
|
109
|
+
lastScrolledRef.current = { id: selectedItemId, index }
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
lastScrolledRef.current = null
|
|
113
|
+
}
|
|
114
|
+
}, [selectedItemId, dailyReportItems])
|
|
115
|
+
|
|
116
|
+
// onSelectItem の最新参照を保持する ref (useEffect 依存配列を不要にする)
|
|
117
|
+
const onSelectItemRef = useRef(onSelectItem)
|
|
118
|
+
onSelectItemRef.current = onSelectItem
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (virtualScrollRef.current) {
|
|
122
|
+
const el = document.querySelector(".test-daily-report-detail-list")
|
|
123
|
+
if (el) {
|
|
124
|
+
const handle = virtualScrollRef.current
|
|
125
|
+
const extendedHandle: DailyReportVirtualScrollHandle = Object.assign({}, handle, {
|
|
126
|
+
findReportIndex: (id: number) => dailyReportItemsRef.current.findIndex((item) => item.reportHubId === id),
|
|
127
|
+
getReportItem: (index: number) => dailyReportItemsRef.current[index],
|
|
128
|
+
selectItem: (id: number) => onSelectItemRef.current?.(id),
|
|
129
|
+
})
|
|
130
|
+
;(el as Element & { __virtualScroll: DailyReportVirtualScrollHandle }).__virtualScroll = extendedHandle
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const itemCount = dailyReportItems.length
|
|
136
|
+
|
|
137
|
+
const getItem = useCallback(
|
|
138
|
+
(index: number): DailyReportItem => {
|
|
139
|
+
if (index >= itemCount) return EMPTY_ITEM
|
|
140
|
+
return dailyReportItems[index]
|
|
141
|
+
},
|
|
142
|
+
[dailyReportItems, itemCount],
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
const schedulePrefetchForRange = useCallback(
|
|
146
|
+
(fromIndex: number, toIndex: number) => {
|
|
147
|
+
if (itemCount === 0) return
|
|
148
|
+
const boundedFrom = Math.max(0, fromIndex)
|
|
149
|
+
const boundedTo = Math.min(itemCount - 1, toIndex)
|
|
150
|
+
if (boundedFrom > boundedTo) return
|
|
151
|
+
|
|
152
|
+
const businessDates = new Set<string>()
|
|
153
|
+
for (let index = boundedFrom; index <= boundedTo; index++) {
|
|
154
|
+
const candidate = getItem(index)
|
|
155
|
+
if (candidate.businessDate) businessDates.add(candidate.businessDate)
|
|
156
|
+
if (businessDates.size >= PREFETCH_MAX_DATES) break
|
|
157
|
+
}
|
|
158
|
+
businessDates.forEach((date) => {
|
|
159
|
+
prefetchDailyReportByDate(date)
|
|
160
|
+
})
|
|
161
|
+
},
|
|
162
|
+
[getItem, itemCount, prefetchDailyReportByDate],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Handles visible range changes to update overlay label and schedule prefetching.
|
|
167
|
+
* 表示範囲の変更を処理し、オーバーレイラベルの更新とプリフェッチのスケジュールを行う。
|
|
168
|
+
*/
|
|
169
|
+
const handleRangeChange = useCallback(
|
|
170
|
+
({ renderingStartIndex, renderingEndIndex, visibleStartIndex }: { renderingStartIndex: number; renderingEndIndex: number; visibleStartIndex: number }) => {
|
|
171
|
+
if (itemCount === 0) return
|
|
172
|
+
const firstVisibleItem = getItem(visibleStartIndex)
|
|
173
|
+
if (firstVisibleItem?.businessDate) {
|
|
174
|
+
setOverlayLabel(formatToIsoDate(firstVisibleItem.businessDate))
|
|
175
|
+
}
|
|
176
|
+
schedulePrefetchForRange(renderingStartIndex, renderingEndIndex)
|
|
177
|
+
schedulePrefetchForRange(renderingEndIndex + 1, renderingEndIndex + PREFETCH_LOOKAHEAD)
|
|
178
|
+
schedulePrefetchForRange(renderingStartIndex - PREFETCH_LOOKAHEAD, renderingStartIndex - 1)
|
|
179
|
+
},
|
|
180
|
+
[getItem, itemCount, schedulePrefetchForRange],
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
const handleScroll = useCallback(() => {
|
|
184
|
+
const container = scrollContainerRef.current
|
|
185
|
+
if (!container) return
|
|
186
|
+
|
|
187
|
+
if (!container.classList.contains("is-scrolling")) {
|
|
188
|
+
container.classList.add("is-scrolling")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (scrollEndTimerRef.current) {
|
|
192
|
+
clearTimeout(scrollEndTimerRef.current)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
scrollEndTimerRef.current = setTimeout(() => {
|
|
196
|
+
if (container) {
|
|
197
|
+
container.classList.remove("is-scrolling")
|
|
198
|
+
}
|
|
199
|
+
scrollEndTimerRef.current = null
|
|
200
|
+
}, 150)
|
|
201
|
+
}, [])
|
|
202
|
+
|
|
203
|
+
const renderThumbOverlay = useCallback((props: ScrollBarThumbOverlayRenderProps) => <BusinessDayThumbOverlay {...props} label={overlayLabel} isWheelScrollActive={isWheelScrollActive} />, [overlayLabel, isWheelScrollActive])
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Sets up wheel event listener to toggle overlay visibility.
|
|
207
|
+
* ホイールイベントリスナーを設定し、オーバーレイの表示状態を切り替える。
|
|
208
|
+
*/
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const el = scrollContainerRef.current
|
|
211
|
+
if (!el) return
|
|
212
|
+
const onWheel = () => {
|
|
213
|
+
setIsWheelScrollActive(true)
|
|
214
|
+
if (wheelResetTimerRef.current) clearTimeout(wheelResetTimerRef.current)
|
|
215
|
+
wheelResetTimerRef.current = window.setTimeout(() => {
|
|
216
|
+
setIsWheelScrollActive(false)
|
|
217
|
+
wheelResetTimerRef.current = null
|
|
218
|
+
}, 240)
|
|
219
|
+
}
|
|
220
|
+
el.addEventListener("wheel", onWheel, { passive: true })
|
|
221
|
+
return () => {
|
|
222
|
+
el.removeEventListener("wheel", onWheel)
|
|
223
|
+
if (wheelResetTimerRef.current) clearTimeout(wheelResetTimerRef.current)
|
|
224
|
+
}
|
|
225
|
+
}, [])
|
|
226
|
+
|
|
227
|
+
const updateItemHeight = useCallback((id: number, height: number) => {
|
|
228
|
+
setItemHeights((prev) => {
|
|
229
|
+
if (prev.get(id) === height) return prev
|
|
230
|
+
const newMap = new Map(prev)
|
|
231
|
+
newMap.set(id, height)
|
|
232
|
+
return newMap
|
|
233
|
+
})
|
|
234
|
+
if (virtualScrollRef.current) {
|
|
235
|
+
const index = dailyReportItemsRef.current.findIndex((item) => item.reportHubId === id)
|
|
236
|
+
if (index !== -1) {
|
|
237
|
+
virtualScrollRef.current.updateItemSize(index, height)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}, [])
|
|
241
|
+
|
|
242
|
+
const getItemHeight = useCallback(
|
|
243
|
+
(index: number) => {
|
|
244
|
+
const item = getItem(index)
|
|
245
|
+
return itemHeights.get(item.reportHubId) || 350
|
|
246
|
+
},
|
|
247
|
+
[getItem, itemHeights],
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const getItemKey = useCallback(
|
|
251
|
+
(index: number) => {
|
|
252
|
+
const item = getItem(index)
|
|
253
|
+
return item.reportHubId
|
|
254
|
+
},
|
|
255
|
+
[getItem],
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
const tapScrollCircleOptions = useMemo<ScrollBarTapCircleOptions>(() => ({ enabled: true, renderVisual: tapScrollCircleSampleVisual }), [])
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Updates viewport height when item count changes.
|
|
262
|
+
* アイテム数変更時にビューポート高さを更新する。
|
|
263
|
+
*/
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (itemCount >= 0) updateViewportHeight()
|
|
266
|
+
}, [itemCount, updateViewportHeight])
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Schedules initial prefetch when items are loaded.
|
|
270
|
+
* アイテム読み込み時に初期プリフェッチをスケジュールする。
|
|
271
|
+
*/
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (itemCount === 0) return
|
|
274
|
+
schedulePrefetchForRange(0, Math.min(itemCount - 1, PREFETCH_LOOKAHEAD))
|
|
275
|
+
}, [itemCount, schedulePrefetchForRange])
|
|
276
|
+
|
|
277
|
+
const handleCommentAdded = useCallback((itemId: number) => {
|
|
278
|
+
const index = dailyReportItemsRef.current.findIndex((item) => item.reportHubId === itemId)
|
|
279
|
+
if (index >= 0) {
|
|
280
|
+
setTimeout(() => virtualScrollRef.current?.scrollToIndex(index, { align: "bottom" }), 300)
|
|
281
|
+
}
|
|
282
|
+
}, [])
|
|
283
|
+
|
|
284
|
+
const renderItem = useCallback((item: DailyReportItem) => <DailyReportItemContainer key={item.reportHubId} item={item} onHeightMeasured={updateItemHeight} onCommentAdded={handleCommentAdded} userId={userId} />, [updateItemHeight, handleCommentAdded, userId])
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<>
|
|
288
|
+
<div ref={scrollContainerRef} className="flex min-h-0 flex-1 flex-col">
|
|
289
|
+
<VirtualScroll
|
|
290
|
+
ref={virtualScrollRef}
|
|
291
|
+
itemCount={itemCount}
|
|
292
|
+
getItem={getItem}
|
|
293
|
+
getItemKey={getItemKey}
|
|
294
|
+
getItemHeight={getItemHeight}
|
|
295
|
+
viewportSize={viewportHeight}
|
|
296
|
+
overscanCount={VIRTUAL_SCROLL_OVERSCAN_COUNT}
|
|
297
|
+
scrollBarOptions={{
|
|
298
|
+
width: 8,
|
|
299
|
+
tapScrollCircleOptions: tapScrollCircleOptions,
|
|
300
|
+
renderThumbOverlay: renderThumbOverlay,
|
|
301
|
+
}}
|
|
302
|
+
behaviorOptions={{
|
|
303
|
+
wheelSpeedMultiplier: FAST_SCROLL_WHEEL_MULTIPLIER,
|
|
304
|
+
inertiaOptions: FAST_SCROLL_INERTIA_OPTIONS,
|
|
305
|
+
}}
|
|
306
|
+
onRangeChange={handleRangeChange}
|
|
307
|
+
onScroll={handleScroll}
|
|
308
|
+
className="t-detail-list test-daily-report-detail-list relative w-full">
|
|
309
|
+
{renderItem}
|
|
310
|
+
</VirtualScroll>
|
|
311
|
+
</div>
|
|
312
|
+
{itemCount === 0 && (
|
|
313
|
+
<div className="py-8 text-center">
|
|
314
|
+
<p className="text-slate-500">データがありません</p>
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
</>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Loading skeleton for the virtual scroll detail entry.
|
|
323
|
+
* 仮想スクロール詳細行の読み込み中スケルトン表示。
|
|
324
|
+
*/
|
|
325
|
+
const SkeletonReportItem = () => (
|
|
326
|
+
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
|
327
|
+
<div className="mb-4 flex flex-wrap gap-2">
|
|
328
|
+
<div className="h-6 w-20 rounded-full bg-slate-200"></div>
|
|
329
|
+
<div className="h-6 w-24 rounded-full bg-slate-200"></div>
|
|
330
|
+
<div className="h-6 w-24 rounded-full bg-slate-200"></div>
|
|
331
|
+
<div className="h-6 w-28 rounded-full bg-slate-200"></div>
|
|
332
|
+
</div>
|
|
333
|
+
<div className="mb-3 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
|
|
334
|
+
<div className="space-y-3">
|
|
335
|
+
<div className="h-5 w-40 rounded bg-slate-200"></div>
|
|
336
|
+
<div className="h-5 w-56 rounded bg-slate-200"></div>
|
|
337
|
+
<div className="h-24 rounded-lg bg-slate-200"></div>
|
|
338
|
+
</div>
|
|
339
|
+
<div className="rounded-xl bg-slate-100 p-3">
|
|
340
|
+
<div className="mb-2 h-4 w-24 rounded bg-slate-200"></div>
|
|
341
|
+
<div className="mb-2 h-4 w-28 rounded bg-slate-200"></div>
|
|
342
|
+
<div className="h-4 w-32 rounded bg-slate-200"></div>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
<div className="space-y-3">
|
|
346
|
+
<div className="h-4 w-24 rounded bg-slate-200"></div>
|
|
347
|
+
<div className="flex flex-wrap gap-2">
|
|
348
|
+
<div className="h-6 w-16 rounded-full bg-slate-200"></div>
|
|
349
|
+
<div className="h-6 w-16 rounded-full bg-slate-200"></div>
|
|
350
|
+
<div className="h-6 w-16 rounded-full bg-slate-200"></div>
|
|
351
|
+
</div>
|
|
352
|
+
<div className="space-y-2">
|
|
353
|
+
<div className="h-16 rounded-lg bg-slate-200"></div>
|
|
354
|
+
<div className="h-16 rounded-lg bg-slate-200"></div>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Chooses the appropriate daily report item representation based on fetch state.
|
|
362
|
+
* フェッチ状態に応じて日報アイテムの表示コンポーネントを選択する関数。
|
|
363
|
+
*/
|
|
364
|
+
const DailyReportItemContainer = ({ item, onHeightMeasured, onCommentAdded, userId }: { item: DailyReportItem; onHeightMeasured: (id: number, height: number) => void; onCommentAdded?: (itemId: number) => void; userId?: string | null }) => {
|
|
365
|
+
const { report, error, isLoading, isRefetching } = useDailyReportDetail(item.reportHubId, item.businessDate)
|
|
366
|
+
const { removeStaleItem } = useDailyReportActionContext()
|
|
367
|
+
const { draftLabelName } = useDailyReportConfig()
|
|
368
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
369
|
+
const [hasBypassedAutoEdit, setHasBypassedAutoEdit] = useState(false)
|
|
370
|
+
// 自動編集モード (ドラフト検出による自動遷移) かどうかを追跡
|
|
371
|
+
const autoEditingRef = useRef(false)
|
|
372
|
+
|
|
373
|
+
// フェッチが一度でも試行されたかを追跡する ref
|
|
374
|
+
const hasEverFetched = useRef(false)
|
|
375
|
+
if (isRefetching) hasEverFetched.current = true
|
|
376
|
+
|
|
377
|
+
// フェッチ完了後に report が null → stale アイテムとしてリストから除去する安全弁
|
|
378
|
+
// 外部リソース同期: useDailyReportDetail のフェッチ結果に反応する
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
if (!(report || isLoading || isRefetching || error) && hasEverFetched.current && item.reportHubId > 0) {
|
|
381
|
+
removeStaleItem(item.reportHubId)
|
|
382
|
+
}
|
|
383
|
+
}, [report, isLoading, isRefetching, error, item.reportHubId, removeStaleItem])
|
|
384
|
+
|
|
385
|
+
const isDraft = report?.labels.some((l) => l.name === draftLabelName)
|
|
386
|
+
const isMine = userId && report?.userId === userId
|
|
387
|
+
|
|
388
|
+
// ドラフトレポートの自動編集モード制御
|
|
389
|
+
// - ドラフト状態かつ自分のレポート: 自動的にエディタモード
|
|
390
|
+
// - SSE でドラフト→公開に遷移: 自動編集モードの場合のみビューモードへ解除
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
if (report && isMine && isDraft && !isEditing && !hasBypassedAutoEdit) {
|
|
393
|
+
setIsEditing(true)
|
|
394
|
+
autoEditingRef.current = true
|
|
395
|
+
} else if (report && !isDraft && isEditing && autoEditingRef.current) {
|
|
396
|
+
// SSE で下書き→公開に遷移: 自動編集モードの場合のみビューモードへ
|
|
397
|
+
setIsEditing(false)
|
|
398
|
+
autoEditingRef.current = false
|
|
399
|
+
}
|
|
400
|
+
}, [report, isMine, isDraft, isEditing, hasBypassedAutoEdit])
|
|
401
|
+
|
|
402
|
+
return (
|
|
403
|
+
<DailyReportDetailRow itemId={item.reportHubId} onHeightMeasured={onHeightMeasured}>
|
|
404
|
+
{error ? (
|
|
405
|
+
<DailyReportError itemId={item.reportHubId} />
|
|
406
|
+
) : !report ? (
|
|
407
|
+
<SkeletonReportItem />
|
|
408
|
+
) : isEditing ? (
|
|
409
|
+
<DailyReportEditForm
|
|
410
|
+
report={report}
|
|
411
|
+
onCancel={() => {
|
|
412
|
+
setIsEditing(false)
|
|
413
|
+
setHasBypassedAutoEdit(true)
|
|
414
|
+
}}
|
|
415
|
+
onPublishSuccess={() => {
|
|
416
|
+
setIsEditing(false)
|
|
417
|
+
}}
|
|
418
|
+
/>
|
|
419
|
+
) : (
|
|
420
|
+
<DailyReportResolvedItem item={item} report={report} onCommentAdded={onCommentAdded} userId={userId} onDoubleClick={isMine ? () => setIsEditing(true) : undefined} />
|
|
421
|
+
)}
|
|
422
|
+
</DailyReportDetailRow>
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Renders the header section of a daily report.
|
|
428
|
+
* 日報のヘッダー部分を描画するコンポーネント。
|
|
429
|
+
*/
|
|
430
|
+
const DailyReportHeader = ({
|
|
431
|
+
report,
|
|
432
|
+
isRead,
|
|
433
|
+
isStarred,
|
|
434
|
+
isMine,
|
|
435
|
+
onToggleRead,
|
|
436
|
+
onToggleStar,
|
|
437
|
+
onDelete,
|
|
438
|
+
visitTimeDisplay,
|
|
439
|
+
categoryLabel,
|
|
440
|
+
fieldLabels,
|
|
441
|
+
}: {
|
|
442
|
+
report: DailyReportDetail
|
|
443
|
+
isRead: boolean
|
|
444
|
+
isStarred: boolean
|
|
445
|
+
isMine: boolean
|
|
446
|
+
onToggleRead: () => void
|
|
447
|
+
onToggleStar: () => void
|
|
448
|
+
onDelete: () => void
|
|
449
|
+
visitTimeDisplay: string
|
|
450
|
+
categoryLabel: string
|
|
451
|
+
fieldLabels: DailyReportFieldLabels
|
|
452
|
+
}) => (
|
|
453
|
+
<div className="flex flex-wrap items-center gap-2 font-medium text-slate-600 text-xs">
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
onClick={onToggleRead}
|
|
457
|
+
className="flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-slate-200 focus:outline-none dark:hover:bg-slate-700"
|
|
458
|
+
title={isRead ? "未読にする" : "既読にする"}
|
|
459
|
+
data-detail-read-button={report.reportHubId}>
|
|
460
|
+
{isRead ? <Check className="h-3.5 w-3.5 text-blue-500" /> : <UnreadIndicator className="text-xs" />}
|
|
461
|
+
</button>
|
|
462
|
+
<button
|
|
463
|
+
type="button"
|
|
464
|
+
onClick={onToggleStar}
|
|
465
|
+
className="flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-slate-200 focus:outline-none dark:hover:bg-slate-700"
|
|
466
|
+
title={isStarred ? "スターを外す" : "スターを付ける"}
|
|
467
|
+
data-detail-star-button={report.reportHubId}>
|
|
468
|
+
{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" />}
|
|
469
|
+
</button>
|
|
470
|
+
{isMine && (
|
|
471
|
+
<button type="button" onClick={onDelete} data-detail-delete-button={report.reportHubId} className="text-lg leading-none transition-transform focus:outline-none active:scale-125" title="削除する">
|
|
472
|
+
<Trash2 className="h-4 w-4 text-slate-300 hover:text-red-500" />
|
|
473
|
+
</button>
|
|
474
|
+
)}
|
|
475
|
+
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-blue-50 px-3 py-1 font-semibold text-blue-700 text-xs">
|
|
476
|
+
<span className="text-[10px] text-blue-500 uppercase tracking-wide">{fieldLabels.id}</span>
|
|
477
|
+
{report.reportHubId}
|
|
478
|
+
</span>
|
|
479
|
+
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-slate-100 px-3 py-1">
|
|
480
|
+
<span className="text-[10px] text-slate-400 uppercase tracking-wide">{fieldLabels.businessDate}</span>
|
|
481
|
+
{formatToIsoDate(report.date)}
|
|
482
|
+
</span>
|
|
483
|
+
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-slate-100 px-3 py-1">
|
|
484
|
+
<span className="text-[10px] text-slate-400 uppercase tracking-wide">{fieldLabels.author}</span>
|
|
485
|
+
{fallbackText(report.employeeName)}
|
|
486
|
+
</span>
|
|
487
|
+
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-slate-100 px-3 py-1">
|
|
488
|
+
<span className="text-[10px] text-slate-400 uppercase tracking-wide">{fieldLabels.visitTime}</span>
|
|
489
|
+
{visitTimeDisplay || "-"}
|
|
490
|
+
</span>
|
|
491
|
+
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-slate-100 px-3 py-1">
|
|
492
|
+
<span className="text-[10px] text-slate-400 uppercase tracking-wide">{fieldLabels.updatedAt}</span>
|
|
493
|
+
{formatToIsoDate(report.updatedAt)}
|
|
494
|
+
</span>
|
|
495
|
+
{categoryLabel && (
|
|
496
|
+
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-amber-100 px-3 py-1 text-amber-700">
|
|
497
|
+
<span className="text-[10px] text-amber-500 uppercase tracking-wide">{fieldLabels.category}</span>
|
|
498
|
+
{categoryLabel}
|
|
499
|
+
</span>
|
|
500
|
+
)}
|
|
501
|
+
</div>
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Renders the side panel with detailed metadata.
|
|
506
|
+
* 詳細なメタデータを表示するサイドパネルを描画するコンポーネント。
|
|
507
|
+
*/
|
|
508
|
+
const MetadataPanel = ({ report, visitTimeDisplay, categoryLabel, fieldLabels }: { report: DailyReportDetail; visitTimeDisplay: string; categoryLabel: string; fieldLabels: DailyReportFieldLabels }) => (
|
|
509
|
+
<div className="flex flex-col gap-3 rounded-xl bg-slate-100 p-4 text-slate-600 text-xs">
|
|
510
|
+
<div className="flex flex-col gap-1">
|
|
511
|
+
<span className="font-semibold text-[10px] text-slate-500 uppercase tracking-wide">{fieldLabels.author}</span>
|
|
512
|
+
<span className="text-slate-700 text-sm">{fallbackText(report.author)}</span>
|
|
513
|
+
</div>
|
|
514
|
+
<div className="flex flex-col gap-1">
|
|
515
|
+
<span className="font-semibold text-[10px] text-slate-500 uppercase tracking-wide">{fieldLabels.createdAt}</span>
|
|
516
|
+
<span className="text-slate-700 text-sm">{formatToIsoDate(report.createdAt)}</span>
|
|
517
|
+
</div>
|
|
518
|
+
<div className="flex flex-col gap-1">
|
|
519
|
+
<span className="font-semibold text-[10px] text-slate-500 uppercase tracking-wide">{fieldLabels.updatedBy}</span>
|
|
520
|
+
<span className="text-slate-700 text-sm">{fallbackText(report.updatedBy)}</span>
|
|
521
|
+
</div>
|
|
522
|
+
<div className="flex flex-col gap-1">
|
|
523
|
+
<span className="font-semibold text-[10px] text-slate-500 uppercase tracking-wide">{fieldLabels.updatedAt}</span>
|
|
524
|
+
<span className="text-slate-700 text-sm">{formatToIsoDate(report.updatedAt)}</span>
|
|
525
|
+
</div>
|
|
526
|
+
<div className="flex flex-col gap-1">
|
|
527
|
+
<span className="font-semibold text-[10px] text-slate-500 uppercase tracking-wide">{fieldLabels.businessDate}</span>
|
|
528
|
+
<span className="text-slate-700 text-sm">{formatToIsoDate(report.date)}</span>
|
|
529
|
+
</div>
|
|
530
|
+
{visitTimeDisplay && (
|
|
531
|
+
<div className="flex flex-col gap-1">
|
|
532
|
+
<span className="font-semibold text-[10px] text-slate-500 uppercase tracking-wide">{fieldLabels.visitTime}</span>
|
|
533
|
+
<span className="text-slate-700 text-sm">{visitTimeDisplay}</span>
|
|
534
|
+
</div>
|
|
535
|
+
)}
|
|
536
|
+
{categoryLabel && (
|
|
537
|
+
<div className="flex flex-col gap-1">
|
|
538
|
+
<span className="font-semibold text-[10px] text-slate-500 uppercase tracking-wide">{fieldLabels.categoryInfo}</span>
|
|
539
|
+
<span className="text-slate-700 text-sm">{categoryLabel}</span>
|
|
540
|
+
</div>
|
|
541
|
+
)}
|
|
542
|
+
</div>
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Renders the resolved daily report detail card with metadata and content.
|
|
547
|
+
* 解決済みの日報詳細カードをメタデータと内容込みで描画する関数。
|
|
548
|
+
*/
|
|
549
|
+
const DailyReportResolvedItem = ({ item, report, onCommentAdded, userId, onDoubleClick }: { item: DailyReportItem; report: DailyReportDetail; onCommentAdded?: (itemId: number) => void; userId?: string | null; onDoubleClick?: () => void }) => {
|
|
550
|
+
const formContainerRef = useRef<HTMLDivElement>(null)
|
|
551
|
+
// 表示中ユーザーはアクションコンテキストから取得する (root loader 非依存)
|
|
552
|
+
const { toggleStar, toggleRead, pendingStarUpdates, pendingReadUpdates, deleteReport, user } = useDailyReportActionContext()
|
|
553
|
+
const { uiComments, handleAddComment, handleDeleteComment, pendingDeleteId, setPendingDeleteId, resolvedIdMap } = useDailyReportComments(report, user, userId)
|
|
554
|
+
const { fieldLabels } = useDailyReportConfig()
|
|
555
|
+
|
|
556
|
+
const handleAddCommentWrapper = async (content: string) => {
|
|
557
|
+
await handleAddComment(content)
|
|
558
|
+
onCommentAdded?.(item.reportHubId)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const isStarred = pendingStarUpdates.current.has(report.reportHubId) ? (pendingStarUpdates.current.get(report.reportHubId) ?? false) : report.isStarred
|
|
562
|
+
const isRead = pendingReadUpdates.current.has(report.reportHubId) ? (pendingReadUpdates.current.get(report.reportHubId) ?? false) : report.isRead
|
|
563
|
+
const isMine = !!(userId && report.userId === userId)
|
|
564
|
+
|
|
565
|
+
const handleToggleRead = async () => {
|
|
566
|
+
try {
|
|
567
|
+
await toggleRead(report.reportHubId, !isRead, report.date ?? "")
|
|
568
|
+
} catch {}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const handleToggleStar = async () => {
|
|
572
|
+
try {
|
|
573
|
+
await toggleStar(report.reportHubId, !isStarred, report.date ?? "")
|
|
574
|
+
} catch {}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const handleDelete = async () => {
|
|
578
|
+
if (!confirm("本当に削除しますか?")) return
|
|
579
|
+
await deleteReport(report.reportHubId, report.date ?? "")
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const normalizedContent = useMemo(() => (report.content ? report.content.replace(/<br\s*\/??\s*>/gi, "\n").trim() : ""), [report.content])
|
|
583
|
+
const visitTimeDisplay = useMemo(() => {
|
|
584
|
+
const from = report.visitTimeFrom?.trim()
|
|
585
|
+
const to = report.visitTimeTo?.trim()
|
|
586
|
+
return from && to ? `${from} - ${to}` : from || to || ""
|
|
587
|
+
}, [report.visitTimeFrom, report.visitTimeTo])
|
|
588
|
+
const categoryLabel = useMemo(() => {
|
|
589
|
+
const labels: string[] = []
|
|
590
|
+
if (report.category?.trim()) labels.push(`${fieldLabels.category}: ${report.category}`)
|
|
591
|
+
if (report.creationCategory?.trim()) labels.push(`${fieldLabels.creationCategory}: ${report.creationCategory}`)
|
|
592
|
+
return labels.join(" / ")
|
|
593
|
+
}, [report.category, report.creationCategory, fieldLabels.category, fieldLabels.creationCategory])
|
|
594
|
+
|
|
595
|
+
return (
|
|
596
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: Double click to edit feature
|
|
597
|
+
<div
|
|
598
|
+
onDoubleClick={onDoubleClick}
|
|
599
|
+
className={`flex h-full w-full flex-col rounded-2xl border bg-white p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-blue-300 hover:shadow-lg dark:border-slate-700 dark:bg-slate-900/70 ${!isRead ? "border-l-4 border-l-blue-500" : ""}`}>
|
|
600
|
+
<div className="flex flex-col gap-4">
|
|
601
|
+
<DailyReportHeader
|
|
602
|
+
report={report}
|
|
603
|
+
isRead={isRead}
|
|
604
|
+
isStarred={isStarred}
|
|
605
|
+
isMine={isMine}
|
|
606
|
+
onToggleRead={handleToggleRead}
|
|
607
|
+
onToggleStar={handleToggleStar}
|
|
608
|
+
onDelete={handleDelete}
|
|
609
|
+
visitTimeDisplay={visitTimeDisplay}
|
|
610
|
+
categoryLabel={categoryLabel}
|
|
611
|
+
fieldLabels={fieldLabels}
|
|
612
|
+
/>
|
|
613
|
+
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_240px]">
|
|
614
|
+
<div className="flex flex-col gap-4">
|
|
615
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
616
|
+
<div className="flex flex-col gap-1">
|
|
617
|
+
<span className="font-semibold text-slate-500 text-xs uppercase tracking-wide">{fieldLabels.customer}</span>
|
|
618
|
+
<span className="font-semibold text-base text-slate-900">{fallbackText(report.customerName)}</span>
|
|
619
|
+
</div>
|
|
620
|
+
<div className="flex flex-col gap-1">
|
|
621
|
+
<span className="font-semibold text-slate-500 text-xs uppercase tracking-wide">{fieldLabels.subject}</span>
|
|
622
|
+
<span className="text-slate-700 text-sm">{fallbackText(report.subject)}</span>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
{normalizedContent && (
|
|
626
|
+
<div className="flex flex-col gap-2">
|
|
627
|
+
<span className="font-semibold text-slate-500 text-xs uppercase tracking-wide">{fieldLabels.content}</span>
|
|
628
|
+
<div className="whitespace-pre-wrap rounded-xl bg-slate-50 p-3 text-slate-700 text-sm leading-relaxed">{normalizedContent}</div>
|
|
629
|
+
</div>
|
|
630
|
+
)}
|
|
631
|
+
{report.interviewers.length > 0 && (
|
|
632
|
+
<div className="flex flex-col gap-2">
|
|
633
|
+
<span className="font-semibold text-slate-500 text-xs uppercase tracking-wide">{fieldLabels.interviewers}</span>
|
|
634
|
+
<ul className="flex flex-wrap gap-2">
|
|
635
|
+
{report.interviewers.map((interviewer) => (
|
|
636
|
+
<li key={`${interviewer.name}-${interviewer.affiliation || ""}`} className="rounded-full bg-slate-100 px-3 py-1 text-slate-600 text-xs">
|
|
637
|
+
{formatInterviewerLabel(interviewer)}
|
|
638
|
+
</li>
|
|
639
|
+
))}
|
|
640
|
+
</ul>
|
|
641
|
+
</div>
|
|
642
|
+
)}
|
|
643
|
+
{uiComments.length > 0 && (
|
|
644
|
+
<div className="flex flex-col gap-2">
|
|
645
|
+
<span className="font-semibold text-slate-500 text-xs uppercase tracking-wide">{fieldLabels.comments}</span>
|
|
646
|
+
<DailyReportCommentList
|
|
647
|
+
comments={uiComments}
|
|
648
|
+
reportHubId={report.reportHubId}
|
|
649
|
+
businessDate={report.date ?? ""}
|
|
650
|
+
onDeleteComment={handleDeleteComment}
|
|
651
|
+
pendingDeleteId={pendingDeleteId}
|
|
652
|
+
onPendingDeleteIdChange={setPendingDeleteId}
|
|
653
|
+
resolvedIdMap={resolvedIdMap}
|
|
654
|
+
/>
|
|
655
|
+
</div>
|
|
656
|
+
)}
|
|
657
|
+
<div className="mt-auto" ref={formContainerRef}>
|
|
658
|
+
<DailyReportCommentForm reportHubId={report.reportHubId} businessDate={report.date ?? ""} onAddComment={handleAddCommentWrapper} />
|
|
659
|
+
</div>
|
|
660
|
+
</div>
|
|
661
|
+
<MetadataPanel report={report} visitTimeDisplay={visitTimeDisplay} categoryLabel={categoryLabel} fieldLabels={fieldLabels} />
|
|
662
|
+
</div>
|
|
663
|
+
</div>
|
|
664
|
+
</div>
|
|
665
|
+
)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Displays an error banner when daily report detail retrieval fails.
|
|
670
|
+
* 日報詳細の取得に失敗した際のエラーバナーを表示する関数。
|
|
671
|
+
*/
|
|
672
|
+
const DailyReportError = ({ itemId }: { itemId: number }) => (
|
|
673
|
+
<div className="rounded-2xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm">
|
|
674
|
+
<p>日報 {itemId.toLocaleString()} の読み込みに失敗しました。</p>
|
|
675
|
+
</div>
|
|
676
|
+
)
|