@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,76 @@
1
+ /**
2
+ * Core data types shared by the daily-report client and server layers.
3
+ * 日報機能の client / server 層で共有するコアデータ型定義。
4
+ */
5
+ /** 日報一覧の 1 アイテム (詳細は遅延読み込み)。 */
6
+ type DailyReportItem = {
7
+ reportHubId: number;
8
+ businessDate: string | null;
9
+ };
10
+ /** 面談者情報。 */
11
+ type DailyReportInterviewer = {
12
+ name: string;
13
+ affiliation: string | null;
14
+ };
15
+ /** レガシー形式 (JSON 埋め込み) のコメント。 */
16
+ type DailyReportComment = {
17
+ name: string | null;
18
+ text: string | null;
19
+ color: string | null;
20
+ };
21
+ /** ラベル定義。 */
22
+ type DailyReportLabelDef = {
23
+ id: number;
24
+ name: string;
25
+ color: string | null;
26
+ };
27
+ /** リレーショナルコメント 1 件。`userId` は難読化済み文字列。 */
28
+ type DailyReportCommentItem = {
29
+ id: number;
30
+ userId: string;
31
+ userName: string;
32
+ content: string;
33
+ createdAt: string;
34
+ isMine: boolean;
35
+ };
36
+ /** 日報詳細 (一覧アイテムの遅延解決結果)。`userId` は難読化済み文字列。 */
37
+ type DailyReportDetail = {
38
+ reportHubId: number;
39
+ date: string | null;
40
+ createdAt: string | null;
41
+ author: string;
42
+ userId: string;
43
+ employeeName: string | null;
44
+ updatedBy: string | null;
45
+ updatedAt: string | null;
46
+ category: string | null;
47
+ creationCategory: string | null;
48
+ visitTimeFrom: string | null;
49
+ visitTimeTo: string | null;
50
+ customerName: string | null;
51
+ interviewers: DailyReportInterviewer[];
52
+ subject: string | null;
53
+ content: string | null;
54
+ comments: DailyReportComment[];
55
+ isRead: boolean;
56
+ isStarred: boolean;
57
+ labels: DailyReportLabelDef[];
58
+ commentItems: DailyReportCommentItem[];
59
+ };
60
+ /**
61
+ * Minimal display-user shape consumed by the client layer.
62
+ * client 層が表示に使う最小ユーザー形状 (Google プロフィール等と構造互換)。
63
+ */
64
+ type DailyReportUser = {
65
+ id: string;
66
+ displayName?: string;
67
+ name?: {
68
+ familyName?: string;
69
+ givenName?: string;
70
+ };
71
+ emails?: {
72
+ value: string;
73
+ }[];
74
+ };
75
+
76
+ export type { DailyReportItem as D, DailyReportDetail as a, DailyReportUser as b, DailyReportComment as c, DailyReportCommentItem as d, DailyReportInterviewer as e, DailyReportLabelDef as f };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Core data types shared by the daily-report client and server layers.
3
+ * 日報機能の client / server 層で共有するコアデータ型定義。
4
+ */
5
+ /** 日報一覧の 1 アイテム (詳細は遅延読み込み)。 */
6
+ type DailyReportItem = {
7
+ reportHubId: number;
8
+ businessDate: string | null;
9
+ };
10
+ /** 面談者情報。 */
11
+ type DailyReportInterviewer = {
12
+ name: string;
13
+ affiliation: string | null;
14
+ };
15
+ /** レガシー形式 (JSON 埋め込み) のコメント。 */
16
+ type DailyReportComment = {
17
+ name: string | null;
18
+ text: string | null;
19
+ color: string | null;
20
+ };
21
+ /** ラベル定義。 */
22
+ type DailyReportLabelDef = {
23
+ id: number;
24
+ name: string;
25
+ color: string | null;
26
+ };
27
+ /** リレーショナルコメント 1 件。`userId` は難読化済み文字列。 */
28
+ type DailyReportCommentItem = {
29
+ id: number;
30
+ userId: string;
31
+ userName: string;
32
+ content: string;
33
+ createdAt: string;
34
+ isMine: boolean;
35
+ };
36
+ /** 日報詳細 (一覧アイテムの遅延解決結果)。`userId` は難読化済み文字列。 */
37
+ type DailyReportDetail = {
38
+ reportHubId: number;
39
+ date: string | null;
40
+ createdAt: string | null;
41
+ author: string;
42
+ userId: string;
43
+ employeeName: string | null;
44
+ updatedBy: string | null;
45
+ updatedAt: string | null;
46
+ category: string | null;
47
+ creationCategory: string | null;
48
+ visitTimeFrom: string | null;
49
+ visitTimeTo: string | null;
50
+ customerName: string | null;
51
+ interviewers: DailyReportInterviewer[];
52
+ subject: string | null;
53
+ content: string | null;
54
+ comments: DailyReportComment[];
55
+ isRead: boolean;
56
+ isStarred: boolean;
57
+ labels: DailyReportLabelDef[];
58
+ commentItems: DailyReportCommentItem[];
59
+ };
60
+ /**
61
+ * Minimal display-user shape consumed by the client layer.
62
+ * client 層が表示に使う最小ユーザー形状 (Google プロフィール等と構造互換)。
63
+ */
64
+ type DailyReportUser = {
65
+ id: string;
66
+ displayName?: string;
67
+ name?: {
68
+ familyName?: string;
69
+ givenName?: string;
70
+ };
71
+ emails?: {
72
+ value: string;
73
+ }[];
74
+ };
75
+
76
+ export type { DailyReportItem as D, DailyReportDetail as a, DailyReportUser as b, DailyReportComment as c, DailyReportCommentItem as d, DailyReportInterviewer as e, DailyReportLabelDef as f };
package/package.json ADDED
@@ -0,0 +1,108 @@
1
+ {
2
+ "name": "@aiquants/daily-report",
3
+ "version": "0.1.1",
4
+ "description": "Reusable daily-report feature package: shared types/schemas, React (Router v7) UI with virtual scrolling + optimistic updates + SSE sync, and a drizzle (mssql) server layer with DI ports (auth, user resolution, redis, external sources).",
5
+ "sideEffects": false,
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.mjs",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.js"
14
+ },
15
+ "./client": {
16
+ "types": "./dist/client.d.ts",
17
+ "import": "./dist/client.mjs",
18
+ "require": "./dist/client.js"
19
+ },
20
+ "./server": {
21
+ "types": "./dist/server.d.ts",
22
+ "import": "./dist/server.mjs",
23
+ "require": "./dist/server.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "src",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "keywords": [
33
+ "daily-report",
34
+ "react-router",
35
+ "drizzle",
36
+ "mssql",
37
+ "sse",
38
+ "virtual-scroll",
39
+ "typescript"
40
+ ],
41
+ "author": "AI Quants",
42
+ "license": "MIT",
43
+ "dependencies": {
44
+ "@heroicons/react": "^2.2.0",
45
+ "@radix-ui/react-label": "^2.1.8",
46
+ "@radix-ui/react-slot": "^1.2.4",
47
+ "@radix-ui/react-switch": "^1.2.6",
48
+ "@radix-ui/react-tabs": "^1.1.13",
49
+ "class-variance-authority": "^0.7.1",
50
+ "clsx": "^2.1.1",
51
+ "lucide-react": "^0.561.0",
52
+ "tailwind-merge": "^3.5.0"
53
+ },
54
+ "devDependencies": {
55
+ "@testing-library/jest-dom": "^6.9.1",
56
+ "@testing-library/react": "^16.3.2",
57
+ "@types/node": "^25.9.1",
58
+ "@types/react": "^19.2.7",
59
+ "drizzle-orm": "1.0.0-rc.4",
60
+ "jsdom": "^27.4.0",
61
+ "react": "^19.2.7",
62
+ "react-dom": "^19.2.7",
63
+ "react-router": "^7.13.0",
64
+ "rimraf": "^6.1.2",
65
+ "tsup": "^8.5.1",
66
+ "typescript": "^5.9.3",
67
+ "vitest": "^4.1.8",
68
+ "zod": "^3.25.76",
69
+ "@aiquants/virtualscroll": "1.18.3",
70
+ "@aiquants/swipe-overlay": "1.2.3"
71
+ },
72
+ "peerDependencies": {
73
+ "drizzle-orm": ">=1.0.0-beta.4 <2.0.0",
74
+ "react": ">=18",
75
+ "react-dom": ">=18",
76
+ "react-router": "^7.0.0",
77
+ "zod": ">=3.25.0 <5.0.0",
78
+ "@aiquants/virtualscroll": "^1.18.3",
79
+ "@aiquants/swipe-overlay": "^1.2.3"
80
+ },
81
+ "peerDependenciesMeta": {
82
+ "drizzle-orm": {
83
+ "optional": true
84
+ }
85
+ },
86
+ "engines": {
87
+ "node": ">=18.0.0",
88
+ "pnpm": ">=8.0.0"
89
+ },
90
+ "publishConfig": {
91
+ "access": "public"
92
+ },
93
+ "scripts": {
94
+ "build": "tsup",
95
+ "build:watch": "tsup --watch",
96
+ "dev": "tsup --watch",
97
+ "typecheck": "tsc --noEmit",
98
+ "clean": "rimraf dist",
99
+ "publish:patch": "pnpm version patch --no-git-tag-version --no-git-checks && pnpm publish --no-git-checks",
100
+ "publish:minor": "pnpm version minor --no-git-tag-version --no-git-checks && pnpm publish --no-git-checks",
101
+ "publish:major": "pnpm version major --no-git-tag-version --no-git-checks && pnpm publish --no-git-checks",
102
+ "lint": "biome lint src/",
103
+ "check": "biome check src/",
104
+ "check:fix": "biome check --write src/",
105
+ "test": "vitest run",
106
+ "test:coverage": "vitest run --coverage"
107
+ }
108
+ }
@@ -0,0 +1,19 @@
1
+ import type { ScrollBarThumbOverlayRenderProps } from "@aiquants/virtualscroll"
2
+ import type { CSSProperties } from "react"
3
+
4
+ /**
5
+ * Tooltip bubble that anchors to the scrollbar thumb while interacting.
6
+ * スクロール操作中にスクロールバーのサムへ連動表示するツールチップのコンポーネント。
7
+ */
8
+ export const BusinessDayThumbOverlay = ({ orientation, thumbCenter, label, isDragging, isTapScrollActive, isWheelScrollActive }: ScrollBarThumbOverlayRenderProps & { label: string; isWheelScrollActive: boolean }) => {
9
+ if (!(label.trim() && (isDragging || isTapScrollActive || isWheelScrollActive))) return null
10
+ const style: CSSProperties = orientation === "vertical" ? { top: thumbCenter, left: -16, transform: "translate(-100%, -50%)" } : { left: thumbCenter, top: -16, transform: "translate(-50%, -100%)" }
11
+
12
+ return (
13
+ <div className="pointer-events-none absolute flex items-center" style={style}>
14
+ <div className="rounded-full border border-slate-200 bg-white px-3 py-1 text-slate-700 text-xs shadow-md shadow-slate-400/30 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-100">
15
+ <span className="whitespace-nowrap font-semibold text-slate-800 dark:text-slate-100">{label.trim()}</span>
16
+ </div>
17
+ </div>
18
+ )
19
+ }
@@ -0,0 +1,81 @@
1
+ import { TrashIcon } from "@heroicons/react/24/outline"
2
+ import { useState } from "react"
3
+ import { resolveCommentColorClass, type UIComment } from "../../shared/comment-adapter"
4
+ import { fallbackText, formatToIsoDate } from "../../shared/text-utils"
5
+ import { cn } from "../ui/cn"
6
+
7
+ type DailyReportCommentItemProps = {
8
+ comment: UIComment
9
+ reportHubId: number
10
+ businessDate: string
11
+ onOptimisticDelete: (commentId: number) => void
12
+ pendingDeleteId: number | string | null
13
+ onPendingDeleteIdChange: (id: number | string | null) => void
14
+ onTrashClick: (e: React.MouseEvent | React.TouchEvent, commentId: number | string) => void
15
+ resolvedIdMap?: Map<number, number>
16
+ }
17
+
18
+ /**
19
+ * Checks if the current comment is in a pending delete state, considering temporary ID resolution.
20
+ * 現在のコメントが削除保留状態かどうかを判定する。一時IDの解決も考慮する。
21
+ */
22
+ const checkIsPendingDelete = (commentId: number | string, pendingDeleteId: number | string | null, resolvedIdMap?: Map<number, number>): boolean => {
23
+ if (pendingDeleteId === commentId) return true
24
+ // Case: commentId is temp, pendingDeleteId is resolved real ID
25
+ if (typeof commentId === "number" && commentId < 0 && resolvedIdMap?.get(commentId) === pendingDeleteId) return true
26
+ // Case: pendingDeleteId is temp, commentId is resolved real ID
27
+ if (typeof pendingDeleteId === "number" && pendingDeleteId < 0 && resolvedIdMap?.get(pendingDeleteId) === commentId) return true
28
+ return false
29
+ }
30
+
31
+ /**
32
+ * Renders a single comment item with delete functionality.
33
+ * 削除機能付きの単一コメントアイテムを描画するコンポーネント。
34
+ */
35
+ export const DailyReportCommentItem = ({ comment, reportHubId: _reportHubId, businessDate: _businessDate, onOptimisticDelete, pendingDeleteId, onPendingDeleteIdChange, onTrashClick, resolvedIdMap }: DailyReportCommentItemProps) => {
36
+ const [isDeleted, setIsDeleted] = useState(false)
37
+
38
+ const isPendingDelete = checkIsPendingDelete(comment.id, pendingDeleteId, resolvedIdMap)
39
+
40
+ const handleConfirmDelete = (e: React.MouseEvent | React.TouchEvent) => {
41
+ e.stopPropagation()
42
+ e.preventDefault()
43
+
44
+ onPendingDeleteIdChange(null)
45
+ setIsDeleted(true)
46
+
47
+ if (typeof comment.id === "number") {
48
+ // Optimistic UI Update (API call is handled by parent hook)
49
+ onOptimisticDelete(comment.id)
50
+ }
51
+ }
52
+
53
+ return (
54
+ <li data-comment-id={comment.id} className={cn("group relative rounded-xl border border-slate-200 bg-white p-3", comment.isMine && "border-blue-200 bg-blue-50", isDeleted && "hidden", isPendingDelete ? "z-50" : "z-0 hover:z-10")}>
55
+ <div className="mb-1 flex items-center justify-between">
56
+ <div className={cn("font-semibold text-xs", resolveCommentColorClass(comment.color))}>{fallbackText(comment.authorName)}</div>
57
+ <div className="flex items-center gap-2">
58
+ {comment.createdAt && <div className="text-[10px] text-slate-400">{formatToIsoDate(comment.createdAt)}</div>}
59
+ {comment.isMine && !comment.isLegacy && (
60
+ <div className="relative">
61
+ <button type="button" className={cn("transition-colors", isPendingDelete ? "text-red-500" : "text-slate-400 hover:text-red-500")} title="削除" aria-label="削除" onClick={(e) => onTrashClick(e, comment.id)}>
62
+ <TrashIcon className="h-4 w-4" />
63
+ </button>
64
+
65
+ {isPendingDelete && (
66
+ <button
67
+ type="button"
68
+ data-delete-confirmation
69
+ className="fade-in zoom-in-95 absolute top-full right-0 z-20 mt-1 animate-in whitespace-nowrap rounded-md bg-red-600 px-3 py-1.5 font-bold text-white text-xs shadow-lg duration-200 hover:bg-red-700 active:scale-95"
70
+ onClick={handleConfirmDelete}>
71
+ 削除する
72
+ </button>
73
+ )}
74
+ </div>
75
+ )}
76
+ </div>
77
+ </div>
78
+ <div className="whitespace-pre-wrap text-slate-700 text-sm leading-relaxed dark:text-slate-200">{comment.content}</div>
79
+ </li>
80
+ )
81
+ }
@@ -0,0 +1,166 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react"
2
+ import type { UIComment } from "../../shared/comment-adapter"
3
+ import { DailyReportCommentItem } from "./daily-report-comment-item"
4
+
5
+ /**
6
+ * Custom hook to manage the auto-reset behavior of the pending delete state.
7
+ * 削除保留状態の自動リセット(タイマーおよび外部クリック)を管理するカスタムフック。
8
+ */
9
+ const useAutoResetPendingDelete = (pendingDeleteId: number | string | null, onPendingDeleteIdChange: (id: number | string | null) => void) => {
10
+ const timerRef = useRef<number | null>(null)
11
+
12
+ /**
13
+ * Effect to handle global clicks and reset pending delete state if clicked outside.
14
+ * 外部クリックを監視し、削除確認ボタン以外がクリックされた場合に削除保留状態をリセットする副作用。
15
+ *
16
+ * Purpose: To cancel the delete confirmation mode when the user interacts with other parts of the UI.
17
+ * Dependencies: [pendingDeleteId, onPendingDeleteIdChange] - Re-runs when the pending ID changes.
18
+ * Cleanup: Removes event listeners for mousedown and touchstart.
19
+ */
20
+ useEffect(() => {
21
+ if (pendingDeleteId === null) return
22
+
23
+ const handleGlobalClick = (e: Event) => {
24
+ const target = e.target as HTMLElement
25
+ // Ignore clicks inside the delete confirmation button
26
+ if (target.closest("[data-delete-confirmation]")) return
27
+
28
+ onPendingDeleteIdChange(null)
29
+ }
30
+
31
+ document.addEventListener("mousedown", handleGlobalClick)
32
+ document.addEventListener("touchstart", handleGlobalClick)
33
+
34
+ return () => {
35
+ document.removeEventListener("mousedown", handleGlobalClick)
36
+ document.removeEventListener("touchstart", handleGlobalClick)
37
+ }
38
+ }, [pendingDeleteId, onPendingDeleteIdChange])
39
+
40
+ /**
41
+ * Effect to clean up the timer on unmount.
42
+ * アンマウント時にタイマーをクリーンアップする副作用。
43
+ *
44
+ * Purpose: To prevent memory leaks and state updates on unmounted components.
45
+ * Dependencies: [] - Runs only on mount/unmount.
46
+ * Cleanup: Clears the timeout if it exists.
47
+ */
48
+ useEffect(() => {
49
+ return () => {
50
+ if (timerRef.current) clearTimeout(timerRef.current)
51
+ }
52
+ }, [])
53
+
54
+ const handleTrashClick = useCallback(
55
+ (e: React.MouseEvent | React.TouchEvent, commentId: number | string) => {
56
+ e.stopPropagation()
57
+ e.preventDefault()
58
+
59
+ if (pendingDeleteId === commentId) return
60
+
61
+ onPendingDeleteIdChange(commentId)
62
+
63
+ if (timerRef.current) clearTimeout(timerRef.current)
64
+ // Auto-reset after 5 seconds
65
+ timerRef.current = window.setTimeout(() => {
66
+ onPendingDeleteIdChange(null)
67
+ timerRef.current = null
68
+ }, 5000)
69
+ },
70
+ [pendingDeleteId, onPendingDeleteIdChange],
71
+ )
72
+
73
+ return { handleTrashClick }
74
+ }
75
+
76
+ type DailyReportCommentListProps = {
77
+ comments: UIComment[]
78
+ reportHubId: number
79
+ businessDate: string
80
+ onDeleteComment?: (commentId: number) => void
81
+ pendingDeleteId: number | string | null
82
+ onPendingDeleteIdChange: (id: number | string | null) => void
83
+ resolvedIdMap?: Map<number, number>
84
+ }
85
+
86
+ /**
87
+ * Renders a list of daily report comments.
88
+ * 日報コメントのリストを描画するコンポーネント。
89
+ */
90
+ export const DailyReportCommentList = ({ comments, reportHubId, businessDate, onDeleteComment, pendingDeleteId, onPendingDeleteIdChange, resolvedIdMap }: DailyReportCommentListProps) => {
91
+ const { handleTrashClick } = useAutoResetPendingDelete(pendingDeleteId, onPendingDeleteIdChange)
92
+
93
+ if (comments.length === 0) return null
94
+
95
+ return (
96
+ <ul className="mb-3 space-y-2">
97
+ {comments.map((comment) => (
98
+ <DailyReportCommentItem
99
+ key={comment.id}
100
+ comment={comment}
101
+ reportHubId={reportHubId}
102
+ businessDate={businessDate}
103
+ onOptimisticDelete={onDeleteComment || (() => {})}
104
+ pendingDeleteId={pendingDeleteId}
105
+ onPendingDeleteIdChange={onPendingDeleteIdChange}
106
+ onTrashClick={handleTrashClick}
107
+ resolvedIdMap={resolvedIdMap}
108
+ />
109
+ ))}
110
+ </ul>
111
+ )
112
+ }
113
+
114
+ type DailyReportCommentFormProps = {
115
+ reportHubId: number
116
+ businessDate: string
117
+ onAddComment?: (content: string) => Promise<void> | void
118
+ }
119
+
120
+ /**
121
+ * Renders a form to add a new comment to a daily report.
122
+ * 日報に新しいコメントを追加するためのフォームを描画するコンポーネント。
123
+ */
124
+ export const DailyReportCommentForm = ({ reportHubId, businessDate, onAddComment }: DailyReportCommentFormProps) => {
125
+ const [isSubmitting, setIsSubmitting] = useState(false)
126
+ const formRef = useRef<HTMLFormElement>(null)
127
+
128
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
129
+ e.preventDefault()
130
+ const formData = new FormData(e.currentTarget)
131
+ const content = (formData.get("content") as string)?.trim()
132
+
133
+ if (!(content && onAddComment)) return
134
+
135
+ setIsSubmitting(true)
136
+ formRef.current?.reset()
137
+ try {
138
+ await onAddComment(content)
139
+ } catch {
140
+ if (formRef.current) {
141
+ const input = formRef.current.elements.namedItem("content") as HTMLInputElement
142
+ if (input) input.value = content
143
+ }
144
+ } finally {
145
+ setIsSubmitting(false)
146
+ }
147
+ }
148
+
149
+ return (
150
+ <form className="flex gap-2" ref={formRef} onSubmit={handleSubmit}>
151
+ <input type="hidden" name="intent" value="addComment" />
152
+ <input type="hidden" name="reportHubId" value={reportHubId} />
153
+ <input type="hidden" name="businessDate" value={businessDate} />
154
+ <input
155
+ type="text"
156
+ name="content"
157
+ placeholder="コメントを追加..."
158
+ className="flex-1 rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-sm focus:border-blue-500 focus:bg-white focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
159
+ autoComplete="off"
160
+ />
161
+ <button type="submit" className="rounded-lg bg-blue-600 px-4 py-2 font-medium text-sm text-white hover:bg-blue-700 disabled:opacity-50" disabled={isSubmitting}>
162
+ 送信
163
+ </button>
164
+ </form>
165
+ )
166
+ }