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