Repository: SOPT-all/36-COLLABORATION-WEB-GOODOC
Pull request #40: Feat: 진료과 선택 컴포넌트 구현 등
Author: login: jstar000
Labels: name: ✨Feature
description: 기능 개발
name: 🐻지성
Created: 2025-05-15T13:39:59Z | Merged: 2025-05-16T04:31:48Z

# 🩺 Pull requests
### 📍 작업한 이슈번호
- #36 

### 💻 작업한 내용
- 진료과 선택 컴포넌트 구현
- QnAList 컴포넌트 구현

### 📢 PR Point
- 기능이나 라이브러리 설치했을 때 제때제때 커밋해야되는데 아직 그 습관이 안들어서 자꾸 한꺼번에 커밋하게 되네요.. 앞으로 커밋도 적절히 끊어서 해야겠어요
- 진료과 토클버튼 클릭 시 전체, 내과, 외과 ... 등 컴포넌트가 나타나는데, 해당 컴포넌트가 나타날 때의 애니메이션 효과는 Framer Motion 등으로 추후 구현할 예정입니다(지금은 우선 기본 UI부터 먼저 구현해두려고요)
- 진료과 컴포넌트는 가로 스크롤이 가능한 컴포넌트고, 스크롤바가 보이는게 거슬려 scrollbar-hide를 위해 tailwind에서 라이브러리를 설치하고 적용해봤는데 적용이 안되네요 아마 해당 라이브러리가 tailwind.config.js 파일을 사용하는게 문제이지 않을까 싶어요 최신버전(v4.1)에서 scrollbar-hide하는 방법 좀 더 알아보겠습니다
- 진료과 컴포넌트(DeptFilter.tsx)를 구현하면서 상태관리 코드를 커스텀 훅(useDeptFilter.ts)으로 분리해봤습니다. 일반적으로 한 페이지 안에 상태관리 코드와 UI가 함께 있다면 상태관리 메서드에 handle이나 on 접두어를 붙이는게 컨벤션인데, 커스텀 훅으로 분리하게 되면 이런 컨벤션을 따르지 않아도 된다고 해요. custom hook에서는 그냥 함수의 역할을 명확하게 드러내는 이름을 지을 수 있으므로 더 자유롭고 직관적인 네이밍이 가능해지고 코드도 깔끔해지니까 여러모로 좋은 것 같습니다ㅎㅎ

### 📸 스크린샷

https://github.com/user-attachments/assets/4a97ab4f-ad87-4e30-bddf-dfcd9b9ce34f




Diff:
diff --git a/package.json b/package.json
index 8273475..df530d8 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
     "prettier-plugin-tailwindcss": "^0.6.11",
     "react": "^19.1.0",
     "react-dom": "^19.1.0",
+    "tailwind-scrollbar-hide": "^2.0.0",
     "tailwindcss": "^4.1.6"
   },
   "devDependencies": {
diff --git a/src/main.tsx b/src/main.tsx
index 7a50a94..b738d71 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -23,4 +23,4 @@ createRoot(document.getElementById('root')!).render(
       <ReactQueryDevtools initialIsOpen={false} />
     </QueryClientProvider>
   </StrictMode>
-);
\ No newline at end of file
+);
diff --git a/src/pages/qna/qnaList/components/.gitkeep b/src/pages/qna/qnaList/components/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/pages/qna/qnaList/components/DeptFilter/DeptBox.tsx b/src/pages/qna/qnaList/components/DeptFilter/DeptBox.tsx
new file mode 100644
index 0000000..a14ed4d
--- /dev/null
+++ b/src/pages/qna/qnaList/components/DeptFilter/DeptBox.tsx
@@ -0,0 +1,21 @@
+interface DeptBoxProps {
+  deptName: string;
+  isSelected: boolean;
+  onBoxClick: () => void;
+}
+
+const DeptBox = ({ deptName, isSelected, onBoxClick }: DeptBoxProps) => {
+  return (
+    <button
+      type="button"
+      className={`flex border rounded-[.25rem] items-center justify-center p-[.625rem] w-[5.9375rem]
+        ${isSelected ? 'border-[#5B5E62]' : 'border-[#F1F1F1]'}
+      `}
+      onClick={onBoxClick}
+    >
+      <span className="title-semi-14 text-WGray">{deptName}</span>
+    </button>
+  );
+};
+
+export default DeptBox;
diff --git a/src/pages/qna/qnaList/components/DeptFilter/DeptFilter.tsx b/src/pages/qna/qnaList/components/DeptFilter/DeptFilter.tsx
new file mode 100644
index 0000000..34d8ae5
--- /dev/null
+++ b/src/pages/qna/qnaList/components/DeptFilter/DeptFilter.tsx
@@ -0,0 +1,37 @@
+import Chevron from '@shared/assets/svg/chevron-updown.svg?react';
+import { deptList } from '@/shared/constants/constant';
+import DeptBox from '@pages/qna/qnaList/components/DeptFilter/DeptBox';
+import { useDeptFilter } from '@pages/qna/qnaList/components/hook/useDeptFilter';
+
+const DeptFilter = () => {
+  const { showDeptList, selectedDept, toggleDeptList, selectDept } = useDeptFilter();
+
+  return (
+    <div className="flex flex-col w-full ml-[1.25rem] mt-[1.25rem]">
+      <button type="button" className="flex" onClick={toggleDeptList}>
+        <span className="title-bold-20 text-CGray-1 mr-[4px]">진료과 전체</span>
+        <Chevron
+          className={`transition-transform duration-300 ${!showDeptList ? 'rotate-180' : 'rotate-0'}`}
+        />
+      </button>
+
+      {/* 추후 Framer Motion 등으로 애니메이션 구현 예정 */}
+      {showDeptList && (
+        <div className="scrollbar-hide overflow-x-auto mt-[1.25rem]">
+          <div className="flex flex-wrap gap-x-[.5rem] gap-y-[.75rem] min-w-[71.875rem] px-4 py-2">
+            {deptList.map(dept => (
+              <DeptBox
+                key={dept}
+                deptName={dept}
+                isSelected={dept === selectedDept}
+                onBoxClick={() => selectDept(dept)}
+              />
+            ))}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default DeptFilter;
diff --git a/src/pages/qna/qnaList/components/QnA/QnAList.tsx b/src/pages/qna/qnaList/components/QnA/QnAList.tsx
new file mode 100644
index 0000000..301bfd0
--- /dev/null
+++ b/src/pages/qna/qnaList/components/QnA/QnAList.tsx
@@ -0,0 +1,19 @@
+import QnAListItem from './QnAListItem';
+
+const QnAList = () => {
+  return (
+    <div className="flex flex-col gap-[3rem] mt-[1.875rem]">
+      {Array.from({ length: 8 }).map((_, idx) => (
+        <QnAListItem
+          key={idx}
+          department="내과"
+          question="위고비를 맞아도 되나요?"
+          timeElapsed="22시간 전"
+          summary="당뇨약을 약하게 먹고 있는데 위고비를 맞아도 될까요?"
+        />
+      ))}
+    </div>
+  );
+};
+
+export default QnAList;
diff --git a/src/pages/qna/qnaList/components/QnAListItem.tsx b/src/pages/qna/qnaList/components/QnA/QnAListItem.tsx
similarity index 80%
rename from src/pages/qna/qnaList/components/QnAListItem.tsx
rename to src/pages/qna/qnaList/components/QnA/QnAListItem.tsx
index 4743af9..78fa2a6 100644
--- a/src/pages/qna/qnaList/components/QnAListItem.tsx
+++ b/src/pages/qna/qnaList/components/QnA/QnAListItem.tsx
@@ -10,13 +10,13 @@ interface QnAListItemProps {
 const QnAListItem = ({ question, summary, department, timeElapsed }: QnAListItemProps) => {
   return (
     <div className="flex flex-col w-[20.9375rem]">
-      <div className="flex gap-[.625rem] items-center text-CGray-4">
+      <div className="flex gap-[.5rem] items-center text-CGray-4">
         <span className="caption-semi-13">{department}</span>
         <Ellipsis />
         <span className="caption-reg-13">{timeElapsed}</span>
       </div>
 
-      <span className="title-semi-18 mt-[.625rem] line-clamp-2">{question}</span>
+      <span className="title-semi-18 mt-[1rem] line-clamp-2">{question}</span>
       <span className="body-reg-14 mt-[.5rem] text-CGray-4 line-clamp-2">{summary}</span>
     </div>
   );
diff --git a/src/pages/qna/qnaList/components/hook/useDeptFilter.ts b/src/pages/qna/qnaList/components/hook/useDeptFilter.ts
new file mode 100644
index 0000000..3caffd5
--- /dev/null
+++ b/src/pages/qna/qnaList/components/hook/useDeptFilter.ts
@@ -0,0 +1,21 @@
+import { useState } from 'react';
+
+export const useDeptFilter = () => {
+  const [showDeptList, setShowDeptList] = useState(false);
+  const [selectedDept, setSelectedDept] = useState<string | null>(null);
+
+  const toggleDeptList = () => {
+    setShowDeptList(prev => !prev);
+  };
+
+  const selectDept = (deptName: string) => {
+    setSelectedDept(deptName);
+  };
+
+  return {
+    showDeptList,
+    selectedDept,
+    toggleDeptList,
+    selectDept,
+  };
+};
diff --git a/src/pages/qna/qnaList/page/.gitkeep b/src/pages/qna/qnaList/page/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/pages/qna/qnaList/page/QnAHome.tsx b/src/pages/qna/qnaList/page/QnAHome.tsx
new file mode 100644
index 0000000..4b7d1ec
--- /dev/null
+++ b/src/pages/qna/qnaList/page/QnAHome.tsx
@@ -0,0 +1,13 @@
+import DeptFilter from '@pages/qna/qnaList/components/DeptFilter/DeptFilter';
+import QnAList from '@pages/qna/qnaList/components/QnA/QnAList';
+
+const QnAHome = () => {
+  return (
+    <div className="flex flex-col justify-center items-center">
+      <DeptFilter />
+      <QnAList />
+    </div>
+  );
+};
+
+export default QnAHome;
diff --git a/src/shared/assets/svg/chevron-updown.svg b/src/shared/assets/svg/chevron-updown.svg
new file mode 100644
index 0000000..c9b638e
--- /dev/null
+++ b/src/shared/assets/svg/chevron-updown.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5.84814 15L12.0091 9M18.1519 15L11.9909 9" stroke="#30343F" stroke-width="1.5" stroke-linecap="round"/>
+</svg>
diff --git a/src/shared/constants/constant.ts b/src/shared/constants/constant.ts
index e69de29..7b672d9 100644
--- a/src/shared/constants/constant.ts
+++ b/src/shared/constants/constant.ts
@@ -0,0 +1,23 @@
+export const deptList = [
+  '전체',
+  '소아청소년과',
+  '치과',
+  '내과',
+  '이비인후과',
+  '피부과',
+  '산부인과',
+  '안과',
+  '정신의학과',
+  '성형외과',
+  '정형외과',
+  '한의과',
+  '비뇨기과',
+  '가정의학과',
+  '신경외과',
+  '외과',
+  '흉부외과',
+  '마취통증과',
+  '영상의학과',
+  '신경과',
+  '재활의학과',
+];
diff --git a/src/shared/styles/global.css b/src/shared/styles/global.css
index d69c5b6..1a9c1db 100644
--- a/src/shared/styles/global.css
+++ b/src/shared/styles/global.css
@@ -33,8 +33,7 @@ button {
 
 input,
 textarea,
-select,
-button {
+select {
   font-family: inherit;
   font-size: inherit;
   border: 1px solid #ccc;
@@ -63,4 +62,4 @@ img {
 .scrollbar-hide {
   -ms-overflow-style: none;
   scrollbar-width: none;
-}
+
diff --git a/tailwind.config.js b/tailwind.config.js
index c41fb44..42f108e 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,6 +1,8 @@
+import scrollbarHide from 'tailwind-scrollbar-hide';
+
 /** @type {import('tailwindcss').Config} */
 export default {
-  content: ['./index.html', './src/**/*.{js,ts,jsx,tsx,css}'],
+  // content: ['./index.html', './src/**/*.{js,ts,jsx,tsx,css}'],
   safelist: ['font-bold', 'font-semibold', 'text-[1.5rem]', 'leading-[1.25rem]'],
   theme: {
     extend: {
@@ -9,4 +11,5 @@ export default {
       },
     },
   },
+  plugins: [scrollbarHide],
 };
diff --git a/yarn.lock b/yarn.lock
index 2ace74c..90b8691 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3322,6 +3322,11 @@ synckit@^0.11.0:
     "@pkgr/core" "^0.2.3"
     tslib "^2.8.1"
 
+tailwind-scrollbar-hide@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/tailwind-scrollbar-hide/-/tailwind-scrollbar-hide-2.0.0.tgz#2d25a3ba383cc7ec1bd516a4416759bb3428f88b"
+  integrity sha512-lqiIutHliEiODwBRHy4G2+Tcayo2U7+3+4frBmoMETD72qtah+XhOk5XcPzC1nJvXhXUdfl2ajlMhUc2qC6CIg==
+
 tailwindcss@4.1.6, tailwindcss@^4.1.6:
   version "4.1.6"
   resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.1.6.tgz#ebe62de22b7d8a8c1f76bd3a07fc37c3fcc36503"


Reviews:
user: login: mmaybei
body: 수고하셨습니다!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-15T16:44:40Z
user: login: jisooooooooooo
body: 상수 분리, 컴포넌트 분리,, 분리왕 👑
고생하셨습니다~~~🤩👍🏻
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T18:42:05Z
user: login: jstar000
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T02:22:52Z
user: login: jstar000
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T02:23:18Z
user: login: jstar000
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T02:24:04Z
user: login: heeyyeon
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-16T03:48:48Z

Review comments:
diff_hunk: @@ -1,4 +1,6 @@
+import QnAHome from './pages/qna/qnaList/page/QnAHome';
+
 function App() {
-  return <></>;
+  return <QnAHome />;
path: src/App.tsx
user: login: mmaybei
body: 요 부분은 원래대로 지워주셔야 할 것 같아요 ~!! 실수로 올라갔나 봅니당 ㅋㅋ ㅎㅎ
created_at: 2025-05-15T16:41:20Z
author_association: COLLABORATOR
path: src/pages/qna/qnaList/components/hook/useDeptFilter.ts
user: login: mmaybei
body: 커스텀 훅까지 최고 👍 
created_at: 2025-05-15T16:43:28Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,37 @@
+import Chevron from '@shared/assets/svg/chevron-updown.svg?react';
+import DeptBox from './DeptBox';
+import { deptList } from '@/shared/constants/constant';
+import { useDeptFilter } from '../hook/useDeptFilter';
path: src/pages/qna/qnaList/components/DeptFilter/DeptFilter.tsx
user: login: mmaybei
body: 궁금한 점이 있는데 어떤 건 @ 절대 경로를 사용하고 어떤 건 상대 경로를 사용한 차이? 이유?가 있나요 ??
created_at: 2025-05-15T16:44:25Z
author_association: COLLABORATOR
path: package.json
user: login: jisooooooooooo
body: 제 PR에도 적어놨지만 저는 global.css에
```
.scrollbar-hide::-webkit-scrollbar {
  display: none;
}
.scrollbar-hide {
  -ms-overflow-style: none;
  scrollbar-width: none;
}
```
 이런 식으로 적어서 사용했더니 스크롤바가 없어지더라구요!
created_at: 2025-05-15T18:03:29Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,37 @@
+import Chevron from '@shared/assets/svg/chevron-updown.svg?react';
+import DeptBox from './DeptBox';
+import { deptList } from '@/shared/constants/constant';
+import { useDeptFilter } from '../hook/useDeptFilter';
+
+const DeptFilter = () => {
+  const { showDeptList, selectedDept, toggleDeptList, selectDept } = useDeptFilter();
+
+  return (
+    <div className="flex flex-col w-full ml-[1.25rem] mt-[1.25rem]">
+      <button type="button" className="flex" onClick={toggleDeptList}>
+        <span className="title-bold-20 text-CGray-1 mr-[4px]">진료과 전체</span>
+        <Chevron
+          className={`transition-transform duration-300 ${!showDeptList ? 'rotate-180' : 'rotate-0'}`}
+        />
+      </button>
+
+      {/* 추후 Framer Motion 등으로 애니메이션 구현 예정 */}
+      {showDeptList && (
+        <div className="scrollbar-hide overflow-x-auto mt-[1.25rem]">
+          <div className="flex flex-wrap gap-x-[.5rem] gap-y-[.75rem] min-w-[71.875rem] px-4 py-2">
+            {deptList.map((dept, index) => (
+              <DeptBox
+                key={index}
path: src/pages/qna/qnaList/components/DeptFilter/DeptFilter.tsx
user: login: jisooooooooooo
body: key값으로 배열의 index를 주게 되면 배열 순서가 바뀌거나 할 때 잘못 매핑될 수도 있는 문제 때문에 고유한 값인 dept 사용 추천해 드립니다!
created_at: 2025-05-15T18:22:54Z
author_association: COLLABORATOR
diff_hunk: @@ -10,14 +10,14 @@ interface QnAListItemProps {
 const QnAListItem = ({ question, summary, department, timeElapsed }: QnAListItemProps) => {
   return (
     <div className="flex flex-col w-[20.9375rem]">
-      <div className="flex gap-[.625rem] items-center text-CGray-4">
+      <div className="flex gap-[.5rem] items-center text-CGray-4">
         <span className="caption-semi-13">{department}</span>
         <Ellipsis />
         <span className="caption-reg-13">{timeElapsed}</span>
       </div>
 
-      <span className="title-semi-18 mt-[.625rem] line-clamp-2">{question}</span>
-      <span className="body-reg-14 mt-[.5rem] text-CGray-4 line-clamp-2">{summary}</span>
+      <span className="title-semi-18 mt-[1rem] line-clamp-2">{question}</span>
+      <span className="body-reg-14 mt-[8px] text-CGray-4 line-clamp-2">{summary}</span>
path: src/pages/qna/qnaList/components/QnA/QnAListItem.tsx
user: login: jisooooooooooo
body: 엇 여긴 왜 px로 바꾸신 건가요?
created_at: 2025-05-15T18:36:43Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,37 @@
+import Chevron from '@shared/assets/svg/chevron-updown.svg?react';
+import DeptBox from './DeptBox';
+import { deptList } from '@/shared/constants/constant';
+import { useDeptFilter } from '../hook/useDeptFilter';
path: src/pages/qna/qnaList/components/DeptFilter/DeptFilter.tsx
user: login: jstar000
body: 컴포넌트 불러올 때 자동완성 기능을 사용하는게 편하다보니 이미지 불러올 때 제외하고는 그냥 자동완성대로 사용하고 있었습니다ㅎㅎ
상대경로가 너무 길어지는 등의 문제가 발생하는 상황이 아니라면 절대경로 방식이 경로 자동완성이 안된다는게 좀 불편한 것 같은데 혹시 직접 입력하고 계신가요? 제가 이 방식을 사용해본 적이 없어서 몰라서 여쭤봅니다!
created_at: 2025-05-16T02:22:52Z
author_association: MEMBER
path: package.json
user: login: jstar000
body: 오오 감사합니다 적용해볼게요!
created_at: 2025-05-16T02:23:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,37 @@
+import Chevron from '@shared/assets/svg/chevron-updown.svg?react';
+import DeptBox from './DeptBox';
+import { deptList } from '@/shared/constants/constant';
+import { useDeptFilter } from '../hook/useDeptFilter';
+
+const DeptFilter = () => {
+  const { showDeptList, selectedDept, toggleDeptList, selectDept } = useDeptFilter();
+
+  return (
+    <div className="flex flex-col w-full ml-[1.25rem] mt-[1.25rem]">
+      <button type="button" className="flex" onClick={toggleDeptList}>
+        <span className="title-bold-20 text-CGray-1 mr-[4px]">진료과 전체</span>
+        <Chevron
+          className={`transition-transform duration-300 ${!showDeptList ? 'rotate-180' : 'rotate-0'}`}
+        />
+      </button>
+
+      {/* 추후 Framer Motion 등으로 애니메이션 구현 예정 */}
+      {showDeptList && (
+        <div className="scrollbar-hide overflow-x-auto mt-[1.25rem]">
+          <div className="flex flex-wrap gap-x-[.5rem] gap-y-[.75rem] min-w-[71.875rem] px-4 py-2">
+            {deptList.map((dept, index) => (
+              <DeptBox
+                key={index}
path: src/pages/qna/qnaList/components/DeptFilter/DeptFilter.tsx
user: login: jstar000
body: 아아 넵 정적 배열이라 그냥 index를 사용했는데 dept값 자체를 사용하는게 더 좋겠네요 감사합니다!
created_at: 2025-05-16T02:24:03Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-15T13:40:30Z
body: ### 빌드 결과
빌드 성공 🎉
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-15T13:56:09Z
body: ### 빌드 결과
빌드 성공 🎉
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-16T02:41:05Z
body: ### 빌드 결과
빌드 성공 🎉
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-16T04:22:59Z
body: ### 빌드 결과
빌드 실패 ❌
author_association: NONE