From 8edca63c33294e02f671e037a9e29a0a6f1a17d6 Mon Sep 17 00:00:00 2001 From: luli Date: Tue, 25 Nov 2025 14:29:54 +0900 Subject: [PATCH 001/116] =?UTF-8?q?feat:=20#109=20datepicker=20=ED=95=9C?= =?UTF-8?q?=EA=B5=AD=20locale=20=EC=A0=81=EC=9A=A9=EC=9A=A9=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 51 +++++++++++++++++++++++++++++++++++------------ package.json | 1 + 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4017b021..c16759aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "axios": "^1.13.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lodash": "^4.17.21", "next": "^15.5.6", "react": "19.2.0", @@ -114,6 +115,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -4483,6 +4485,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -4619,6 +4622,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.6" }, @@ -4653,7 +4657,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4674,7 +4677,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -4746,8 +4748,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4935,6 +4936,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5019,6 +5021,7 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -5549,6 +5552,7 @@ "integrity": "sha512-SdrcvwvP6q8n82cS2BthbZuHGFPHeKkjbpeIRhGaeV8hJ8P0swWFx5lUZ/Vnd7G0CsfL6m4/3lOaqd/n12vtZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/mocker": "4.0.6", "@vitest/utils": "4.0.6", @@ -5572,6 +5576,7 @@ "integrity": "sha512-u6DliDabPQYXz8U4ZwnCvr9q2kJnribkenO6FK0qv5Gu/m1X884JOf0IZ71x7rLrmSsoP3YD3hVnNutAbhEX3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/browser": "4.0.6", "@vitest/mocker": "4.0.6", @@ -5843,6 +5848,7 @@ "integrity": "sha512-trPk5qpd7Jj+AiLZbV/e+KiiaGXZ8ECsRxtnPnCrJr9OW2mLB72Cb824IXgxVz/mVU3Aj4VebY+tDTPn++j1Og==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.6", "pathe": "^2.0.3" @@ -6148,6 +6154,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6213,6 +6220,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7207,6 +7215,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -8253,7 +8262,6 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -8340,8 +8348,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-converter": { "version": "0.2.0", @@ -8773,6 +8780,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8837,6 +8845,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8937,6 +8946,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9038,6 +9048,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -11593,6 +11604,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12099,7 +12111,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -13603,6 +13614,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13935,6 +13947,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14054,7 +14067,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -14070,7 +14082,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -14081,7 +14092,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -14094,8 +14104,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/process": { "version": "0.11.10", @@ -14259,6 +14268,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14328,6 +14338,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14340,6 +14351,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -14372,6 +14384,7 @@ "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14979,6 +14992,7 @@ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -15215,6 +15229,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15681,6 +15696,7 @@ "integrity": "sha512-s3qY17stuSxU9TFdSHWF2VWJiDKrhZsdlM9M9/APBLT+3kbTlfCPuaNONE0UETgz5tiZLIrC9K4RYjJ8c6PHPA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^1.6.0", @@ -16425,6 +16441,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16616,6 +16633,7 @@ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=10" }, @@ -16707,6 +16725,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17013,6 +17032,7 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17106,6 +17126,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17119,6 +17140,7 @@ "integrity": "sha512-gR7INfiVRwnEOkCk47faros/9McCZMp5LM+OMNWGLaDBSvJxIzwjgNFufkuePBNaesGRnLmNfW+ddbUJRZn0nQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.6", "@vitest/mocker": "4.0.6", @@ -17343,6 +17365,7 @@ "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -17421,6 +17444,7 @@ "integrity": "sha512-khZGfAeJx6I8K9zKohEWWYN6KDlVw2DHownoe+6Vtwj1LP9WFgegXnVMSkZ/dBEBtXFwrkkydsaPFlB7f8wU2A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-html-community": "0.0.8", "html-entities": "^2.1.0", @@ -17815,6 +17839,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 36d3bc3f..feeebd96 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "axios": "^1.13.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lodash": "^4.17.21", "next": "^15.5.6", "react": "19.2.0", From 2687f092410d810f0e770a03de5e812b3c7ccf0e Mon Sep 17 00:00:00 2001 From: luli Date: Tue, 25 Nov 2025 14:30:33 +0900 Subject: [PATCH 002/116] =?UTF-8?q?refactor:=20#109=20datepicker=20locale?= =?UTF-8?q?=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9,=20timepicker=20=EC=8B=9C=EA=B0=84=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/date-timepicker/single-datepicker.tsx | 2 ++ src/components/ui/date-timepicker/timepicker.tsx | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/ui/date-timepicker/single-datepicker.tsx b/src/components/ui/date-timepicker/single-datepicker.tsx index dd59eddc..b137d0a3 100644 --- a/src/components/ui/date-timepicker/single-datepicker.tsx +++ b/src/components/ui/date-timepicker/single-datepicker.tsx @@ -4,6 +4,7 @@ import Datepicker from "react-datepicker"; import { useDatepickerDate } from "@/hooks"; import CustomHeader from "./custom-datepicker-header"; import { cuttingDayString, otherMonthIndicator } from "@/lib/utils"; +import { ko } from "date-fns/locale"; interface CustomSingleDatepickerProps { startDate: Date | null; @@ -29,6 +30,7 @@ export default function CustomSingleDatepicker({ onMonthChange={handleMonthChange} dayClassName={date => otherMonthIndicator(date, currentMonth, currentYear)} minDate={useMinDate ? new Date() : undefined} + locale={ko} /> ); } diff --git a/src/components/ui/date-timepicker/timepicker.tsx b/src/components/ui/date-timepicker/timepicker.tsx index 1a38a4c3..ea8d9ac3 100644 --- a/src/components/ui/date-timepicker/timepicker.tsx +++ b/src/components/ui/date-timepicker/timepicker.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useRef, useState } from "react"; import Button from "../button/button"; import cn from "@/lib/cn"; -import { isEmpty } from "@/lib/utils"; const TIME_PERIOD = { AM: "오전", @@ -50,13 +49,12 @@ export default function CustomTimePicker({ selectedTime, onTimeChange }: CustomT }, [selectedTime]); const handleTimeClick = (index: number, newPeriod?: TimePeriod) => { - if (isEmpty(newPeriod)) newPeriod = TIME_PERIOD.AM; + const currentPeriod = newPeriod || period; setSelectedIndex(index); const time = times[index]; let hour = time.hour % 12; - if (newPeriod === TIME_PERIOD.PM) hour += 12; - if (newPeriod === TIME_PERIOD.AM && hour === 12) hour = 0; - + if (currentPeriod === TIME_PERIOD.PM) hour += 12; + if (currentPeriod === TIME_PERIOD.AM && hour === 12) hour = 0; const newTime = new Date(); newTime.setHours(hour, time.minute, 0, 0); onTimeChange(newTime); @@ -79,6 +77,7 @@ export default function CustomTimePicker({ selectedTime, onTimeChange }: CustomT )} intent="primary" onClick={() => handlePeriodChange(p)} + type="button" > {p} @@ -97,6 +96,7 @@ export default function CustomTimePicker({ selectedTime, onTimeChange }: CustomT ? "font-semibold text-pink-500" : "text-gray-300 hover:bg-gray-700", )} + type="button" > {time.label} From 1b54f99a80d78055e34edf56cc593871c0c5fb6f Mon Sep 17 00:00:00 2001 From: luli Date: Tue, 25 Nov 2025 14:32:18 +0900 Subject: [PATCH 003/116] =?UTF-8?q?feat:=20#109=20tasklist=20type=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/date-format-type.ts | 9 +++++++++ src/types/task.ts | 19 ++++++++++++++----- src/types/tasklist.ts | 9 +++++++-- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/types/date-format-type.ts b/src/types/date-format-type.ts index b9b79c84..338a097a 100644 --- a/src/types/date-format-type.ts +++ b/src/types/date-format-type.ts @@ -44,3 +44,12 @@ export const DailyFrequency = [ ]; export type DailyFrequencyArray = (typeof DailyFrequency)[number]; + +export const FrequencyType = { + Once: "ONCE", + Daily: "DAILY", + Weekly: "WEEKLY", + Monthly: "MONTHLY", +} as const; + +export type FrequencyTypeValue = (typeof FrequencyType)[keyof typeof FrequencyType]; diff --git a/src/types/task.ts b/src/types/task.ts index e32ad9a6..e03d5aff 100644 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -41,20 +41,29 @@ export type Task = TaskCommonValue & { // task 상세정보 export type TaskDetail = Task & { - recurring: Recurring; + recurring: RecurringType; }; -export type Recurring = { +type RecurringCommon = { id: number; name: string; - description: string | null; createdAt: string; updatedAt: string; startDate: string; frequencyType: string; - weekDays: string[]; - monthDay: number; taskListId: number; groupId: number; writerId: number; }; + +export type RecurringType = RecurringCommon & { + description: string | null; + weekDays: number[]; + monthDay: number; +}; + +export type RecurringPostType = RecurringCommon & { + description: string; + weekDays?: number[]; + monthDay?: number; +}; diff --git a/src/types/tasklist.ts b/src/types/tasklist.ts index 99778c46..e719b1c8 100644 --- a/src/types/tasklist.ts +++ b/src/types/tasklist.ts @@ -4,7 +4,12 @@ import { Task } from "./task"; * @author luli */ -type Member = { +export interface MemberPermissionProps { + groupId: number; + userId?: number; +} + +export type MemberInfo = { userId: number; groupId: number; userName: string; @@ -20,7 +25,7 @@ export type GroupTaskList = { createdAt: string; updatedAt: string; teamId: string; - members: Member[]; + members: MemberInfo[]; taskLists: TaskList[]; }; From 6ab1389e2aa4ad607ecda123fb30729b10e36082 Mon Sep 17 00:00:00 2001 From: luli Date: Tue, 25 Nov 2025 14:33:10 +0900 Subject: [PATCH 004/116] =?UTF-8?q?refactor:=20#109=20zod=20tasklist=20sch?= =?UTF-8?q?ema=20string=EA=B0=92=EC=97=90=20trim=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/schema.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 2c63e37b..eca449e2 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -70,10 +70,12 @@ export const taskSchema = z .object({ name: z .string() + .trim() .min(1, { error: "제목은 필수 입력입니다." }) .max(30, { error: "제목은 최대 30자까지 가능합니다." }), startDate: z .string() + .trim() .min(1, { error: "시작 날짜와 시간은 필수 입력입니다.." }) .refine(val => !isNaN(new Date(val).getTime()), { error: "유효한 날짜 형식이 아닙니다." }) .refine(val => new Date(val) > new Date(), { @@ -81,12 +83,14 @@ export const taskSchema = z }), frequencyType: z .string() + .trim() .min(1, { error: "반복 설정 값은 필수 값입니다." }) .refine(val => frequency.includes(val), { error: "유효한 반복 설정 값이 아닙니다." }), weekDays: z.array(z.number()).optional(), monthDay: z.number().optional(), description: z .string() + .trim() .refine(val => val.length <= 255, { error: "메모는 최대 255자까지 가능합니다." }), }) .refine( @@ -108,7 +112,6 @@ export const taskSchema = z } return true; }, - { error: "반복 날짜는 1~31일 사이로 선택하여야 합니다.", path: ["frequencyType"] }, ); From 8c76306fe472b2bc67e6d1552010dac787b1f630 Mon Sep 17 00:00:00 2001 From: luli Date: Tue, 25 Nov 2025 14:33:59 +0900 Subject: [PATCH 005/116] =?UTF-8?q?feat:=20#109=20date=20ISOString=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=EC=8B=9C=20=ED=95=9C=EA=B5=AD=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=A0=81=EC=9A=A9=20=EB=B2=84=EC=A0=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=8F=AC=EB=A7=B7=ED=95=B4=EC=A3=BC=EB=8A=94=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/utils.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 7aeefce8..b55ad922 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -85,9 +85,9 @@ export const otherMonthIndicator = (date: Date, currentMonth: number, currentYea * @param date */ export const formatDateForToMonthAndDays = (date: Date | string | undefined): string => { - if (isEmpty(date)) return ""; + if (!date) return ""; - const resultDate = strToDate(date as Date | string); + const resultDate = strToDate(date); return resultDate .toLocaleDateString("ko-KR", { month: "long", @@ -103,9 +103,9 @@ export const formatDateForToMonthAndDays = (date: Date | string | undefined): st * @param date */ export const formatDateToFullStr = ({ date, type = "korean" }: DateFullProps): string => { - if (isEmpty(date)) return ""; + if (!date) return ""; - const resultDate = strToDate(date as Date | string); + const resultDate = strToDate(date); let resultDateStr = ""; if (type === "korean") { // `YYYY년 MM월 DD일` @@ -168,9 +168,9 @@ export const isNoAuthURL = (config: AxiosRequestConfig) => { * @param date */ export const formatTimeToStr = ({ date, type = "colon" }: DateTimeProps): string => { - if (isEmpty(date)) return ""; + if (!date) return ""; - const resultDate = strToDate(date as Date | string); + const resultDate = strToDate(date); let resultTimeStr = ""; if (type === "colon") { // "HH:mm" @@ -179,7 +179,7 @@ export const formatTimeToStr = ({ date, type = "colon" }: DateTimeProps): string minute: "2-digit", }); } else if (type === "meridiem") { - // 오전 or 오후 HH:mm + // 오전 or 오후 hh:mm resultTimeStr = resultDate.toLocaleTimeString("ko-KR", { hour12: true, hour: "numeric", @@ -231,3 +231,15 @@ export const isEmpty = (value: unknown): boolean => { export const getFrequencyLabel = (frequency: string): string => { return FrequencyOptions.find(fo => fo.value === frequency)?.label ?? ""; }; + +/** + * Date 시간타입 korea locale 적용한 ISO date로 변환. + * @author luli + * @param date + */ +export const formatDateToISOString = (date: Date | string) => { + const resultDate = strToDate(date); + const tzOffset = 9 * 60; + const localDate = new Date(resultDate.getTime() + tzOffset * 60 * 1000); + return localDate.toISOString().replace("Z", "+09:00"); +}; From 4430cdee177ec667cb1299cdd94f98bb2145768c Mon Sep 17 00:00:00 2001 From: luli Date: Tue, 25 Nov 2025 14:35:12 +0900 Subject: [PATCH 006/116] =?UTF-8?q?refactor:=20#109=20dropdown=20type=20?= =?UTF-8?q?=3D=20button=20=EA=B0=92=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/dropdown/trigger-select.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ui/dropdown/trigger-select.tsx b/src/components/ui/dropdown/trigger-select.tsx index 7dfb3ba2..026c9cea 100644 --- a/src/components/ui/dropdown/trigger-select.tsx +++ b/src/components/ui/dropdown/trigger-select.tsx @@ -20,6 +20,7 @@ export function TriggerSelect({ dropDownTriggerStyle({ size: ctx?.size, intent, className }), ctx?.isOpen && "bg-gray-700", )} + type="button" > {selectedLabel} {isIcon && children} From 39f8e49b56b928162b1d4ad930e41db8fedccc6a Mon Sep 17 00:00:00 2001 From: luli Date: Tue, 25 Nov 2025 14:36:43 +0900 Subject: [PATCH 007/116] =?UTF-8?q?feat:=20#124=20tasklist=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20(=EB=AA=A9=EB=A1=9D,?= =?UTF-8?q?=20=EC=83=81=EC=84=B8)=20=EC=9E=91=EC=84=B1=20=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/tasklist/task-add-modal.tsx | 15 ++-- .../tasklist/task-recurring-add-modal.tsx | 77 ++++++++++++------- 2 files changed, 59 insertions(+), 33 deletions(-) diff --git a/src/components/features/tasklist/task-add-modal.tsx b/src/components/features/tasklist/task-add-modal.tsx index fac75d91..e23eb3ce 100644 --- a/src/components/features/tasklist/task-add-modal.tsx +++ b/src/components/features/tasklist/task-add-modal.tsx @@ -7,16 +7,21 @@ import { ChangeEvent, useState } from "react"; interface TaskAddProps { isOpen: boolean; onClose: () => void; - onSubmit: (value: string) => void; + onSubmit: (value: string) => Promise; } export default function TaskAddTemplate({ isOpen, onClose, onSubmit }: TaskAddProps) { const [taskName, setTaskName] = useState(""); - const handleMakeClick = () => { - onSubmit(taskName); - setTaskName(""); - onClose(); + const handleMakeClick = async () => { + const resultName = taskName.trim() ?? ""; + + try { + await onSubmit(resultName); + onClose(); + } catch (error) { + console.error("제출 실패", error); + } }; return ( diff --git a/src/components/features/tasklist/task-recurring-add-modal.tsx b/src/components/features/tasklist/task-recurring-add-modal.tsx index 55d9469e..dfbc47dc 100644 --- a/src/components/features/tasklist/task-recurring-add-modal.tsx +++ b/src/components/features/tasklist/task-recurring-add-modal.tsx @@ -4,7 +4,7 @@ import { DropdownOption } from "@/types/option"; import { useEffect, useRef, useState } from "react"; import { Controller, SubmitHandler, useFormContext } from "react-hook-form"; import IcDropdown from "@/assets/icons/ic-dropdown.svg"; -import { FrequencyOptions } from "@/types/date-format-type"; +import { FrequencyOptions, FrequencyType } from "@/types/date-format-type"; import CustomSingleDatepicker from "@/components/ui/date-timepicker/single-datepicker"; import DailyFrequencyOptions from "./daily-frequency-options"; import z4 from "zod/v4"; @@ -21,10 +21,25 @@ export default function TaskRecurringAddModal({ isOpen, onClose, onSubmit }: Tas const [dayIndexArray, setDayIndexArray] = useState([]); const handleSubmit: SubmitHandler> = submitData => { + if (submitData.frequencyType !== FrequencyType.Weekly) { + delete submitData.weekDays; + } + + if (submitData.frequencyType !== FrequencyType.Monthly) { + delete submitData.monthDay; + } + + let monthDayResult; + if (submitData.frequencyType === FrequencyType.Monthly) { + const date = new Date(submitData.startDate).getDate(); + monthDayResult = parseInt(date.toString()); + } + const transformedData = { ...submitData, description: submitData.description || "", - weekDays: dayIndexArray, + ...(submitData.frequencyType === FrequencyType.Monthly && { monthDay: monthDayResult }), + ...(submitData.frequencyType === FrequencyType.Weekly && { weekDays: dayIndexArray }), }; onSubmit(transformedData); }; @@ -32,7 +47,7 @@ export default function TaskRecurringAddModal({ isOpen, onClose, onSubmit }: Tas const defaultValues = { name: "", description: "", - frequencyType: "ONCE", + frequencyType: FrequencyType.Once, monthDay: 1, startDate: "", weekDays: [], @@ -62,7 +77,6 @@ function FormField({ setDayIndexArray: React.Dispatch>; }) { const { - register, setValue, control, formState: { errors }, @@ -90,7 +104,7 @@ function FormField({ setValue("frequencyType", option.value, { shouldValidate: true }); - if (option.value === "WEEKLY") { + if (option.value === FrequencyType.Weekly) { setIsWeekpickerOpen(true); } else { setIsWeekpickerOpen(false); @@ -249,29 +263,36 @@ function FormField({
- - option.value === frequencyTypeValue)?.label - : "" - } - > - - - - - - {selectOptions.map(option => ( - - {option.label} - - ))} - - + ( + + option.value === frequencyTypeValue)?.label + : "" + } + > + + + + + + {selectOptions.map(option => ( + + {option.label} + + ))} + + + )} + />
{isWeekpickerOpen && ( From f13ead8a86d827dd0acb7fc14ad37cfbccf09f42 Mon Sep 17 00:00:00 2001 From: luli Date: Tue, 25 Nov 2025 14:38:50 +0900 Subject: [PATCH 008/116] =?UTF-8?q?feat:=20#116=20tasklist=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0,=20=EA=B7=B8=EB=A3=B9=EA=B6=8C=ED=95=9C=20provider?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tasklist/@detail/(.)[taskId]/page.tsx | 5 +- .../team/[id]/tasklist/[taskId]/page.tsx | 5 +- src/app/(routes)/team/[id]/tasklist/page.tsx | 14 ++-- .../tasklist/team-permission-provider.tsx | 68 +++++++++++++++++++ 4 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 src/app/(routes)/team/[id]/tasklist/team-permission-provider.tsx diff --git a/src/app/(routes)/team/[id]/tasklist/@detail/(.)[taskId]/page.tsx b/src/app/(routes)/team/[id]/tasklist/@detail/(.)[taskId]/page.tsx index 0f061bc8..bf1b4c9e 100644 --- a/src/app/(routes)/team/[id]/tasklist/@detail/(.)[taskId]/page.tsx +++ b/src/app/(routes)/team/[id]/tasklist/@detail/(.)[taskId]/page.tsx @@ -1,4 +1,4 @@ -// import TaskDetailWrapper from "@/components/features/tasklist/task-detail-wrapper"; +import TaskDetailWrapper from "@/components/features/tasklist/task-detail-wrapper"; export default async function TaskDetailPage({ params, @@ -7,6 +7,5 @@ export default async function TaskDetailPage({ }) { const { taskId, id } = await params; - return

{taskId + id}

; - // ; + return ; } diff --git a/src/app/(routes)/team/[id]/tasklist/[taskId]/page.tsx b/src/app/(routes)/team/[id]/tasklist/[taskId]/page.tsx index 365bf7f7..bcdf54c4 100644 --- a/src/app/(routes)/team/[id]/tasklist/[taskId]/page.tsx +++ b/src/app/(routes)/team/[id]/tasklist/[taskId]/page.tsx @@ -1,4 +1,4 @@ -// import TaskDetailWrapper from "@/components/features/tasklist/task-detail-wrapper"; +import TaskDetailWrapper from "@/components/features/tasklist/task-detail-wrapper"; export default async function TaskDetailPage({ params, @@ -8,6 +8,5 @@ export default async function TaskDetailPage({ const { taskId, id } = await params; //모달 아님 - return

{taskId + id}

; - // ; + return ; } diff --git a/src/app/(routes)/team/[id]/tasklist/page.tsx b/src/app/(routes)/team/[id]/tasklist/page.tsx index fb6d4034..a0952b23 100644 --- a/src/app/(routes)/team/[id]/tasklist/page.tsx +++ b/src/app/(routes)/team/[id]/tasklist/page.tsx @@ -1,9 +1,10 @@ import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"; import TaskListClient from "./tasklist-client"; -import { isEmpty } from "@/lib/utils"; +import { formatDateToISOString, isEmpty } from "@/lib/utils"; import { GroupTaskList } from "@/types/tasklist"; import { redirect } from "next/navigation"; import { getGroupTaskListsforServer, getTaskListForServer } from "@/api/tasklist/index-server"; +import TeamPermissionProvider from "./team-permission-provider"; export default async function TasklistPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -20,6 +21,7 @@ export default async function TasklistPage({ params }: { params: Promise<{ id: s }); const currentDate = new Date(); + currentDate.setHours(10, 0, 0, 0); const dateString = currentDate.toISOString().split("T")[0]; let firstTaskListId: number | null = null; @@ -38,7 +40,7 @@ export default async function TasklistPage({ params }: { params: Promise<{ id: s getTaskListForServer({ groupId: groupId, taskListId: firstTaskListId, - date: currentDate.toString(), + date: formatDateToISOString(currentDate), }), }); } @@ -46,9 +48,11 @@ export default async function TasklistPage({ params }: { params: Promise<{ id: s return ( <> - - - + + + + + ); } diff --git a/src/app/(routes)/team/[id]/tasklist/team-permission-provider.tsx b/src/app/(routes)/team/[id]/tasklist/team-permission-provider.tsx new file mode 100644 index 00000000..e6f4edb3 --- /dev/null +++ b/src/app/(routes)/team/[id]/tasklist/team-permission-provider.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { usetMemberPermission } from "@/hooks/taskList/use-tasklist"; +import { useAuthStore } from "@/store/auth.store"; +import { MemberInfo } from "@/types/tasklist"; +import React, { createContext, useContext, useEffect, useState } from "react"; + +type TeamPermissionType = { + isTeam: boolean; + isLoading: boolean; + memberInfo: MemberInfo | null; + refresh: () => Promise; +}; + +const TeamPermissionContext = createContext(undefined); + +export default function TeamPermissionProvider({ + groupId, + children, +}: { + groupId: number; + children: React.ReactNode; +}) { + const { user, initialized } = useAuthStore(); + const [memberInfo, setMemberInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const { + data, + refetch, + isLoading: queryLoading, + } = usetMemberPermission({ + groupId, + userId: user?.id, + }); + + useEffect(() => { + if (!initialized) return; + + setIsLoading(queryLoading); + setMemberInfo(data ?? null); + }, [initialized, queryLoading, data]); + + const refresh = async () => { + setIsLoading(true); + const result = await refetch(); + setMemberInfo(result.data ?? null); + setIsLoading(false); + }; + + if (!initialized || !user) return null; + + const isTeam = !!memberInfo; + + return ( + + {children} + + ); +} + +export function useTeamPermission() { + const context = useContext(TeamPermissionContext); + if (!context) { + throw new Error("team permission context error"); + } + return context; +} From 326c448ed9821817ae9016a70c50d3257a738e6d Mon Sep 17 00:00:00 2001 From: luli Date: Tue, 25 Nov 2025 14:40:25 +0900 Subject: [PATCH 009/116] =?UTF-8?q?refactor:=20#109=20tasklist=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=8B=9C=20=EC=A3=BC=EA=B0=84=20=EB=B0=98=EB=B3=B5=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=EA=B0=92=20type=20=3D=20button=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/features/tasklist/daily-frequency-options.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/features/tasklist/daily-frequency-options.tsx b/src/components/features/tasklist/daily-frequency-options.tsx index 63030ad4..50eaa9a0 100644 --- a/src/components/features/tasklist/daily-frequency-options.tsx +++ b/src/components/features/tasklist/daily-frequency-options.tsx @@ -26,6 +26,7 @@ export default function DailyFrequencyOptions({ intent="tertiary" className={cn("w-full px-[13px]", isSelected ? "bg-pink-400 text-white" : "")} onClick={() => onChange(list.value)} + type="button" > {list.label} From f0221a362c65fd33dcb09a214a789466e6a45fd3 Mon Sep 17 00:00:00 2001 From: luli Date: Tue, 25 Nov 2025 14:41:55 +0900 Subject: [PATCH 010/116] =?UTF-8?q?refactor:=20#114=20=ED=95=A0=EC=9D=BC?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/tasklist/task-detail-main.tsx | 64 +++++++++++++++ .../features/tasklist/task-detail-wrapper.tsx | 79 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/components/features/tasklist/task-detail-main.tsx create mode 100644 src/components/features/tasklist/task-detail-wrapper.tsx diff --git a/src/components/features/tasklist/task-detail-main.tsx b/src/components/features/tasklist/task-detail-main.tsx new file mode 100644 index 00000000..7a3df7ee --- /dev/null +++ b/src/components/features/tasklist/task-detail-main.tsx @@ -0,0 +1,64 @@ +import KebabIcon from "@/assets/icons/ic-kebab.svg"; +import { Avatar } from "@/components/ui"; +import { formatDateToFullStr, formatTimeToStr, getFrequencyLabel } from "@/lib/utils"; +import CalendarIcon from "@/assets/icons/ic-calendar.svg"; +import RepeatIcon from "@/assets/icons/ic-repeat.svg"; +import TimeIcon from "@/assets/icons/ic-time.svg"; +import { TaskDetail } from "@/types/task"; + +interface TaskDetailProps { + taskDetail: TaskDetail; +} + +export default function TaskDetailMain({ taskDetail }: TaskDetailProps) { + return ( + <> +
+

{taskDetail.name}

+ +
+
+
+ + {taskDetail.writer.nickname} +
+ + {formatDateToFullStr({ date: taskDetail.date, type: "korean" })} + +
+
+
+
+ +
+ + {formatDateToFullStr({ date: taskDetail.date, type: "korean" })} + +
+ | +
+
+ +
+ + {formatTimeToStr({ date: taskDetail.date, type: "meridiem" })} + +
+ | +
+ {taskDetail?.frequency !== "ONCE" && ( +
+ +
+ )} + + {getFrequencyLabel(taskDetail.frequency)} + +
+
+
{taskDetail.description &&

{taskDetail.description}

}
+ + ); +} diff --git a/src/components/features/tasklist/task-detail-wrapper.tsx b/src/components/features/tasklist/task-detail-wrapper.tsx new file mode 100644 index 00000000..e1a0b4f8 --- /dev/null +++ b/src/components/features/tasklist/task-detail-wrapper.tsx @@ -0,0 +1,79 @@ +"use client"; +import CancelIcon from "@/assets/icons/ic-cancel.svg"; +import { Container } from "@/components/layout"; +import { useTaskDetail } from "@/hooks/taskList/use-tasklist"; +import { useEffect, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import TaskDetailMain from "./task-detail-main"; + +export default function TaskDetailWrapper({ + taskId, + groupId, +}: { + taskId: number; + groupId: number; +}) { + const router = useRouter(); + + const context = useMemo(() => { + if (typeof window === "undefined") return null; + + const storageDatas = sessionStorage.getItem("taskStorageProps"); + if (!storageDatas) return null; + + try { + const parsedData = JSON.parse(storageDatas); + return { + taskListId: Number(parsedData.taskListId), + date: parsedData.taskDate, + groupId: Number(parsedData.taskGroupId), + }; + } catch { + return null; + } + }, [taskId]); + + useEffect(() => { + if (!context || !taskId) { + router.push("/"); + } + }, [context, taskId, router]); + + const { data, isLoading } = useTaskDetail({ + groupId: groupId, + taskListId: context?.taskListId ?? 0, + taskId: taskId, + }); + + if (!context) { + return
로딩중...
; + } + + if (isLoading) { + return
로딩중...
; + } + + const handleCloseButton = () => { + sessionStorage.setItem("closeDetailModal", "true"); + router.push(`/team/${groupId}/tasklist`); + }; + + return ( + <> + {data ? ( + +
+ +
+
+ +
+
+ ) : ( +
암것도 없어
+ )} + + ); +} From 80aa04817f0aef25bff9679ce58bf8fb740cf021 Mon Sep 17 00:00:00 2001 From: luli Date: Tue, 25 Nov 2025 14:43:25 +0900 Subject: [PATCH 011/116] =?UTF-8?q?feat:=20#104=20tasklist=20card=20kebab?= =?UTF-8?q?=20dropdown=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/features/tasklist/task.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/features/tasklist/task.tsx b/src/components/features/tasklist/task.tsx index 5691a5cb..1394ca1d 100644 --- a/src/components/features/tasklist/task.tsx +++ b/src/components/features/tasklist/task.tsx @@ -11,11 +11,18 @@ import { formatDateToFullStr, getFrequencyLabel } from "@/lib/utils"; interface TaskProps { task: TaskType; + onKebabClick: ({ taskId, type }: { taskId: number; type: KebabType }) => void; } -export default function Task({ task }: TaskProps) { +export type KebabType = "update" | "delete"; + +export default function Task({ task, onKebabClick }: TaskProps) { const [checked, setChecked] = useState(false); + const handleKebabClick = (type: KebabType) => { + onKebabClick({ taskId: task.id, type }); + }; + return (
@@ -54,10 +61,10 @@ export default function Task({ task }: TaskProps) { - + handleKebabClick("update")}> 수정하기 - + handleKebabClick("delete")}> 삭제하기 From e46578e6ecbe8b3f7c304bdc7a84579e0d7867a1 Mon Sep 17 00:00:00 2001 From: luli Date: Tue, 25 Nov 2025 15:40:33 +0900 Subject: [PATCH 012/116] =?UTF-8?q?refactor:=20#109=20alert=EA=B0=80=20mod?= =?UTF-8?q?al=EB=B3=B4=EB=8B=A4=20=EC=83=81=EB=8B=A8=EC=97=90=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/alert/alert.styles.ts | 30 ++++++---------- src/components/ui/alert/alert.tsx | 48 ++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/components/ui/alert/alert.styles.ts b/src/components/ui/alert/alert.styles.ts index 1757aaeb..e542fa02 100644 --- a/src/components/ui/alert/alert.styles.ts +++ b/src/components/ui/alert/alert.styles.ts @@ -1,26 +1,16 @@ import { cva } from "class-variance-authority"; -export const alertOverlayStyle = - "fixed inset-0 z-[9999] flex items-center justify-center bg-modal-dimmed"; +export const alertOverlayStyle = [ + "fixed inset-0 z-[9999] flex items-center justify-center", + "custom-dialog-backdrop bg-transparent overflow-hidden", + "rounded-t-xl", +].join(" "); -export const alertContainerStyle = cva( - [ - "flex flex-col bg-gray-800 px-4 pb-8 pt-4 text-center items-center rounded-t-xl h-auto", - "p-0 tablet:px-4 tablet:pb-8 tablet:pt-4", - "absolute bottom-0 mobile:relative mobile:bottom-auto mobile:rounded-bl-0 mobile:rounded-b-xl", - ], - { - variants: { - size: { - md: "max-w-[375px] w-[100%]", - lg: "max-w-[384px] w-[100%]", - }, - }, - defaultVariants: { - size: "lg", - }, - }, -); +export const alertContainerStyle = [ + "flex flex-col bg-gray-800 px-4 pb-8 pt-4 text-center items-center rounded-t-xl h-auto w-[100vw] max-w-[384px]", + "p-0 tablet:px-4 tablet:pb-8 tablet:pt-4", + "absolute bottom-0 mobile:relative mobile:bottom-auto mobile:rounded-bl-0 mobile:rounded-b-xl", +].join(" "); export const alertIcon = "mt-[24px] w-[24px] h-[24px]"; diff --git a/src/components/ui/alert/alert.tsx b/src/components/ui/alert/alert.tsx index 42e2c4a7..aa3970be 100644 --- a/src/components/ui/alert/alert.tsx +++ b/src/components/ui/alert/alert.tsx @@ -12,6 +12,8 @@ import { } from "./alert.styles"; import AlertIcon from "@/assets/icons/ic-alert.svg"; import { Button } from ".."; +import { createPortal } from "react-dom"; +import { useEffect, useRef } from "react"; interface AlertProps { isOpen: boolean; @@ -32,11 +34,48 @@ export default function Alert({ onCancel, type, }: AlertProps) { + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + if (isOpen && !dialog.open) dialog.showModal(); + }, [isOpen]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + const handleClose = () => onCancel(); + dialog.addEventListener("close", handleClose); + return () => dialog.removeEventListener("close", handleClose); + }, [onCancel]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + const handleEscKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onCancel(); + } + }; + + if (isOpen) { + document.addEventListener("keydown", handleEscKey); + } + + return () => { + document.removeEventListener("keydown", handleEscKey); + }; + }, [isOpen, onCancel]); + if (!isOpen) return null; - return ( -
-
e.stopPropagation()}> + return createPortal( + +
e.stopPropagation()}> {type === ALERT_TYPE.Leave && }
-
+ , + document.body, ); } From bedaa594a481ee74fecc0f0253808102c84d8446 Mon Sep 17 00:00:00 2001 From: luli Date: Wed, 26 Nov 2025 06:52:00 +0900 Subject: [PATCH 013/116] =?UTF-8?q?refactor:=20#109=20axios=20server?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/axios-server.ts | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 src/lib/axios-server.ts diff --git a/src/lib/axios-server.ts b/src/lib/axios-server.ts deleted file mode 100644 index 5756c80c..00000000 --- a/src/lib/axios-server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import axios from "axios"; -import { cookies } from "next/headers"; - -const serverAxios = axios.create({ - baseURL: process.env.NEXT_PUBLIC_API_URL, - timeout: 5000, - headers: { - "Content-Type": "application/json", - }, -}); - -serverAxios.interceptors.request.use( - async config => { - const cookieStore = await cookies(); - const accessToken = cookieStore.get("accessToken")?.value; - - if (accessToken) { - config.headers.Authorization = `Bearer ${accessToken}`; - } - return config; - }, - err => Promise.reject(err), -); - -serverAxios.interceptors.response.use( - res => res, - err => { - const { response } = err; - if (response?.status === 401) { - throw new Error("Unauthorized"); - } - return Promise.reject(err); - }, -); - -export default serverAxios; From 46b6271fd571de7ed40f703b61fff2f5e4283ef5 Mon Sep 17 00:00:00 2001 From: luli Date: Wed, 26 Nov 2025 06:53:03 +0900 Subject: [PATCH 014/116] =?UTF-8?q?refactor:=20#109=20server=20api=20serve?= =?UTF-8?q?rFetch=EC=93=B0=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/tasklist/index-server.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/api/tasklist/index-server.ts b/src/api/tasklist/index-server.ts index 3bb83afd..7b551d84 100644 --- a/src/api/tasklist/index-server.ts +++ b/src/api/tasklist/index-server.ts @@ -1,10 +1,13 @@ -import serverAxios from "@/lib/axios-server"; +import { serverFetch } from "@/lib/server/server-fetch"; import { TaskListProps } from "@/types/task"; +import { GroupTaskList } from "@/types/tasklist"; -export async function getGroupTaskListsforServer(groupId: number) { +export async function getGroupTaskListsforServer(groupId: number): Promise { try { - const res = await serverAxios.get(`/groups/${groupId}`); - return res.data; + return await serverFetch(`/groups/${groupId}`, { + method: "GET", + cache: "no-store", + }); } catch (e) { console.error(e); throw e; @@ -16,10 +19,10 @@ export async function getTaskListForServer({ groupId, taskListId, date }: TaskLi const params = new URLSearchParams(); params.append("date", date); - const res = await serverAxios.get( - `/groups/${groupId}/task-lists/${taskListId}?${params.toString()}`, - ); - return res.data; + return await serverFetch(`/groups/${groupId}/task-lists/${taskListId}?${params.toString()}`, { + method: "GET", + cache: "no-store", + }); } catch (e) { console.error(e); throw e; From 3aec072444b94b2e09531c411fd2206a2ba210ef Mon Sep 17 00:00:00 2001 From: luli Date: Wed, 26 Nov 2025 06:55:41 +0900 Subject: [PATCH 015/116] =?UTF-8?q?refactor:=20#114=20tasklist=20provider?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=ED=95=98=EA=B8=B0=EC=9C=84=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20layout=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/[id]/tasklist/layout-content.tsx | 39 ++++++++++++++++ .../(routes)/team/[id]/tasklist/layout.tsx | 45 ++++++------------- 2 files changed, 53 insertions(+), 31 deletions(-) create mode 100644 src/app/(routes)/team/[id]/tasklist/layout-content.tsx diff --git a/src/app/(routes)/team/[id]/tasklist/layout-content.tsx b/src/app/(routes)/team/[id]/tasklist/layout-content.tsx new file mode 100644 index 00000000..afa3629e --- /dev/null +++ b/src/app/(routes)/team/[id]/tasklist/layout-content.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { layoutStyle } from "./index.styles"; +import { usePathname } from "next/navigation"; + +interface CommonProps { + children: React.ReactNode; + detail: React.ReactNode; +} + +export default function LayoutContent({ children, detail }: CommonProps) { + const pathname = usePathname(); + + const [isOpenDetailModal, setIsOpenDetailModal] = useState(false); + + useEffect(() => { + const isClose = sessionStorage.getItem("closeDetailModal"); + const isModal = sessionStorage.getItem("openDetailModal"); + + if (isModal) { + setIsOpenDetailModal(true); + sessionStorage.removeItem("openDetailModal"); + } else if (isClose) { + setIsOpenDetailModal(false); + sessionStorage.removeItem("closeDetailModal"); + } + }, [pathname]); + + return ( + <> +
+
{children}
+ + {isOpenDetailModal &&
{detail}
} +
+ + ); +} diff --git a/src/app/(routes)/team/[id]/tasklist/layout.tsx b/src/app/(routes)/team/[id]/tasklist/layout.tsx index d24bb6de..aff170b1 100644 --- a/src/app/(routes)/team/[id]/tasklist/layout.tsx +++ b/src/app/(routes)/team/[id]/tasklist/layout.tsx @@ -1,41 +1,24 @@ -"use client"; +import TaskListProvider from "./tasklist-provider"; +import LayoutContent from "./layout-content"; +import { formatDateToISOString } from "@/lib/utils"; -import { useEffect, useState } from "react"; -import { layoutStyle } from "./index.styles"; -import { usePathname } from "next/navigation"; - -interface CommonProps { +interface LayoutProps { children: React.ReactNode; detail: React.ReactNode; + params: Promise<{ id: string }>; } -export default function LayoutContent({ children, detail }: CommonProps) { - const pathname = usePathname(); - - const [isOpenDetailModal, setIsOpenDetailModal] = useState(false); - - useEffect(() => { - const isClose = sessionStorage.getItem("closeDetailModal"); - const isModal = sessionStorage.getItem("openDetailModal"); +export default async function TasklistLayout({ children, detail, params }: LayoutProps) { + const { id } = await params; + const groupId = Number(id); - if (isModal) { - setIsOpenDetailModal(true); - sessionStorage.removeItem("openDetailModal"); - } else if (isClose) { - setIsOpenDetailModal(false); - sessionStorage.removeItem("closeDetailModal"); - } - }, [pathname]); - - console.log("isOpenDetailModal: ", isOpenDetailModal); + const currentDate = new Date(); + currentDate.setHours(10, 0, 0, 0); + const formattedDate = formatDateToISOString(currentDate); return ( - <> -
-
{children}
- - {isOpenDetailModal &&
{detail}
} -
- + + {children} + ); } From 57785f0b70643d88c8535cdd4ad2d1f60613d2ef Mon Sep 17 00:00:00 2001 From: luli Date: Wed, 26 Nov 2025 06:57:45 +0900 Subject: [PATCH 016/116] =?UTF-8?q?reafactor:=20#109=20taskList=20provider?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=EC=B2=B4=ED=81=AC=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=82=AC=EC=9A=A9,=20date=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=82=AC=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/[id]/tasklist/tasklist-provider.tsx | 112 ++++++++++++++++++ .../tasklist/team-permission-provider.tsx | 68 ----------- src/types/task.ts | 1 + 3 files changed, 113 insertions(+), 68 deletions(-) create mode 100644 src/app/(routes)/team/[id]/tasklist/tasklist-provider.tsx delete mode 100644 src/app/(routes)/team/[id]/tasklist/team-permission-provider.tsx diff --git a/src/app/(routes)/team/[id]/tasklist/tasklist-provider.tsx b/src/app/(routes)/team/[id]/tasklist/tasklist-provider.tsx new file mode 100644 index 00000000..4e7d717d --- /dev/null +++ b/src/app/(routes)/team/[id]/tasklist/tasklist-provider.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { usetMemberPermission } from "@/hooks/taskList/use-tasklist"; +import { useAlert } from "@/providers/alert-provider"; +import { useAuthStore } from "@/store/auth.store"; +import { MemberInfo } from "@/types/tasklist"; +import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; + +type TaskListProviderType = { + isTeam: boolean; + isLoading: boolean; + memberInfo: MemberInfo | null; + refresh: () => Promise; + permissionCheck: () => Promise; + currentISOStrDate: string; + setCurrentISOStrDate: (date: string) => void; + todayISOStrDate: string; + dateString: string; +}; + +const TaskListProviderContext = createContext(undefined); + +export default function TaskListProvider({ + groupId, + date, + children, +}: { + groupId: number; + date: string; + children: React.ReactNode; +}) { + const { user, initialized } = useAuthStore(); + const [memberInfo, setMemberInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [currentISOStrDate, setCurrentISOStrDate] = useState(date); + const { showAlert } = useAlert(); + + const dateString = useMemo(() => { + return currentISOStrDate.split("T")[0]; + }, [currentISOStrDate]); + + const permissionCheck = async () => { + try { + await refresh(); + + if (isTeam) { + return true; + } else { + console.error("팀 권한 없음:"); + showAlert("팀 권한이 없습니다."); + return false; + } + } catch (error) { + console.error("권한 체크 중 에러 발생:", error); + showAlert("팀 권한 확인 중 오류가 발생했습니다."); + return false; + } + }; + + const { + data, + refetch, + isLoading: queryLoading, + } = usetMemberPermission({ + groupId, + userId: user?.id, + }); + + useEffect(() => { + if (!initialized) return; + + setIsLoading(queryLoading); + setMemberInfo(data ?? null); + }, [initialized, queryLoading, data]); + + const refresh = async () => { + setIsLoading(true); + const result = await refetch(); + setMemberInfo(result.data ?? null); + setIsLoading(false); + }; + + if (!initialized || !user) return null; + + const isTeam = !!memberInfo; + + return ( + + {children} + + ); +} + +export function useTaskListContext() { + const context = useContext(TaskListProviderContext); + if (!context) { + throw new Error("team permission context error"); + } + return context; +} diff --git a/src/app/(routes)/team/[id]/tasklist/team-permission-provider.tsx b/src/app/(routes)/team/[id]/tasklist/team-permission-provider.tsx deleted file mode 100644 index e6f4edb3..00000000 --- a/src/app/(routes)/team/[id]/tasklist/team-permission-provider.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client"; - -import { usetMemberPermission } from "@/hooks/taskList/use-tasklist"; -import { useAuthStore } from "@/store/auth.store"; -import { MemberInfo } from "@/types/tasklist"; -import React, { createContext, useContext, useEffect, useState } from "react"; - -type TeamPermissionType = { - isTeam: boolean; - isLoading: boolean; - memberInfo: MemberInfo | null; - refresh: () => Promise; -}; - -const TeamPermissionContext = createContext(undefined); - -export default function TeamPermissionProvider({ - groupId, - children, -}: { - groupId: number; - children: React.ReactNode; -}) { - const { user, initialized } = useAuthStore(); - const [memberInfo, setMemberInfo] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - const { - data, - refetch, - isLoading: queryLoading, - } = usetMemberPermission({ - groupId, - userId: user?.id, - }); - - useEffect(() => { - if (!initialized) return; - - setIsLoading(queryLoading); - setMemberInfo(data ?? null); - }, [initialized, queryLoading, data]); - - const refresh = async () => { - setIsLoading(true); - const result = await refetch(); - setMemberInfo(result.data ?? null); - setIsLoading(false); - }; - - if (!initialized || !user) return null; - - const isTeam = !!memberInfo; - - return ( - - {children} - - ); -} - -export function useTeamPermission() { - const context = useContext(TeamPermissionContext); - if (!context) { - throw new Error("team permission context error"); - } - return context; -} diff --git a/src/types/task.ts b/src/types/task.ts index e03d5aff..c8ceaf28 100644 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -10,6 +10,7 @@ export interface TaskCommonProps { export interface TaskListProps extends TaskCommonProps { date: string; + dateString?: string; } export interface TaskDetailProps extends TaskCommonProps { From 064e748b71ff445394547ac8207dcb009b46fff7 Mon Sep 17 00:00:00 2001 From: luli Date: Wed, 26 Nov 2025 06:59:47 +0900 Subject: [PATCH 017/116] =?UTF-8?q?refactor:=20#109=20tasklist=20client,?= =?UTF-8?q?=20card=20field=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20query=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(routes)/team/[id]/tasklist/page.tsx | 20 +- .../team/[id]/tasklist/tasklist-client.tsx | 174 ++++++++--------- .../features/tasklist/task-card-field.tsx | 177 ++++++++++++++++++ 3 files changed, 259 insertions(+), 112 deletions(-) create mode 100644 src/components/features/tasklist/task-card-field.tsx diff --git a/src/app/(routes)/team/[id]/tasklist/page.tsx b/src/app/(routes)/team/[id]/tasklist/page.tsx index a0952b23..003c9585 100644 --- a/src/app/(routes)/team/[id]/tasklist/page.tsx +++ b/src/app/(routes)/team/[id]/tasklist/page.tsx @@ -1,10 +1,8 @@ import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"; import TaskListClient from "./tasklist-client"; import { formatDateToISOString, isEmpty } from "@/lib/utils"; -import { GroupTaskList } from "@/types/tasklist"; import { redirect } from "next/navigation"; import { getGroupTaskListsforServer, getTaskListForServer } from "@/api/tasklist/index-server"; -import TeamPermissionProvider from "./team-permission-provider"; export default async function TasklistPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -15,14 +13,12 @@ export default async function TasklistPage({ params }: { params: Promise<{ id: s const groupId = Number(id); const queryClient = new QueryClient(); - const groupResult: GroupTaskList = await queryClient.fetchQuery({ - queryKey: ["taskList", groupId], - queryFn: () => getGroupTaskListsforServer(groupId), - }); + const groupResult = await getGroupTaskListsforServer(groupId); const currentDate = new Date(); currentDate.setHours(10, 0, 0, 0); - const dateString = currentDate.toISOString().split("T")[0]; + const dateString = formatDateToISOString(currentDate).split("T")[0]; + const formattedDate = formatDateToISOString(currentDate); let firstTaskListId: number | null = null; if (groupResult?.taskLists?.length) { @@ -40,7 +36,7 @@ export default async function TasklistPage({ params }: { params: Promise<{ id: s getTaskListForServer({ groupId: groupId, taskListId: firstTaskListId, - date: formatDateToISOString(currentDate), + date: formattedDate, }), }); } @@ -48,11 +44,9 @@ export default async function TasklistPage({ params }: { params: Promise<{ id: s return ( <> - - - - - + + + ); } diff --git a/src/app/(routes)/team/[id]/tasklist/tasklist-client.tsx b/src/app/(routes)/team/[id]/tasklist/tasklist-client.tsx index 4dd3c065..8ce401d0 100644 --- a/src/app/(routes)/team/[id]/tasklist/tasklist-client.tsx +++ b/src/app/(routes)/team/[id]/tasklist/tasklist-client.tsx @@ -5,8 +5,7 @@ import LeftArrowIcon from "@/assets/icons/ic-arrow-left-circle.svg"; import RightArrowIcon from "@/assets/icons/ic-arrow-right-circle.svg"; import CalendarIcon from "@/assets/icons/ic-calendar-circle.svg"; import PlusIcon from "@/assets/icons/ic-plus.svg"; -import Task from "@/components/features/tasklist/task"; -import { useTaskList } from "@/hooks/taskList/use-tasklist"; +import { createRecurring, createTask } from "@/hooks/taskList/use-tasklist"; import { formatDateForToMonthAndDays, isEmpty } from "@/lib/utils"; import { Button, Floating } from "@/components/ui"; import { useToggle } from "@/hooks"; @@ -15,11 +14,12 @@ import TaskRecurringAddModal from "@/components/features/tasklist/task-recurring import { useEffect, useState } from "react"; import { useAlert } from "@/providers/alert-provider"; import { GroupTaskList } from "@/types/tasklist"; -import cn from "@/lib/cn"; -import { usePathname, useRouter } from "next/navigation"; -import { taskSchema } from "@/lib/schema"; +import { useRouter } from "next/navigation"; +import { taskDetailSchema, taskSchema } from "@/lib/schema"; import z4 from "zod/v4"; -import { dateTitleStyle, hiddenBrStyle, newListbuttonStyle, tabButtonStyle } from "./index.styles"; +import { dateTitleStyle, hiddenBrStyle, newListbuttonStyle } from "./index.styles"; +import { useTaskListContext } from "./tasklist-provider"; +import TaskCardField from "@/components/features/tasklist/task-card-field"; interface TaskListPageProps { groupData: GroupTaskList; @@ -36,7 +36,13 @@ export default function TasklistClient({ groupData, taskListId, date }: TaskList setOpen: setOpenRecurring, setClose: setCloseRecurring, } = useToggle(); + const [activeTab, setActiveTab] = useState(taskListId || 0); const { showAlert } = useAlert(); + const router = useRouter(); + + const { isTeam, isLoading, permissionCheck, dateString } = useTaskListContext(); + const { mutate: postRecurringMutate } = createRecurring(); + const { mutate: taskMutate } = createTask(); const currentDateStr = formatDateForToMonthAndDays(date); @@ -51,15 +57,67 @@ export default function TasklistClient({ groupData, taskListId, date }: TaskList setOpenRecurring(); } }; - - const handleTaskSubmit = (value: string) => { - console.log("tasklist 그룹 추가", value); + const handleTaskSubmit = async (value: z4.infer) => { + const result = await permissionCheck(); + if (result) { + const resultValue = value.name; + if (isEmpty(resultValue)) + taskMutate( + { + groupId: groupData.id, + name: resultValue, + }, + { + onSuccess: () => { + showAlert("할 일 목록이 등록되었습니다."); // 나중에 toast로 교체 + setCloseTask(); + router.refresh(); + }, + onError: error => { + console.error(error); + showAlert("등록 중 오류가 발생했습니다."); + }, + }, + ); + } }; - const handleTaskRecurringSubmit = (value: z4.infer) => { - console.log("task 추가", value); + const handleTaskRecurringSubmit = async (value: z4.infer) => { + const result = await permissionCheck(); + + if (!activeTab) { + showAlert("할 일 목록을 먼저 선택하여야 합니다."); + return; + } + + if (result) { + postRecurringMutate( + { + groupId: groupData.id, + taskListId: activeTab, + recurringData: value, + dateString: dateString, + }, + { + onSuccess: () => { + showAlert("할 일이 등록되었습니다."); // 나중에 toast로 교체 + setCloseRecurring(); + }, + onError: () => { + showAlert("등록 중 오류가 발생했습니다."); + }, + }, + ); + } }; + useEffect(() => { + if (!isLoading && !isTeam) { + showAlert("해당 팀에 권한이 없습니다."); + router.push("/"); + } + }, [isLoading, isTeam, router, showAlert]); + return ( <> @@ -98,7 +156,12 @@ export default function TasklistClient({ groupData, taskListId, date }: TaskList ) : ( - + )}
@@ -126,90 +189,3 @@ export default function TasklistClient({ groupData, taskListId, date }: TaskList ); } - -function TaskCardField({ groupData, taskListId, date }: TaskListPageProps) { - const router = useRouter(); - const pathname = usePathname(); - - const groupId = groupData.id; - const [activeTab, setActiveTab] = useState(taskListId || 0); - const tabs = groupData.taskLists - .sort((a, b) => a.displayIndex - b.displayIndex) - .map(taskList => ({ id: taskList.id, label: taskList.name })); - - const { data: taskListData } = useTaskList({ - groupId: groupId, - taskListId: activeTab, - date: date.toISOString(), - }); - - const storageDatas = { - taskGroupId: groupId.toString(), - taskListId: activeTab, - taskDate: date, - }; - - const handleTaskClick = (id: number) => { - sessionStorage.setItem("taskStorageProps", JSON.stringify(storageDatas)); - sessionStorage.setItem("openDetailModal", "true"); - router.push(`/team/${groupId}/tasklist/${id}`); - }; - - // 탭이동시 상세보기 화면 닫기 - useEffect(() => { - const pathParts = pathname.split("/").filter(Boolean); - if (pathParts.length >= 4 && pathParts[2] === "tasklist") { - router.back(); - } - }, [activeTab, router]); - - if (!taskListId || isEmpty(taskListData)) return null; - - return ( - <> -
-
- {!isEmpty(tabs) && - tabs.map(tab => { - return ( - - ); - })} -
-
- {taskListData ? ( -
- {taskListData.tasks.map(task => ( -
handleTaskClick(task.id)} - > - -
- ))} -
- ) : ( -
- - 아직 할 일 목록이 없습니다. -
- 새로운 목록을 추가해주세요. -
-
- )} - - ); -} diff --git a/src/components/features/tasklist/task-card-field.tsx b/src/components/features/tasklist/task-card-field.tsx new file mode 100644 index 00000000..16f0e2ad --- /dev/null +++ b/src/components/features/tasklist/task-card-field.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Task, { KebabType } from "@/components/features/tasklist/task"; +import { tabButtonStyle } from "@/app/(routes)/team/[id]/tasklist/index.styles"; +import { GroupTaskList } from "@/types/tasklist"; +import cn from "@/lib/cn"; +import { usePathname, useRouter } from "next/navigation"; +import TaskDetailUpdateTemplate from "@/components/features/tasklist/task-recurring-update-modal"; +import { updateRecurring, useTaskList } from "@/hooks/taskList/use-tasklist"; +import { formatDateToISOString, isEmpty } from "@/lib/utils"; +import { useToggle } from "@/hooks"; +import z4 from "zod/v4"; +import { taskDetailUpdateSchema } from "@/lib/schema"; +import { useAlert } from "@/providers/alert-provider"; +import { useTaskListContext } from "@/app/(routes)/team/[id]/tasklist/tasklist-provider"; + +interface TaskListPageProps { + groupData: GroupTaskList; + date: Date; +} + +interface TaskFieldProps extends TaskListPageProps { + activeTab: number; + setActiveTab: React.Dispatch>; +} + +export default function TaskCardField({ + groupData, + date, + activeTab, + setActiveTab, +}: TaskFieldProps) { + const router = useRouter(); + const pathname = usePathname(); + const { + isOpen: isOpenUpdateTaskDetail, + setOpen: setOpenUpdateTaskDetail, + setClose: setCloseUpdateTaskDetail, + } = useToggle(); + + const { showAlert } = useAlert(); + const { permissionCheck, dateString } = useTaskListContext(); + const { mutate } = updateRecurring(); + + const [selectedRecurringId, setSelectedRecurringId] = useState(null); + + const groupId = groupData.id; + const tabs = groupData.taskLists + .sort((a, b) => a.displayIndex - b.displayIndex) + .map(taskList => ({ id: taskList.id, label: taskList.name })); + + const { data: taskListData } = useTaskList({ + groupId: groupId, + taskListId: activeTab, + date: formatDateToISOString(date), + dateString: dateString, + }); + + const storageDatas = { + taskGroupId: groupId.toString(), + taskListId: activeTab, + taskDate: date, + }; + + const handleTaskClick = (id: number) => { + sessionStorage.setItem("taskStorageProps", JSON.stringify(storageDatas)); + sessionStorage.setItem("openDetailModal", "true"); + router.push(`/team/${groupId}/tasklist/${id}`); + }; + + const handleKebabClick = ({ taskId, type }: { taskId: number; type: KebabType }) => { + if (type === "update") { + setSelectedRecurringId(taskId); + setOpenUpdateTaskDetail(); + } else { + console.log("delete:", taskId); + } + }; + + const handleTaskUpdateSubmit = async ( + value: z4.infer>, + ) => { + if (selectedRecurringId === null) return; + + const result = await permissionCheck(); + if (result) { + mutate( + { + groupId: groupData.id, + taskListId: activeTab, + dateString: dateString, + taskId: selectedRecurringId, + name: value.name, + }, + { + onSuccess: () => { + showAlert("할 일 제목이 수정되었습니다."); // 나중에 toast로 교체 + setCloseUpdateTaskDetail(); + }, + onError: () => { + showAlert("등록 중 오류가 발생했습니다."); + }, + }, + ); + } + }; + + // 탭이동시 상세보기 화면 닫기 + useEffect(() => { + const pathParts = pathname.split("/").filter(Boolean); + if (pathParts.length >= 4 && pathParts[2] === "tasklist") { + router.back(); + } + sessionStorage.setItem("closeDetailModal", "true"); + }, [activeTab, router]); + + if (!activeTab || isEmpty(taskListData)) return null; + + return ( + <> +
+
+ {!isEmpty(tabs) && + tabs.map(tab => { + return ( + + ); + })} +
+
+ {taskListData ? ( +
+ {taskListData.tasks.map(task => ( +
handleTaskClick(task.id)} + > + +
+ ))} +
+ ) : ( +
+ + 아직 할 일 목록이 없습니다. +
+ 새로운 목록을 추가해주세요. +
+
+ )} + + {isOpenUpdateTaskDetail && ( + task.id === selectedRecurringId)?.name ?? ""} + type="nameOnly" + /> + )} + + ); +} From b0c1d2e621dc286e61cec16d7a9354a11d86d9b6 Mon Sep 17 00:00:00 2001 From: luli Date: Wed, 26 Nov 2025 07:03:03 +0900 Subject: [PATCH 018/116] =?UTF-8?q?feat:=20#109=20tasklist=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=BD=94=EB=93=9C=20=EC=9D=BC=EB=B6=80=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD,=20=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(=ED=95=A0=EC=9D=BC=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=ED=8F=AC=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/tasklist/task-add-modal.tsx | 100 ++++++++++------ .../features/tasklist/task-detail-main.tsx | 29 ++++- .../features/tasklist/task-detail-wrapper.tsx | 90 +++++++++++++-- .../tasklist/task-recurring-add-modal.tsx | 12 +- .../tasklist/task-recurring-update-modal.tsx | 109 ++++++++++++++++++ 5 files changed, 279 insertions(+), 61 deletions(-) create mode 100644 src/components/features/tasklist/task-recurring-update-modal.tsx diff --git a/src/components/features/tasklist/task-add-modal.tsx b/src/components/features/tasklist/task-add-modal.tsx index e23eb3ce..313e8c01 100644 --- a/src/components/features/tasklist/task-add-modal.tsx +++ b/src/components/features/tasklist/task-add-modal.tsx @@ -1,55 +1,79 @@ "use client"; import { addTaskListStyle } from "@/app/(routes)/team/[id]/tasklist/index.styles"; -import { Input, Modal } from "@/components/ui"; -import { ChangeEvent, useState } from "react"; +import { Form, Input, Modal } from "@/components/ui"; +import { taskSchema } from "@/lib/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Controller, SubmitHandler, useFormContext } from "react-hook-form"; +import z4 from "zod/v4"; interface TaskAddProps { isOpen: boolean; onClose: () => void; - onSubmit: (value: string) => Promise; + onSubmit: (value: z4.infer) => Promise; } export default function TaskAddTemplate({ isOpen, onClose, onSubmit }: TaskAddProps) { - const [taskName, setTaskName] = useState(""); - - const handleMakeClick = async () => { - const resultName = taskName.trim() ?? ""; - - try { - await onSubmit(resultName); - onClose(); - } catch (error) { - console.error("제출 실패", error); - } + const handleSubmit: SubmitHandler> = data => { + onSubmit(data); }; return ( - -
- -
- - 할 일에 대한 목록을 추가하고 -
- 목록별 할 일을 만들 수 있습니다. -
-
- - - ) => setTaskName(e.target.value)} - className="mb-[24px]" - /> - -
- -
+
+ +
+ + +
+
); } + +function FormField() { + const { + control, + formState: { errors }, + } = useFormContext>(); + + return ( + +
+ + 할 일에 대한 목록을 추가하고 +
+ 목록별 할 일을 만들 수 있습니다. +
+
+ +
+ + + ( + <> + + + + )} + /> + +
+
+ ); +} diff --git a/src/components/features/tasklist/task-detail-main.tsx b/src/components/features/tasklist/task-detail-main.tsx index 7a3df7ee..56b74751 100644 --- a/src/components/features/tasklist/task-detail-main.tsx +++ b/src/components/features/tasklist/task-detail-main.tsx @@ -1,23 +1,42 @@ import KebabIcon from "@/assets/icons/ic-kebab.svg"; -import { Avatar } from "@/components/ui"; +import { Avatar, Dropdown } from "@/components/ui"; import { formatDateToFullStr, formatTimeToStr, getFrequencyLabel } from "@/lib/utils"; import CalendarIcon from "@/assets/icons/ic-calendar.svg"; import RepeatIcon from "@/assets/icons/ic-repeat.svg"; import TimeIcon from "@/assets/icons/ic-time.svg"; import { TaskDetail } from "@/types/task"; +import { KebabType } from "./task"; interface TaskDetailProps { taskDetail: TaskDetail; + onKebabClick: ({ taskId, type }: { taskId: number; type: KebabType }) => void; } -export default function TaskDetailMain({ taskDetail }: TaskDetailProps) { +export default function TaskDetailMain({ taskDetail, onKebabClick }: TaskDetailProps) { + const handleKebabClick = (type: KebabType) => { + onKebabClick({ taskId: taskDetail.id, type }); + }; + return ( <>

{taskDetail.name}

- + + + + + + handleKebabClick("update")}> + 수정하기 + + handleKebabClick("delete")}> + 삭제하기 + + +
diff --git a/src/components/features/tasklist/task-detail-wrapper.tsx b/src/components/features/tasklist/task-detail-wrapper.tsx index e1a0b4f8..c7fc61b9 100644 --- a/src/components/features/tasklist/task-detail-wrapper.tsx +++ b/src/components/features/tasklist/task-detail-wrapper.tsx @@ -1,10 +1,17 @@ "use client"; import CancelIcon from "@/assets/icons/ic-cancel.svg"; import { Container } from "@/components/layout"; -import { useTaskDetail } from "@/hooks/taskList/use-tasklist"; -import { useEffect, useMemo } from "react"; +import { updateRecurring, useTaskDetail } from "@/hooks/taskList/use-tasklist"; +import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import TaskDetailMain from "./task-detail-main"; +import { KebabType } from "./task"; +import { useToggle } from "@/hooks"; +import TaskDetailUpdateTemplate from "./task-recurring-update-modal"; +import z4 from "zod/v4"; +import { taskDetailUpdateSchema } from "@/lib/schema"; +import { useTaskListContext } from "@/app/(routes)/team/[id]/tasklist/tasklist-provider"; +import { useAlert } from "@/providers/alert-provider"; export default function TaskDetailWrapper({ taskId, @@ -14,6 +21,15 @@ export default function TaskDetailWrapper({ groupId: number; }) { const router = useRouter(); + const { + isOpen: isOpenUpdateTaskDetail, + setOpen: setOpenUpdateTaskDetail, + setClose: setCloseUpdateTaskDetail, + } = useToggle(); + const [selectedRecurringId, setSelectedRecurringId] = useState(null); + const { showAlert } = useAlert(); + const { permissionCheck, dateString } = useTaskListContext(); + const { mutate } = updateRecurring(); const context = useMemo(() => { if (typeof window === "undefined") return null; @@ -33,6 +49,15 @@ export default function TaskDetailWrapper({ } }, [taskId]); + const handleKebabClick = ({ taskId, type }: { taskId: number; type: KebabType }) => { + if (type === "update") { + setSelectedRecurringId(taskId); + setOpenUpdateTaskDetail(); + } else { + console.log("delete:", taskId); + } + }; + useEffect(() => { if (!context || !taskId) { router.push("/"); @@ -58,19 +83,60 @@ export default function TaskDetailWrapper({ router.push(`/team/${groupId}/tasklist`); }; + const handleTaskUpdateSubmit = async ( + value: z4.infer>, + ) => { + if (selectedRecurringId === null) return; + + const result = await permissionCheck(); + if (result) { + mutate( + { + groupId: groupId, + taskListId: context.taskListId, + dateString: dateString, + taskId: taskId, + ...value, + }, + { + onSuccess: () => { + showAlert("할 일 내용이 수정되었습니다."); // 나중에 toast로 교체 + setCloseUpdateTaskDetail(); + }, + onError: () => { + showAlert("등록 중 오류가 발생했습니다."); + }, + }, + ); + } + }; + return ( <> {data ? ( - -
- -
-
- -
-
+ <> + +
+ +
+
+ +
+
+ + {isOpenUpdateTaskDetail && ( + + )} + ) : (
암것도 없어
)} diff --git a/src/components/features/tasklist/task-recurring-add-modal.tsx b/src/components/features/tasklist/task-recurring-add-modal.tsx index dfbc47dc..eda13500 100644 --- a/src/components/features/tasklist/task-recurring-add-modal.tsx +++ b/src/components/features/tasklist/task-recurring-add-modal.tsx @@ -8,19 +8,19 @@ import { FrequencyOptions, FrequencyType } from "@/types/date-format-type"; import CustomSingleDatepicker from "@/components/ui/date-timepicker/single-datepicker"; import DailyFrequencyOptions from "./daily-frequency-options"; import z4 from "zod/v4"; -import { taskSchema } from "@/lib/schema"; +import { taskDetailSchema } from "@/lib/schema"; import { zodResolver } from "@hookform/resolvers/zod"; interface TaskRecurringProps { isOpen: boolean; onClose: () => void; - onSubmit: (value: z4.infer) => void; + onSubmit: (value: z4.infer) => void; } export default function TaskRecurringAddModal({ isOpen, onClose, onSubmit }: TaskRecurringProps) { const [dayIndexArray, setDayIndexArray] = useState([]); - const handleSubmit: SubmitHandler> = submitData => { + const handleSubmit: SubmitHandler> = submitData => { if (submitData.frequencyType !== FrequencyType.Weekly) { delete submitData.weekDays; } @@ -57,8 +57,8 @@ export default function TaskRecurringAddModal({ isOpen, onClose, onSubmit }: Tas
@@ -82,7 +82,7 @@ function FormField({ formState: { errors }, clearErrors, watch, - } = useFormContext>(); + } = useFormContext>(); const [isDatepickerOpen, setIsDatepickerOpen] = useState(false); const [isTimepickerOpen, setIsTimepickerOpen] = useState(false); diff --git a/src/components/features/tasklist/task-recurring-update-modal.tsx b/src/components/features/tasklist/task-recurring-update-modal.tsx new file mode 100644 index 00000000..c830ba93 --- /dev/null +++ b/src/components/features/tasklist/task-recurring-update-modal.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { addTaskListStyle } from "@/app/(routes)/team/[id]/tasklist/index.styles"; +import { Form, Input, Modal } from "@/components/ui"; +import { taskDetailUpdateSchema } from "@/lib/schema"; +import { extractChangedFields } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Controller, SubmitHandler, useFormContext } from "react-hook-form"; +import z4 from "zod/v4"; + +interface TaskUpdateProps { + name: string; + description?: string; + isOpen: boolean; + onClose: () => void; + onSubmit: (value: z4.infer>) => Promise; + type: FormFieldType; +} + +type FormFieldType = "nameOnly" | "nameAndDescription"; + +export default function TaskDetailUpdateTemplate({ + name, + description, + isOpen, + onClose, + onSubmit, + type, +}: TaskUpdateProps) { + const handleSubmit: SubmitHandler>> = data => { + const changedFields = extractChangedFields(data, name, description); + onSubmit(changedFields); + }; + + const schema = + type === "nameOnly" ? taskDetailUpdateSchema(name) : taskDetailUpdateSchema(name, description); + + return ( + + + +
+ + +
+ +
+ ); +} + +function FormField({ type }: { type: FormFieldType }) { + const { + control, + formState: { errors }, + } = useFormContext>>(); + + return ( + +
+ + + ( + <> + + + + )} + /> + + {type !== "nameOnly" && ( + + + ( + <> + + + + )} + /> + + )} +
+
+ ); +} From ce5d62cba8fe5eac35d457147ecef81a50723a3b Mon Sep 17 00:00:00 2001 From: luli Date: Wed, 26 Nov 2025 07:04:06 +0900 Subject: [PATCH 019/116] =?UTF-8?q?feat:=20#109=20tasklist=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20api=20=EB=B0=8F=20hook,=20zod?= =?UTF-8?q?=20=EC=9C=A0=ED=9A=A8=EC=84=B1=EA=B2=80=EC=82=AC,=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EB=90=9C=EA=B0=92=EB=A7=8C=20=EC=B6=94=EC=B6=9C?= =?UTF-8?q?=ED=95=98=EB=8A=94=20util=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/tasklist/index.ts | 88 +++++++++++++++++++++ src/hooks/taskList/use-tasklist.ts | 122 ++++++++++++++++++++++++++--- src/lib/schema.ts | 57 +++++++++++++- src/lib/utils.ts | 27 +++++++ 4 files changed, 283 insertions(+), 11 deletions(-) diff --git a/src/api/tasklist/index.ts b/src/api/tasklist/index.ts index e9c9286d..30326be2 100644 --- a/src/api/tasklist/index.ts +++ b/src/api/tasklist/index.ts @@ -2,6 +2,9 @@ import { TaskDetailProps, TaskListProps } from "@/types/task"; import axiosInstance from "@/lib/axios"; +import { MemberPermissionProps } from "@/types/tasklist"; +import z4 from "zod/v4"; +import { taskDetailSchema } from "@/lib/schema"; export async function getGroupTaskLists(groupId: number) { try { @@ -39,3 +42,88 @@ export async function getTaskDetail({ groupId, taskListId, taskId }: TaskDetailP throw e; } } + +export async function getMemberInfo({ groupId, userId }: MemberPermissionProps) { + try { + const res = await axiosInstance.get(`/groups/${groupId}/member/${userId}`); + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function postRecurring({ + groupId, + taskListId, + recurringData, +}: { + groupId: number; + taskListId: number; + recurringData: z4.infer; + dateString?: string; +}) { + try { + const res = await axiosInstance.post( + `/groups/${groupId}/task-lists/${taskListId}/recurring`, + recurringData, + ); + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function postTask({ groupId, name }: { groupId: number; name: string }) { + try { + const res = await axiosInstance.post(`/groups/${groupId}/task-lists`, { name }); + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function patchRecurring({ + groupId, + taskListId, + taskId, + name, + description, +}: TaskDetailProps & { name?: string; description?: string; dateString: string }) { + try { + const payload = Object.fromEntries( + Object.entries({ name, description }).filter(([, value]) => value !== undefined), + ); + + const res = await axiosInstance.patch( + `/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}`, + payload, + ); + + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function patchRecurringDoneAt({ + groupId, + taskListId, + taskId, + done, +}: TaskDetailProps & { done: boolean; dateString: string }) { + try { + const res = await axiosInstance.patch( + `/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}`, + { done }, + ); + + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} diff --git a/src/hooks/taskList/use-tasklist.ts b/src/hooks/taskList/use-tasklist.ts index 3a13c36f..9f757e4e 100644 --- a/src/hooks/taskList/use-tasklist.ts +++ b/src/hooks/taskList/use-tasklist.ts @@ -1,7 +1,15 @@ -import { getGroupTaskLists, getTaskDetail, getTaskList } from "@/api/tasklist"; +import { + getGroupTaskLists, + getMemberInfo, + getTaskDetail, + getTaskList, + patchRecurring, + postRecurring, + postTask, +} from "@/api/tasklist"; import { TaskDetail, TaskDetailProps, TaskListProps } from "@/types/task"; -import { GroupTaskList, TaskList } from "@/types/tasklist"; -import { useQuery } from "@tanstack/react-query"; +import { GroupTaskList, MemberInfo, MemberPermissionProps, TaskList } from "@/types/tasklist"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; /** * TaskList 조회 훅 @@ -9,29 +17,123 @@ import { useQuery } from "@tanstack/react-query"; * @param taskListId */ -export function useGroupTaskLists(groupId: number) { +export const useGroupTaskLists = (groupId: number) => { return useQuery({ queryKey: ["groupTaskLists", groupId], queryFn: () => getGroupTaskLists(groupId), enabled: !!groupId, staleTime: 1000 * 60 * 5, // 5분 }); -} +}; -export function useTaskList({ groupId, taskListId, date }: TaskListProps) { +export const useTaskList = ({ + groupId, + taskListId, + date, + dateString, +}: TaskListProps & { dateString: string }) => { return useQuery({ - queryKey: ["taskList", groupId, taskListId, date], + queryKey: ["taskList", groupId, taskListId, dateString], queryFn: () => getTaskList({ groupId, taskListId, date }), enabled: !!(groupId && taskListId && date), staleTime: 1000 * 60 * 5, }); -} +}; -export function useTaskDetail({ groupId, taskListId, taskId }: TaskDetailProps) { +export const useTaskDetail = ({ groupId, taskListId, taskId }: TaskDetailProps) => { return useQuery({ queryKey: ["taskDetail", groupId, taskListId, taskId], queryFn: () => getTaskDetail({ groupId, taskListId, taskId }), enabled: !!(groupId && taskListId && taskId), staleTime: 1000 * 60 * 5, }); -} +}; + +export const usetMemberPermission = ({ groupId, userId }: MemberPermissionProps) => { + return useQuery({ + queryKey: ["memberInfo", groupId, userId], + queryFn: () => getMemberInfo({ groupId, userId }), + enabled: !!userId && !!groupId, + staleTime: 1000 * 30, // 30초마다 체크 + refetchOnWindowFocus: true, + }); +}; + +export const createRecurring = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: postRecurring, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["taskList", variables.groupId, variables.taskListId, variables.dateString], + }); + }, + onError: error => console.error("할 일 상세 등록 실패.", error), + }); +}; + +export const createTask = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: postTask, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["groupTaskLists", variables.groupId], + }); + }, + onError: error => console.error("할 일 목록 등록 실패.", error), + }); +}; + +export const updateRecurring = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: patchRecurring, + onMutate: async variables => { + const queryKey = ["taskList", variables.groupId, variables.taskListId, variables.dateString]; + + await queryClient.cancelQueries({ queryKey }); + const previousData = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (old: TaskList | undefined) => { + if (!old?.tasks) return old; + const updatedTaskList = old.tasks.map(task => + task.id === variables.taskId + ? { + ...task, + ...(variables.name !== undefined && { name: variables.name }), + ...(variables.description !== undefined && { description: variables.description }), + } + : task, + ); + + return { ...old, tasks: updatedTaskList }; + }); + return { previousData, queryKey, variables }; + }, + onError: (error, variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(context.queryKey, context.previousData); + } + console.error("할 일 수정 실패.", error); + }, + onSettled: (data, error, variables, context) => { + if (context) { + queryClient.invalidateQueries({ + queryKey: context.queryKey, + }); + queryClient.invalidateQueries({ + queryKey: [ + "taskDetail", + context.variables.groupId, + context.variables.taskListId, + context.variables.taskId, + ], + }); + } + }, + }); +}; diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 7a53e829..482dc8eb 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -79,13 +79,25 @@ export const validateRequired = (value: string, label = "필수 항목") => { return isInputEmpty(value) ? `${label} 입력해주세요.` : true; }; +/** + * task + */ + +export const taskSchema = z.object({ + name: z + .string() + .trim() + .min(1, { error: "목록 이름은 필수 입력입니다." }) + .max(30, { error: "목록 명은 최대 30자까지 가능합니다." }), +}); + /** * taskSchema * @author luli * */ const frequency = ["ONCE", "DAILY", "WEEKLY", "MONTHLY"]; -export const taskSchema = z +export const taskDetailSchema = z .object({ name: z .string() @@ -134,6 +146,49 @@ export const taskSchema = z { error: "반복 날짜는 1~31일 사이로 선택하여야 합니다.", path: ["frequencyType"] }, ); +export const taskDetailUpdateSchema = (currentName: string, currentDescription?: string) => { + const hasDescription = currentDescription !== undefined; + + const baseSchema = z.object({ + name: z + .string() + .trim() + .min(1, { message: "제목은 필수 입력입니다." }) + .max(30, { message: "제목은 최대 30자까지 가능합니다." }) + .optional(), + description: z + .string() + .trim() + .max(255, { message: "메모는 최대 255자까지 가능합니다." }) + .optional(), + }); + + if (hasDescription) { + return baseSchema.refine( + values => { + const nameChanged = values.name && values.name !== currentName; + const descChanged = + values.description !== undefined && values.description !== currentDescription; + return nameChanged || descChanged; + }, + { + message: "제목 또는 메모 중 최소 하나는 변경되어야 합니다.", + path: ["name"], + }, + ); + } else { + return baseSchema.refine( + values => { + return values.name && values.name !== currentName; + }, + { + message: "같은 값으로는 수정할 수 없습니다.", + path: ["name"], + }, + ); + } +}; + export const articleFormSchema = z.object({ title: z .string() diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 75cadbf5..fe09e30d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -270,3 +270,30 @@ export const formatDateToISOString = (date: Date | string) => { const localDate = new Date(resultDate.getTime() + tzOffset * 60 * 1000); return localDate.toISOString().replace("Z", "+09:00"); }; + +/** + * taskList 변경값만 추출 + * @author luli + * @param + */ +export const extractChangedFields = ( + values: { name?: string; description?: string }, + currentName: string, + currentDescription?: string, +) => { + const result: { name?: string; description?: string } = {}; + + if (values.name !== undefined && values.name !== currentName) { + result.name = values.name; + } + + if ( + currentDescription !== undefined && + values.description !== undefined && + values.description !== currentDescription + ) { + result.description = values.description; + } + + return result; +}; From 5449f246517d5ac3c01d2263d8933276d04fb2c6 Mon Sep 17 00:00:00 2001 From: luli Date: Wed, 26 Nov 2025 07:04:41 +0900 Subject: [PATCH 020/116] =?UTF-8?q?design:=20#109=20tasklist=20kebab=20css?= =?UTF-8?q?=EB=A7=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/features/tasklist/task.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/features/tasklist/task.tsx b/src/components/features/tasklist/task.tsx index 1394ca1d..a90c88b3 100644 --- a/src/components/features/tasklist/task.tsx +++ b/src/components/features/tasklist/task.tsx @@ -55,10 +55,13 @@ export default function Task({ task, onKebabClick }: TaskProps) {
-
e.stopPropagation()} className="rounded px-[2px] hover:bg-gray-700"> +
e.stopPropagation()}> - - + + handleKebabClick("update")}> From 85f6f1a8c0aebe3bba824e0d235477017ee4207a Mon Sep 17 00:00:00 2001 From: luli Date: Thu, 27 Nov 2025 04:58:24 +0900 Subject: [PATCH 021/116] =?UTF-8?q?refactor:=20#109=20tasklist=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD(=E3=85=8E)=20=ED=9B=84=20arrow?= =?UTF-8?q?=20navi=EC=9E=91=EB=8F=99=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../@detail/(.)[taskId]/page.tsx | 0 .../{ => [taskListId]}/@detail/default.tsx | 0 .../{ => [taskListId]}/[taskId]/page.tsx | 0 .../{ => [taskListId]}/layout-content.tsx | 26 ++-- .../tasklist/{ => [taskListId]}/layout.tsx | 0 .../team/[id]/tasklist/[taskListId]/page.tsx | 55 ++++++++ .../{ => [taskListId]}/tasklist-client.tsx | 117 ++++++++++++++---- .../{ => [taskListId]}/tasklist-provider.tsx | 11 +- 8 files changed, 167 insertions(+), 42 deletions(-) rename src/app/(routes)/team/[id]/tasklist/{ => [taskListId]}/@detail/(.)[taskId]/page.tsx (100%) rename src/app/(routes)/team/[id]/tasklist/{ => [taskListId]}/@detail/default.tsx (100%) rename src/app/(routes)/team/[id]/tasklist/{ => [taskListId]}/[taskId]/page.tsx (100%) rename src/app/(routes)/team/[id]/tasklist/{ => [taskListId]}/layout-content.tsx (57%) rename src/app/(routes)/team/[id]/tasklist/{ => [taskListId]}/layout.tsx (100%) create mode 100644 src/app/(routes)/team/[id]/tasklist/[taskListId]/page.tsx rename src/app/(routes)/team/[id]/tasklist/{ => [taskListId]}/tasklist-client.tsx (67%) rename src/app/(routes)/team/[id]/tasklist/{ => [taskListId]}/tasklist-provider.tsx (90%) diff --git a/src/app/(routes)/team/[id]/tasklist/@detail/(.)[taskId]/page.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/@detail/(.)[taskId]/page.tsx similarity index 100% rename from src/app/(routes)/team/[id]/tasklist/@detail/(.)[taskId]/page.tsx rename to src/app/(routes)/team/[id]/tasklist/[taskListId]/@detail/(.)[taskId]/page.tsx diff --git a/src/app/(routes)/team/[id]/tasklist/@detail/default.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/@detail/default.tsx similarity index 100% rename from src/app/(routes)/team/[id]/tasklist/@detail/default.tsx rename to src/app/(routes)/team/[id]/tasklist/[taskListId]/@detail/default.tsx diff --git a/src/app/(routes)/team/[id]/tasklist/[taskId]/page.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/[taskId]/page.tsx similarity index 100% rename from src/app/(routes)/team/[id]/tasklist/[taskId]/page.tsx rename to src/app/(routes)/team/[id]/tasklist/[taskListId]/[taskId]/page.tsx diff --git a/src/app/(routes)/team/[id]/tasklist/layout-content.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/layout-content.tsx similarity index 57% rename from src/app/(routes)/team/[id]/tasklist/layout-content.tsx rename to src/app/(routes)/team/[id]/tasklist/[taskListId]/layout-content.tsx index afa3629e..d8b16ac5 100644 --- a/src/app/(routes)/team/[id]/tasklist/layout-content.tsx +++ b/src/app/(routes)/team/[id]/tasklist/[taskListId]/layout-content.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { layoutStyle } from "./index.styles"; +import { layoutStyle } from "../index.styles"; import { usePathname } from "next/navigation"; interface CommonProps { @@ -15,16 +15,20 @@ export default function LayoutContent({ children, detail }: CommonProps) { const [isOpenDetailModal, setIsOpenDetailModal] = useState(false); useEffect(() => { - const isClose = sessionStorage.getItem("closeDetailModal"); - const isModal = sessionStorage.getItem("openDetailModal"); - - if (isModal) { - setIsOpenDetailModal(true); - sessionStorage.removeItem("openDetailModal"); - } else if (isClose) { - setIsOpenDetailModal(false); - sessionStorage.removeItem("closeDetailModal"); - } + const checkModal = () => { + const isClose = sessionStorage.getItem("closeDetailModal"); + const isOpen = sessionStorage.getItem("openDetailModal"); + + if (isOpen) { + setIsOpenDetailModal(true); + sessionStorage.removeItem("openDetailModal"); + } else if (isClose) { + setIsOpenDetailModal(false); + sessionStorage.removeItem("closeDetailModal"); + } + }; + + checkModal(); }, [pathname]); return ( diff --git a/src/app/(routes)/team/[id]/tasklist/layout.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/layout.tsx similarity index 100% rename from src/app/(routes)/team/[id]/tasklist/layout.tsx rename to src/app/(routes)/team/[id]/tasklist/[taskListId]/layout.tsx diff --git a/src/app/(routes)/team/[id]/tasklist/[taskListId]/page.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/page.tsx new file mode 100644 index 00000000..0728f5e6 --- /dev/null +++ b/src/app/(routes)/team/[id]/tasklist/[taskListId]/page.tsx @@ -0,0 +1,55 @@ +import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"; +import TaskListClient from "./tasklist-client"; +import { isEmpty } from "@/lib/utils"; +import { redirect } from "next/navigation"; +import { getGroupTaskListsforServer } from "@/api/tasklist/index-server"; + +export default async function TasklistPage({ + params, +}: { + params: Promise<{ id: string; taskListId: string }>; +}) { + const { id, taskListId } = await params; + + if (isEmpty(id) || isEmpty(taskListId)) { + redirect("/"); + } + + const groupId = Number(id); + const queryClient = new QueryClient(); + + const groupResult = await getGroupTaskListsforServer(groupId); + + // const dateString = formatDateToISOString(currentDate).split("T")[0]; + + // let firstTaskListId: number | null = null; + + // if (groupResult?.taskLists?.length) { + // const first = groupResult.taskLists.find(t => t.displayIndex === 0); + // if (first) { + // firstTaskListId = first.id; + // } + // } + + // if (firstTaskListId !== null) { + // if (!isEmpty(firstTaskListId)) { + // await queryClient.prefetchQuery({ + // queryKey: ["taskList", groupId, firstTaskListId, dateString], + // queryFn: () => + // getTaskListForServer({ + // groupId: groupId, + // taskListId: firstTaskListId, + // date: formattedDate, + // }), + // }); + // } + // } + + return ( + <> + + + + + ); +} diff --git a/src/app/(routes)/team/[id]/tasklist/tasklist-client.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-client.tsx similarity index 67% rename from src/app/(routes)/team/[id]/tasklist/tasklist-client.tsx rename to src/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-client.tsx index 8ce401d0..bfba4d36 100644 --- a/src/app/(routes)/team/[id]/tasklist/tasklist-client.tsx +++ b/src/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-client.tsx @@ -6,7 +6,7 @@ import RightArrowIcon from "@/assets/icons/ic-arrow-right-circle.svg"; import CalendarIcon from "@/assets/icons/ic-calendar-circle.svg"; import PlusIcon from "@/assets/icons/ic-plus.svg"; import { createRecurring, createTask } from "@/hooks/taskList/use-tasklist"; -import { formatDateForToMonthAndDays, isEmpty } from "@/lib/utils"; +import { formatDateForToMonthAndDays, formatDateToISOString, isEmpty } from "@/lib/utils"; import { Button, Floating } from "@/components/ui"; import { useToggle } from "@/hooks"; import TaskAddTemplate from "@/components/features/tasklist/task-add-modal"; @@ -14,43 +14,79 @@ import TaskRecurringAddModal from "@/components/features/tasklist/task-recurring import { useEffect, useState } from "react"; import { useAlert } from "@/providers/alert-provider"; import { GroupTaskList } from "@/types/tasklist"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { taskDetailSchema, taskSchema } from "@/lib/schema"; import z4 from "zod/v4"; -import { dateTitleStyle, hiddenBrStyle, newListbuttonStyle } from "./index.styles"; +import { dateTitleStyle, hiddenBrStyle, newListbuttonStyle } from "../index.styles"; import { useTaskListContext } from "./tasklist-provider"; import TaskCardField from "@/components/features/tasklist/task-card-field"; interface TaskListPageProps { groupData: GroupTaskList; - taskListId?: number | null; - date: Date; + taskListId: string; + // date: string; } type ModalType = "task" | "recurring"; +type ArrowType = "prev" | "next"; + +export default function TasklistClient({ groupData, taskListId }: TaskListPageProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const { showAlert } = useAlert(); + const { + isTeam, + isLoading, + permissionCheck, + dateString, + currentISOStrDate, + setCurrentISOStrDate, + } = useTaskListContext(); -export default function TasklistClient({ groupData, taskListId, date }: TaskListPageProps) { const { isOpen: isOpenTask, setOpen: setOpenTask, setClose: setCloseTask } = useToggle(); const { isOpen: isOpenRecurring, setOpen: setOpenRecurring, setClose: setCloseRecurring, } = useToggle(); - const [activeTab, setActiveTab] = useState(taskListId || 0); - const { showAlert } = useAlert(); - const router = useRouter(); - const { isTeam, isLoading, permissionCheck, dateString } = useTaskListContext(); + const currentDate = new Date(); + currentDate.setHours(10, 0, 0, 0); + + const queryDate = searchParams.get("date"); + + const [activeTab, setActiveTab] = useState(Number(taskListId)); + + useEffect(() => { + let initialDate = queryDate; + + if (!queryDate) { + const today = new Date(); + today.setHours(10, 0, 0, 0); + initialDate = formatDateToISOString(today); + + const params = new URLSearchParams(searchParams.toString()); + params.set("date", initialDate); + router.replace(`${window.location.pathname}?${params.toString()}`, { scroll: false }); + setCurrentISOStrDate(initialDate); + } + }, [queryDate, router, searchParams, setCurrentISOStrDate]); + + const [titleCurrentDate, setTitleCurrentDate] = useState(""); + + useEffect(() => { + setTitleCurrentDate(formatDateForToMonthAndDays(new Date(currentISOStrDate))); + }, [currentISOStrDate]); + const { mutate: postRecurringMutate } = createRecurring(); const { mutate: taskMutate } = createTask(); - const currentDateStr = formatDateForToMonthAndDays(date); - const handleButtonClick = (type: ModalType) => { if (type === "task") { setOpenTask(); } else if (type === "recurring") { - if (!taskListId) { + if (!activeTab) { showAlert("할 일 그룹을 먼저 추가하여야 합니다."); return; } @@ -111,6 +147,27 @@ export default function TasklistClient({ groupData, taskListId, date }: TaskList } }; + const handleArrowClick = (type: ArrowType) => { + const currentDate = new Date(currentISOStrDate); + let newDate: Date; + newDate = currentDate; + + if (type === "prev") { + newDate.setDate(newDate.getDate() - 1); + } else if (type === "next") { + newDate.setDate(newDate.getDate() + 1); + } + + setCurrentISOStrDate(formatDateToISOString(newDate)); + + const newDateStr = formatDateToISOString(newDate); + setCurrentISOStrDate(newDateStr); + + const params = new URLSearchParams(searchParams.toString()); + params.set("date", newDateStr); + router.replace(`${window.location.pathname}?${params.toString()}`, { scroll: false }); + }; + useEffect(() => { if (!isLoading && !isTeam) { showAlert("해당 팀에 권한이 없습니다."); @@ -127,14 +184,22 @@ export default function TasklistClient({ groupData, taskListId, date }: TaskList
- {currentDateStr} + {titleCurrentDate}
-
+
-
+ +
+
@@ -147,7 +212,14 @@ export default function TasklistClient({ groupData, taskListId, date }: TaskList
- {!taskListId ? ( + {activeTab ? ( + + ) : (
아직 할 일 목록이 없습니다. @@ -155,13 +227,6 @@ export default function TasklistClient({ groupData, taskListId, date }: TaskList 새로운 목록을 추가해주세요.
- ) : ( - )}