diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 4dbaac0..79700df 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -1,6 +1,6 @@ import { hc } from "hono/client"; import { useEffect, useState } from "react"; -import { HiOutlineCalendar, HiOutlineCog, HiOutlinePlus, HiOutlineUser, HiOutlineUsers } from "react-icons/hi"; +import { HiOutlineCalendar, HiOutlinePlus, HiOutlineUser, HiOutlineUsers } from "react-icons/hi"; import { NavLink } from "react-router"; import type { AppType } from "../../../server/src/main"; import Header from "../components/Header"; @@ -108,38 +108,25 @@ function ProjectCard({ project }: { project: BriefProject }) { return (
-
-
-

{project.name}

- - {project.isHost ? ( - <> - - 主催者 - - ) : ( - <> - - 参加者 - - )} - -
- - {project.isHost && ( - e.stopPropagation()} - className="btn btn-ghost btn-sm px-3 py-1 text-gray-500 transition-all hover:bg-gray-100 hover:text-gray-700" - > - - 管理 - - )} +
+

{project.name}

+ + {project.isHost ? ( + <> + + 主催者 + + ) : ( + <> + + 参加者 + + )} +
diff --git a/client/src/pages/Project.tsx b/client/src/pages/Project.tsx index c6ac92e..86331ce 100644 --- a/client/src/pages/Project.tsx +++ b/client/src/pages/Project.tsx @@ -1,7 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import dayjs from "dayjs"; import { hc } from "hono/client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { HiClipboardCheck, @@ -9,10 +9,11 @@ import { HiInformationCircle, HiOutlineCheckCircle, HiOutlineExclamationCircle, + HiOutlineTrash, } from "react-icons/hi"; import { NavLink, useNavigate, useParams } from "react-router"; import type { z } from "zod"; -import { generateDistinctColor } from "../../../common/colors"; +import { DEFAULT_PARTICIPATION_OPTION, generateDistinctColor } from "../../../common/colors"; import { editReqSchema, projectReqSchema } from "../../../common/validators"; import type { AppType } from "../../../server/src/main"; import Header from "../components/Header"; @@ -79,11 +80,7 @@ export default function ProjectPage() { const [copied, setCopied] = useState(false); const [isInfoExpanded, setIsInfoExpanded] = useState(!eventId); // 新規作成時は展開、編集時は折りたたみ - - const [participationOptions, setParticipationOptions] = useState<{ id: string; label: string; color: string }[]>([]); - const [initialParticipationOptions, setInitialParticipationOptions] = useState< - { id: string; label: string; color: string }[] - >([]); + const [isParticipationExpanded, setIsParticipationExpanded] = useState(!!eventId); // 新規作成時は折りたたみ、編集時は展開 const { register, @@ -100,7 +97,16 @@ export default function ProjectPage() { description: "", startDate: eventId ? "" : dayjs().format("YYYY-MM-DD"), endDate: eventId ? "" : dayjs().add(6, "day").format("YYYY-MM-DD"), - allowedRanges: [{ startTime: "00:00", endTime: "23:45" }], + allowedRanges: [{ startTime: "08:00", endTime: "23:00" }], + participationOptions: eventId + ? [] + : [ + { + id: crypto.randomUUID(), + label: DEFAULT_PARTICIPATION_OPTION.label, + color: DEFAULT_PARTICIPATION_OPTION.color, + }, + ], }, }); @@ -108,11 +114,20 @@ export default function ProjectPage() { trigger("name"); }; - const { fields, replace } = useFieldArray({ + const { fields: allowedRangeFields, replace } = useFieldArray({ control, name: "allowedRanges", }); + const { + fields: participationFields, + append: appendParticipation, + remove: removeParticipation, + } = useFieldArray({ + control, + keyName: "fieldId", // RHF 内部のキーの名称。デフォルトの id だと participationOptions の id と衝突するため変更 + name: "participationOptions", + }); useEffect(() => { if (!eventId) return; if (!project) return; @@ -127,15 +142,12 @@ export default function ProjectPage() { endTime: dayjs(project.allowedRanges[0].endTime).format("HH:mm"), }, ], + participationOptions: project.participationOptions.map((opt) => ({ + id: opt.id, + label: opt.label, + color: opt.color, + })), }); - // 参加形態の初期化 - const initialOptions = project.participationOptions.map((opt) => ({ - id: opt.id, - label: opt.label, - color: opt.color, - })); - setParticipationOptions(initialOptions); - setInitialParticipationOptions(initialOptions); }, [eventId, project, reset]); // 送信処理 @@ -158,13 +170,11 @@ export default function ProjectPage() { startDate: startDateTime, endDate: endDateTime, allowedRanges: rangeWithDateTime ?? [], - participationOptions: participationOptions - .filter((opt) => opt.label.trim()) // 空のラベルは除外 - .map((opt) => ({ - id: opt.id, - label: opt.label.trim(), - color: opt.color, - })), + participationOptions: (data.participationOptions ?? []).map((opt) => ({ + id: opt.id, + label: opt.label.trim(), + color: opt.color, + })), } satisfies z.infer; if (!project) { @@ -207,11 +217,22 @@ export default function ProjectPage() { }); setTimeout(() => setToast(null), 3000); } else { + let errorMessage = "更新に失敗しました。"; + try { + const data = await res.json(); + if (data && typeof data.message === "string" && data.message.trim()) { + errorMessage = data.message.trim(); + } else if (res.status === 403) { + errorMessage = "権限がありません。"; + } + } catch (_) { + if (res.status === 403) errorMessage = "権限がありません。"; + } setToast({ - message: res.status === 403 ? "権限がありません。" : "更新に失敗しました。", + message: errorMessage, variant: "error", }); - setTimeout(() => setToast(null), 3000); + setTimeout(() => setToast(null), 4000); } } }; @@ -228,33 +249,6 @@ export default function ProjectPage() { } }, [loading, project, isHost, eventId, navigate]); - // 参加形態の変更を検知 TODO: 実装の改善、rhf での管理 - const hasParticipationOptionsChanged = useMemo(() => { - if (!eventId) return false; // 新規作成の場合は参加形態の変更を検知しない - - // 数が違う場合 - if (participationOptions.length !== initialParticipationOptions.length) return true; - - // 各要素を比較 - for (let i = 0; i < participationOptions.length; i++) { - const current = participationOptions[i]; - const initial = initialParticipationOptions.find((opt) => opt.id === current.id); - - // IDが見つからない(新規追加された) - if (!initial) return true; - - // label または color が変更された - if (current.label !== initial.label || current.color !== initial.color) return true; - } - - // 削除された要素がないかチェック - for (const initial of initialParticipationOptions) { - if (!participationOptions.find((opt) => opt.id === initial.id)) return true; - } - - return false; - }, [participationOptions, initialParticipationOptions, eventId]); - return ( <>
@@ -275,7 +269,7 @@ export default function ProjectPage() {

{project ? `${project.name} の編集` : "イベントの作成"}

-
+
- {!project || (project && project.guests.length === 0) ? ( - <> -
- setIsInfoExpanded(e.target.checked)} - /> -
- - 開始日・終了日/時間帯について -
-
-

- イツヒマでは、主催者側で候補日程を設定せずに日程調整します。 -
- ここでは、参加者の日程を知りたい日付の範囲と時間帯の範囲を設定してください。 -
- 詳しくは、 - - 使い方ページ - - をご覧ください。 -

-
-
-
-
- - - {errors.startDate &&

{errors.startDate.message}

} -
-
- - + setIsInfoExpanded(e.target.checked)} /> +
+ + 開始日・終了日/時間帯について +
+
+

+ イツヒマでは、主催者側で候補日程を設定せずに日程調整します。 +
+ ここでは、参加者の日程を知りたい日付の範囲と時間帯の範囲を設定してください。 +
+ 詳しくは、 + + 使い方ページ + + をご覧ください。 +

+
+
+
+
0 ? "tooltip tooltip-top flex-1" : "flex-1"} + data-tip={ + project && project.guests.length > 0 + ? "すでに日程を登録したユーザーがいるため、開始日の編集はできません" + : "" + } + > + + 0 ? "cursor-not-allowed opacity-60" : ""}`} + onFocus={handleFieldFocus} + disabled={!!(project && project.guests.length > 0)} + /> + {errors.startDate &&

{errors.startDate.message}

} +
+
0 ? "tooltip tooltip-top flex-1" : "flex-1"} + data-tip={ + project && project.guests.length > 0 + ? "すでに日程を登録したユーザーがいるため、終了日の編集はできません" + : "" + } + > + + 0 ? "cursor-not-allowed opacity-60" : ""}`} + onFocus={handleFieldFocus} + disabled={!!(project && project.guests.length > 0)} + /> + {errors.endDate &&

{errors.endDate.message}

} +
+
+
+ 時間帯 +
0 ? "tooltip tooltip-top w-full" : "w-full"} + data-tip={ + project && project.guests.length > 0 + ? "すでに日程を登録したユーザーがいるため、時間帯の編集はできません" + : "" + } + > +
+
+ { - replace([ - { - startTime: `${e.target.value}:${fields[0].startTime.split(":")[1]}`, - endTime: fields[0].endTime, - }, - ]); - }} - onFocus={handleFieldFocus} - > - + {Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, "0")).map((h) => ( + - {Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, "0")).map((h) => ( - - ))} - - + -
- -
- +
+ +
+ - + -
+ ))} +
- {errors.allowedRanges && typeof errors.allowedRanges?.message === "string" && ( -

{errors.allowedRanges.message}

- )} -
- - ) : ( -

すでにデータを登録したユーザーがいるため、日時の編集はできません。

- )} -
- 参加形態(任意) -

- 参加形態を設定すると、参加者は「対面」「オンライン」などの形態を選んで日程を登録できます。 - 設定しない場合は、デフォルトの参加形態が自動的に作成されます。 -

+
+
+ {errors.allowedRanges && typeof errors.allowedRanges?.message === "string" && ( +

{errors.allowedRanges.message}

+ )} + +
+ setIsParticipationExpanded(e.target.checked)} + /> +
参加形態の設定 (任意)
+
+
+

+ 参加形態を設定すると、参加者は「対面」「オンライン」などの形態を選んで日程を登録できます。 +

+ + {participationFields.map((field, index) => { + const hasSlots = project?.guests.some((guest) => + guest.slots.some((slot) => slot.participationOptionId === field.id), + ); + const isLastOption = participationFields.length === 1; + const cannotDelete = hasSlots || isLastOption; + const tooltipMessage = hasSlots + ? "すでにこの参加形態の日程が登録されているため、削除できません" + : isLastOption + ? "最低1つの参加形態が必要です" + : ""; + return ( +
+
+ + + { + // 値を変更していない場合でも空ならエラー表示させるため手動で検証 + trigger(`participationOptions.${index}.label` as const); + }} + /> +
+ +
+
+ {errors.participationOptions?.[index]?.label && ( +

+ {errors.participationOptions[index]?.label?.message as string} +

+ )} + {errors.participationOptions?.[index]?.color && ( +

+ {errors.participationOptions[index]?.color?.message as string} +

+ )} +
+ ); + })} - {participationOptions.map((option, index) => ( -
- { - const newOptions = [...participationOptions]; - newOptions[index].color = e.target.value; - setParticipationOptions(newOptions); - }} - className="h-10 w-10 cursor-pointer rounded border-0" - /> - { - const newOptions = [...participationOptions]; - newOptions[index].label = e.target.value; - setParticipationOptions(newOptions); - }} - placeholder="参加形態名(例:対面、オンライン)" - className="input input-bordered flex-1 text-base" - /> -
- ))} - - -
+ +
+
{project && (
イベントの削除 @@ -533,7 +573,7 @@ export default function ProjectPage() {
)} -
- - ホームに戻る - -
diff --git a/client/src/pages/eventId/Submission.tsx b/client/src/pages/eventId/Submission.tsx index d09e118..a422e60 100644 --- a/client/src/pages/eventId/Submission.tsx +++ b/client/src/pages/eventId/Submission.tsx @@ -19,6 +19,17 @@ const client = hc(API_ENDPOINT); export type EditingSlot = Pick; +const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: Number.parseInt(result[1], 16), + g: Number.parseInt(result[2], 16), + b: Number.parseInt(result[3], 16), + } + : null; +}; + export default function SubmissionPage() { const { eventId: projectId } = useParams<{ eventId: string }>(); const [project, setProject] = useState(null); @@ -227,18 +238,35 @@ export default function SubmissionPage() { {editMode && project.participationOptions.length > 1 && selectedParticipationOptionId !== null && (
- 参加形態を選択 - + 参加形態を選択 +
+ {project.participationOptions.map((opt) => { + const rgb = hexToRgb(opt.color); + const lightBg = rgb + ? `rgba(${rgb.r * 0.2 + 255 * 0.8}, ${rgb.g * 0.2 + 255 * 0.8}, ${rgb.b * 0.2 + 255 * 0.8}, 1)` + : undefined; + + return ( + + ); + })} +
)} ranges.every(({ startTime, endTime }) => isQuarterHour(startTime) && isQuarterHour(endTime)), { message: "開始時刻と終了時刻は15分単位で入力してください", }), - participationOptions: z.array(participationOptionCreateSchema).optional(), + participationOptions: z.array(participationOptionCreateSchema).min(1, "参加形態は最低1つ必要です"), }); export const projectReqSchema = baseProjectReqSchema.refine( diff --git a/package-lock.json b/package-lock.json index f224bda..b3bf946 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1161,9 +1161,10 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.1.tgz", - "integrity": "sha512-h44e5s+ByUriaRIbeS/C74O8v90m0A95luyYQGMF7KEn96KkYMXO7bZAwombzTpjQTU4e0TkU8U1WBIXlwuwtA==", + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", + "integrity": "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==", + "license": "MIT", "engines": { "node": ">=18.14.1" }, @@ -1172,9 +1173,10 @@ } }, "node_modules/@hono/zod-validator": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.7.2.tgz", - "integrity": "sha512-ub5eL/NeZ4eLZawu78JpW/J+dugDAYhwqUIdp9KYScI6PZECij4Hx4UsrthlEUutqDDhPwRI0MscUfNkvn/mqQ==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.7.5.tgz", + "integrity": "sha512-n4l4hutkfYU07PzRUHBOVzUEn38VSfrh+UVE5d0w4lyfWDOEhzxIupqo5iakRiJL44c3vTuFJBvcmUl8b9agIA==", + "license": "MIT", "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" @@ -2583,9 +2585,10 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.9.6.tgz", - "integrity": "sha512-doVjXhSFvYZ7y0dNokjwwSahcrAfdz+/BCLvAMa/vHLzjj8+CFyV5xteThGUsKdkaasgN+gF2mUxao+SGLpUeA==", + "version": "4.10.6", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.6.tgz", + "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==", + "license": "MIT", "engines": { "node": ">=16.9.0" } diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index eefea22..a74c6bc 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -1,11 +1,9 @@ -import { randomUUID } from "node:crypto"; import { zValidator } from "@hono/zod-validator"; import dotenv from "dotenv"; import { Hono } from "hono"; import { getSignedCookie, setSignedCookie } from "hono/cookie"; import { nanoid } from "nanoid"; import { z } from "zod"; -import { DEFAULT_PARTICIPATION_OPTION } from "../../../common/colors.js"; import { editReqSchema, projectReqSchema, submitReqSchema } from "../../../common/validators.js"; import { cookieOptions, prisma } from "../main.js"; @@ -25,22 +23,6 @@ const router = new Hono() try { const data = c.req.valid("json"); - // 参加形態の処理(指定がない場合はデフォルトを作成) - const participationOptionsData = - data.participationOptions && data.participationOptions.length > 0 - ? data.participationOptions.map((opt) => ({ - id: opt.id, // フロントエンドで生成された UUID をそのまま使用 - label: opt.label, - color: opt.color, - })) - : [ - { - id: randomUUID(), // デフォルト作成時のみサーバーで生成 - label: DEFAULT_PARTICIPATION_OPTION.label, - color: DEFAULT_PARTICIPATION_OPTION.color, - }, - ]; - const event = await prisma.project.create({ data: { id: nanoid(), @@ -60,7 +42,11 @@ const router = new Hono() }, }, participationOptions: { - create: participationOptionsData, + create: data.participationOptions.map((opt) => ({ + id: opt.id, + label: opt.label, + color: opt.color, + })), }, }, include: { hosts: true, participationOptions: true },