React

 

 

 

Shadcn UI & Next.js - 대시보드 만들기 프로젝트

 

 기본 기능

  • 라이트 / 다크 모드 전환
  • 로그인 및 회원가입 페이지
    • 로그인 폼 및 유효성 검사 추가
    • 회원가입 시 추가 검증 기능 (예: 비밀번호 유효성 검사, 회사 계정 선택 시 추가 입력 필드)
  • UI 확장 기능
    • 비밀번호 토글 버튼 추가 (Shadcn UI 기본 제공 기능이 아님)
    • 캘린더 기능 확장 (월/연도 직접 선택 기능 추가)

 

 대시보드 기능

  • 메인 대시보드
    • 데이터 시각화 (그래프 및 차트 활용)
    • 라이트/다크 모드 지원
  • 팀 통계 페이지
    • 원형 차트(Pie Chart) 및 라인 차트(Line Chart) 활용
  • 직원 관리 페이지
    • 페이지네이션이 적용된 테이블
    • 데이터 로딩 상태(Skeleton UI) 적용
  • 반응형 디자인
    • 모바일 화면에서 카드형 UI 자동 정렬
    • 햄버거 메뉴(모바일 메뉴) 추가

 

이프로젝트는   Shadcn UI의 활용 및 확장에 초점을 맞춤니다.

 

 

소스 :  https://github.com/braverokmc79/nextjs-shadcn-app

 

 

 

1.랜딩 페이지 설정

 

 

1. Next.js 프로젝트 설정 및 Shadcn UI 설치 가이드

 

1. Next.js 프로젝트 생성

Shadcn UI를 활용한 Next.js 프로젝트를 설정하는 방법을 단계별로 설명합니다.

 

1.1 Node.js 및 npm 설치 확인

Shadcn UI를 설치하기 전에 먼저 Node.js와 npm이 설치되어 있는지 확인합니다. 설치되지 않았다면, Node.js 공식 사이트에서 최신 버전을 다운로드하여 설치하세요.

1.2 VS Code 실행 및 프로젝트 생성

VS Code를 열고 새 터미널 창을 연 후, 다음 명령어를 실행하여 최신 버전의 Next.js 프로젝트를 생성합니다.

npx create-next-app@latest

설치 마법사를 따라 프로젝트를 설정합니다.

  • 프로젝트 이름: nextjs-shadcn-app
  • TypeScript 사용:
  • ESLint 사용:
  • Tailwind CSS 사용:
  • src 디렉터리 사용: 아니오
  • App Router 사용:
  • Import alias 설정: 아니오
>npx create-next-app@latest
√ What is your project named? ... nextjs-shadcn-app
√ Would you like to use TypeScript? ... No / Yes
√ Would you like to use ESLint? ... No / Yes
√ Would you like to use Tailwind CSS? ... No / Yes
√ Would you like your code inside a `src/` directory? ... No / Yes
√ Would you like to use App Router? (recommended) ... No / Yes
√ Would you like to use Turbopack for `next dev`? ... No / Yes
√ Would you like to customize the import alias (`@/*` by default)? ... No / Yes
√ What import alias would you like configured? ... @/*
Creating a new Next.js app in E:\nextjs-shadcn-app.

Using npm.

Initializing project with template: app-tw


Installing dependencies:
- react
- react-dom
- next

Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- postcss
- tailwindcss
- eslint
- eslint-config-next
- @eslint/eslintrc

 

 

 

1.3 프로젝트 폴더 이동 및 VS Code에서 열기

cd nextjs-shadcn-app
code .

위 명령어를 실행하면 해당 프로젝트 디렉터리가 VS Code에서 열립니다.

 

2. Shadcn UI 설치

https://ui.shadcn.com/docs/installation/next

 

2.1 Shadcn UI 설치 명령어 실행

다음 명령어를 실행하여 Shadcn UI를 프로젝트에 추가합니다.

pnpm dlx shadcn@latest init

 

호환성 

Use --legacy-peer-deps 선택

? How would you like to proceed? » - Use arrow-keys. Return to submit.
    Use --force
>   Use --legacy-peer-deps

 

✔ Preflight checks.
✔ Verifying framework. Found Next.js.
✔ Validating Tailwind CSS.
✔ Validating import alias.
✔ Writing components.json.
✔ Checking registry.
✔ Updating tailwind.config.ts
✔ Updating src\app\globals.css
  Installing dependencies.

It looks like you are using React 19. 
Some packages may fail to install due to peer dependency issues in npm (see https://ui.shadcn.com/react-19).

√ How would you like to proceed? » Use --legacy-peer-deps

 

 

설치 과정에서 다음과 같은 설정을 진행합니다.

  • TypeScript 사용:
  • 기본 스타일 및 기본 색상 선택: Zinc
  • Global CSS 파일 경로: app/global.css
  • CSS 변수 사용 여부:
  • Tailwind 설정 파일 확장자: tailwind.config.ts
  • Import alias 설정 (@/components 및 @/utils 유지):
  • React Server Components 사용 여부:
  • components.json 파일 생성 여부:

위 설정을 완료하면 Shadcn UI가 프로젝트에 설치되고, 기본적인 디렉터리 구조가 생성됩니다.

 

3. Shadcn UI 구성 요소 추가

Shadcn UI는 일반적인 UI 라이브러리와 다르게 컴포넌트의 소스 코드를 직접 프로젝트에 복사하여 활용하는 방식입니다. 따라서 모든 컴포넌트를 자유롭게 수정할 수 있습니다.

 

3.1 버튼(Button) 컴포넌트 설치

터미널에서 다음 명령어를 실행하여 버튼 컴포넌트를 추가합니다.

nextjs-shadcn-app> npx shadcn@latest add button
✔ Checking registry.
  Installing dependencies.

It looks like you are using React 19. 
Some packages may fail to install due to peer dependency issues in npm (see https://ui.shadcn.com/react-19).

√ How would you like to proceed? » Use --legacy-peer-deps
✔ Installing dependencies.
✔ Created 1 file:
  - src\components\ui\button.tsx

이 명령어를 실행하면 components/ui/button.tsx 파일이 생성됩니다. 이제 프로젝트에서 버튼 컴포넌트를 자유롭게 수정하고 활용할 수 있습니다.

 

 

3.2 버튼 컴포넌트 렌더링

설치된 버튼 컴포넌트를 page.tsx 파일에서 불러와 사용할 수 있습니다.

import { Button } from "@/components/ui/button";

export default function Home() {
  return (
    <div className="flex justify-center items-center h-screen">
      <Button>로그인</Button>
    </div>
  );
}

이제 Next.js 프로젝트에서 Shadcn UI의 버튼 컴포넌트를 확인할 수 있습니다.

 

4. Tailwind CSS와 Shadcn UI

Shadcn UI는 Tailwind CSS를 활용하여 스타일링됩니다. 따라서 Tailwind CSS의 기본 개념을 알고 있으면 활용하기 좋습니다. 하지만 이 강의에서는 Tailwind CSS의 필수적인 부분을 다루므로, 추가 학습이 필요하지는 않습니다.

 

 

전체적은  테마 맞춤형 설정

https://ui.shadcn.com/themes

customize 버튼 클릭후 선택  후  Copy code 를 버튼을 클릭한다.

globals.css 에 붙여넣기 한다.

 

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 240 10% 3.9%;
    --card: 0 0% 100%;
    --card-foreground: 240 10% 3.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 240 10% 3.9%;
    --primary: 346.8 77.2% 49.8%;
    --primary-foreground: 355.7 100% 97.3%;
    --secondary: 240 4.8% 95.9%;
    --secondary-foreground: 240 5.9% 10%;
    --muted: 240 4.8% 95.9%;
    --muted-foreground: 240 3.8% 46.1%;
    --accent: 240 4.8% 95.9%;
    --accent-foreground: 240 5.9% 10%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 0 0% 98%;
    --border: 240 5.9% 90%;
    --input: 240 5.9% 90%;
    --ring: 346.8 77.2% 49.8%;
    --radius: 0.5rem;
    --chart-1: 12 76% 61%;
    --chart-2: 173 58% 39%;
    --chart-3: 197 37% 24%;
    --chart-4: 43 74% 66%;
    --chart-5: 27 87% 67%;
  }

  .dark {
    --background: 20 14.3% 4.1%;
    --foreground: 0 0% 95%;
    --card: 24 9.8% 10%;
    --card-foreground: 0 0% 95%;
    --popover: 0 0% 9%;
    --popover-foreground: 0 0% 95%;
    --primary: 346.8 77.2% 49.8%;
    --primary-foreground: 355.7 100% 97.3%;
    --secondary: 240 3.7% 15.9%;
    --secondary-foreground: 0 0% 98%;
    --muted: 0 0% 15%;
    --muted-foreground: 240 5% 64.9%;
    --accent: 12 6.5% 15.1%;
    --accent-foreground: 0 0% 98%;
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 0 85.7% 97.3%;
    --border: 240 3.7% 15.9%;
    --input: 240 3.7% 15.9%;
    --ring: 346.8 77.2% 49.8%;
    --chart-1: 220 70% 50%;
    --chart-2: 160 60% 45%;
    --chart-3: 30 80% 55%;
    --chart-4: 280 65% 60%;
    --chart-5: 340 75% 55%;
  }
}

 

 

한글 폰트 적용

Next.js에서는 app/globals.css 파일을 수정하여 전역 스타일을 적용할 수 있습니다.
아래와 같이 한글 폰트를 추가하세요.

1)globals.css

:root {
  --font-noto-sans-kr: "Noto Sans KR", sans-serif;
  --font-nanum-sans: "Nanum Gothic", sans-serif;
  --font-sans: var(--font-noto-sans-kr), var(--font-nanum-sans), "Pretendard", sans-serif;

}


@layer base {
  * {
    @apply border-border outline-ring/50;
  
  }
  html {
    font-family: var(--font-sans);
  }
  
  .font-nanum-gothic {
    font-family: var(--font-nanum-sans);
  }

  .font-noto-sans-kr {
    font-family: var(--font-noto-sans-kr);
  }
  
  body {
    @apply bg-background text-foreground;
    
  }
}






 

2)RootLayout

import type { Metadata } from "next";
import {  Noto_Sans_KR, Nanum_Gothic } from "next/font/google";
import "./globals.css";

const nanumGothic = Nanum_Gothic({
  variable: "--font-nanum-sans",
  subsets: ["latin"],
  weight: ["400", "700" ],
});
const notoSansKR = Noto_Sans_KR({
  variable: "--font-noto-sans-kr",
  subsets: ["latin", "cyrillic"],
  weight: ["400", "700"],
});


export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko" data-theme="blue">
      <body className={`${nanumGothic.variable} ${notoSansKR.variable}  antialiased`}>
        <div className="bg-background text-foreground p-6">
          
          <h1 className={`font-nanum-gothic text-2xl font-bold`}>
            나눔고딕  Tailwind 4.0에서 @theme 적용
          </h1>

          <p className={`text-2xl font-bold`}>
          Noto Sans KR 은 1순위 기본 폰트 설정 완료!
          </p>
        </div>

        {children}
      </body>
    </html>
  );
}

 

===>위와 같이  설정하면 적용이 되나  해당  폰트를 다운 받지 못해서 오류가 발생한다면 @font-face로 완전히 로컬화 하는 방법

✅ 1단계: 폰트 파일 다운로드

예시: Noto Sans KR, Nanum Gothic

???? 다운로드 경로

???? Google Fonts Helper

  1. 원하는 폰트를 검색 (예: Noto Sans KR, Nanum Gothic)

  2. 필요한 weight 선택 (예: 400, 500, 700 등)

  3. "Download files" 클릭

다운로드하면 .woff2, .woff, .ttf 등이 포함된 zip 파일이 생겨요.

 

✅ 2단계: 폰트 파일 저장

public/fonts/ 디렉토리에 저장합니다.

 

✅ 3단계: @font-face 작성

globals.css 또는 별도 fonts.css에 아래처럼 작성하세요:

globals.css 최상단에 작성할것.

@font-face {
  font-family: 'Nanum Gothic';
  src: url('/fonts/Nanum_Gothic/NanumGothic-Regular.ttf') format('truetype');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Nanum Gothic';
  src: url('/fonts/Nanum_Gothic/NanumGothic-Bold.ttf') format('truetype');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Nanum Gothic';
  src: url('/fonts/Nanum_Gothic/NanumGothic-ExtraBold.ttf') format('truetype');
  font-weight: 800;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Noto Sans KR';
  src: url('/fonts/Noto_Sans_KR/NotoSansKR-VariableFont_wght.ttf') format('truetype');
  font-weight: 100 900; 
  font-style: normal;
  font-display: swap;
}


@import "tailwindcss";
@import "tw-animate-css";

~

 

 

✅ 4단계: 전역 폰트 변수로 설정

globals.css

:root {
  --font-noto-sans-kr: "Noto Sans KR", sans-serif;
  --font-nanum-sans: "Nanum Gothic", sans-serif;
  --font-sans: var(--font-noto-sans-kr), var(--font-nanum-sans), "Pretendard", sans-serif;


}

 

✅ 5단계: 적용

globals.css

@layer base {
  * {
    @apply border-border outline-ring/50;
  
  }
  html {
    font-family: var(--font-sans);
  }
  
  .font-nanum-gothic {
    font-family: var(--font-nanum-sans);
  }

  .font-noto-sans-kr {
    font-family: var(--font-noto-sans-kr);
  }
  
  body {
    @apply bg-background text-foreground;
    
  }
}

 

Tailwind 커스텀 폰트로 설정 (선택)

tailwind.config.ts:

extend: {
  fontFamily: {
    noto: 'var(--font-noto-sans-kr)',
    nanum: 'var(--font-nanum-sans)',
  }
}

 

RootLayout

import type { Metadata } from "next";
import "./globals.css";


export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko" data-theme="blue">
      <body className={` antialiased`}>
        <div className="bg-background text-foreground p-6">
          
          <h1 className={`font-nanum-gothic text-2xl font-black`}>
            나눔고딕  Tailwind 4.0에서 @theme 적용
          </h1>

          <p className={`text-2xl font-bold  `}>
          Noto Sans KR 은 1순위 기본 폰트 설정 완료!
          </p>
        </div>

        {children}
      </body>
    </html>
  );
}

 

font-weight

https://tailwindcss.com/docs/font-weight

 

 

 

 

 

 

 

 

 

 

 

 

Tailwind 4.0 은 다음과 같이 변경

Shadncn ui 의 테마에서  테마 선택후  ai 로  Tailwind 4.0 변경 

다음고 같이 변경한다.

:root {
  --font-noto-sans-kr: "Noto Sans KR", sans-serif;
  --font-nanum-sans: "Nanum Gothic", sans-serif;
  --font-sans: var(--font-noto-sans-kr), var(--font-nanum-sans), "Pretendard", sans-serif;

  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --accent-foreground: oklch(0.205 0 0);
  --destructive: oklch(0.577 0.245 27.325);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --chart-1: oklch(0.646 0.222 41.116);
  --chart-2: oklch(0.6 0.118 184.704);
  --chart-3: oklch(0.398 0.07 227.392);
  --chart-4: oklch(0.828 0.189 84.429);
  --chart-5: oklch(0.769 0.188 70.08);
  --sidebar: oklch(0.985 0 0);
  --sidebar-foreground: oklch(0.145 0 0);
  --sidebar-primary: oklch(0.205 0 0);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.97 0 0);
  --sidebar-accent-foreground: oklch(0.205 0 0);
  --sidebar-border: oklch(0.922 0 0);
  --sidebar-ring: oklch(0.708 0 0);

  --background: oklch(1 0 0);
  --foreground: oklch(0.15 0 270);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.15 0 270);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.15 0 270);
  --primary: oklch(0.62 0.28 11);
  --primary-foreground: oklch(0.98 0.02 30);
  --secondary: oklch(0.96 0.02 270);
  --secondary-foreground: oklch(0.2 0.02 270);
  --muted: oklch(0.96 0.02 270);
  --muted-foreground: oklch(0.5 0.02 270);
  --accent: oklch(0.96 0.02 270);
  --accent-foreground: oklch(0.2 0.02 270);
  --destructive: oklch(0.7 0.32 25);
  --destructive-foreground: oklch(0.98 0 0);
  --border: oklch(0.92 0.02 270);
  --input: oklch(0.92 0.02 270);
  --ring: oklch(0.62 0.28 11);
  --radius: 0.5rem;

  /* ✅ 차트 색상 (Tailwind 4.0 최적화) */
  --chart-1: oklch(0.72 0.3 70);
  --chart-2: oklch(0.55 0.25 150);
  --chart-3: oklch(0.42 0.2 200);
  --chart-4: oklch(0.78 0.35 80);
  --chart-5: oklch(0.82 0.38 40);
}




.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.205 0 0);
  --popover-foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --accent: oklch(0.269 0 0);
  --accent-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.704 0.191 22.216);
  --border: oklch(1 0 0 / 10%);
  --input: oklch(1 0 0 / 15%);
  --ring: oklch(0.556 0 0);
  --chart-1: oklch(0.488 0.243 264.376);
  --chart-2: oklch(0.696 0.17 162.48);
  --chart-3: oklch(0.769 0.188 70.08);
  --chart-4: oklch(0.627 0.265 303.9);
  --chart-5: oklch(0.645 0.246 16.439);
  --sidebar: oklch(0.205 0 0);
  --sidebar-foreground: oklch(0.985 0 0);
  --sidebar-primary: oklch(0.488 0.243 264.376);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.269 0 0);
  --sidebar-accent-foreground: oklch(0.985 0 0);
  --sidebar-border: oklch(1 0 0 / 10%);
  --sidebar-ring: oklch(0.556 0 0);

  --background: oklch(0.2 0.02 90);
  --foreground: oklch(0.95 0 0);
  --card: oklch(0.25 0.02 80);
  --card-foreground: oklch(0.95 0 0);
  --popover: oklch(0.1 0.02 80);
  --popover-foreground: oklch(0.95 0 0);
  --primary: oklch(0.62 0.28 11);
  --primary-foreground: oklch(0.98 0.02 30);
  --secondary: oklch(0.3 0.02 270);
  --secondary-foreground: oklch(0.98 0 0);
  --muted: oklch(0.25 0 0);
  --muted-foreground: oklch(0.6 0.02 270);
  --accent: oklch(0.3 0.02 80);
  --accent-foreground: oklch(0.98 0 0);
  --destructive: oklch(0.45 0.3 25);
  --destructive-foreground: oklch(0.98 0.02 10);
  --border: oklch(0.3 0.02 270);
  --input: oklch(0.3 0.02 270);
  --ring: oklch(0.62 0.28 11);

  /* ✅ 다크 모드 차트 색상 */
  --chart-1: oklch(0.65 0.32 240);
  --chart-2: oklch(0.52 0.28 130);
  --chart-3: oklch(0.48 0.3 50);
  --chart-4: oklch(0.72 0.35 290);
  --chart-5: oklch(0.68 0.38 350);
}

 

 

 

 

 

 

 

 

 

React 19 관련 업데이트 안내

다음과 같은 문구가 나오는데,   Use --legacy-peer-deps 를 추천 하지만  이것 보다 React 18로 버전을 롤백할 것을 추천합니다.

 How would you like to proceed? » - Use arrow-keys. Return to submit.
>   Use --force
    Use --legacy-peer-deps

 

React 19가 출시되었지만, 일부 npm 패키지가 아직 완벽히 지원하지 않습니다. 따라서, React 18로 버전을 롤백할 것을 추천합니다.

React 18로 버전 다운그레이드 방법

  1. package.json 수정

    • react 및 react-dom을 18 버전으로 변경
    • @types/react 및 @types/react-dom도 18 버전으로 수정
  2. 새로운 버전 설치

    npm i
    
    • 위 명령어를 실행하여 패키지를 다시 설치

Shared CDN UI 설치 명령어 변경

  • 기존:
    npx shared-cdn-ui@latest add button
    
  • 변경 후:
    npx shadcn@latest add button
    
    • shared-cdn-ui 대신 shadcn  으로 간략화됨

 

 

 

 

 

 

 

 

 

2. RootLayout 에서 폰트적용 및 다크 모드 기본 적용

 

1.LandingPage 컴포넌트

import { Button } from "@/components/ui/button";
import React from "react";

const LandingPage:React.FC = () => {
  return (
    <div>
      <h1>SupportMe</h1>  
      <Button>로그인</Button>  
      <Button>로그아웃</Button>  
    </div>
  );
};

export default LandingPage;

 

위 코드에서는 SupportMe라는 제목과 로그인, 로그아웃 버튼을 추가했습니다.

 

1. 폰트 설정

1.1 기본 폰트 변경

Next.js는 기본적으로 Inter 폰트를 사용하지만, 이를 Noto Sans KR, Poppins, Geist Sans로 변경합니다.

layout.tsx

import type { Metadata } from "next";
import { Noto_Sans_KR, Poppins, Geist } from "next/font/google";
import { cn } from "@/lib/utils";;
import { cookies } from "next/headers"; 

import "./globals.css";

// 폰트 설정
const notoSansKR = Noto_Sans_KR({
  subsets: ["latin"], 
  weight: ["400", "500", "600", "700", "800", "900"],
  variable: "--font-noto-sans-kr",
});

const poppins = Poppins({
  subsets: ["latin"],
  weight: ["400", "500", "600", "700", "800", "900"],
  variable: "--font-poppins",
});

const geistSans = Geist({
  subsets: ["latin"],
  variable: "--font-geist-sans",
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
   // 서버에서 다크 모드 쿠키 가져오기
   const darkModeCookie = (await cookies()).get("dark-mode")?.value;
   const isDarkMode = darkModeCookie === "true"; 
 
  return (
    <html lang="ko" >
      <body
        className={cn(
          `antialiased,
          ${notoSansKR.variable},
          ${poppins.variable},
          ${geistSans.variable},
          ${isDarkMode ? "dark" : ""}
        `)}
      >
        {children}
      </body>
    </html>
  );
}

위 코드에서는 dark 모드를 기본 설정하며, cn 유틸리티 함수를 이용해 Tailwind CSS 클래스를 결합했습니다.

 

components/light-dark-toggle.tsx

"use client";
import React, { useEffect, useState } from "react";
import Cookies from "js-cookie";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "./tooltip";
import { MoonIcon, SunIcon } from "lucide-react";

interface LightDarkToggleProps {
  className?: string;
}
const LightDarkToggle: React.FC<LightDarkToggleProps> = ({ className }) => {
  const [isDarkMode, setIsDarkMode] = useState(false);

  //마운트시 쿠키에서 다크 모드상태 불러오기
  useEffect(()=>{
    const savedTheme = Cookies.get("dark-mode");
    if(savedTheme==="true"){
        setIsDarkMode(true);
        document.body.classList.add("dark");
    }
  },[]);

  const toggleTheme = () => {
    const newMode = !isDarkMode;
    setIsDarkMode(newMode);
    document.body.classList.toggle("dark", newMode);
    Cookies.set("dark-mode", newMode.toString(), { expires: 365 }); // 1년 동안 유지
  };

  return (
    <>
      <TooltipProvider>
        <Tooltip>
          <TooltipTrigger
            className={className}
            onClick={toggleTheme}        
          >
            
            {isDarkMode ? <MoonIcon /> : <SunIcon />}
          </TooltipTrigger>
          <TooltipContent>
            {isDarkMode ? "밝은 테마" : "어두운 테마"}
          </TooltipContent>
        </Tooltip>
      </TooltipProvider>
    </>
  );
};

export default LightDarkToggle;

 

 

 

2. Tailwind 유틸리티 함수 설정

Tailwind CSS에서 클래스명을 결합하는 유틸리티 함수를 추가합니다.

 

clsx와 tailwind-merge는 Tailwind CSS를 설치할 때 자동으로 설치되지 않습니다.

위 코드는  Tailwind CSS 클래스들을 효과적으로 병합하는 유틸리티 함수(cn) 를 정의한 것으로, clsx와 tailwind-merge 패키지가 필요합니다.

clsx와 tailwind-merge의 역할

1.clsx: 여러 개의 CSS 클래스를 조합하고, 조건부로 추가하거나 제거하는 기능을 제공

2.tailwind-merge: Tailwind의 중복되거나 충돌하는 클래스를 자동으로 정리

 

직접 설치해야 함

따라서 cn 함수를 사용하려면 다음 패키지를 직접 설치해야 합니다.

npm install clsx tailwind-merge

이제 cn 함수를 사용하면 Tailwind 클래스를 깔끔하게 병합할 수 있습니다.
예를 들어,

const buttonClass = cn("bg-blue-500", "hover:bg-blue-700", "text-white", "bg-red-500")  
console.log(buttonClass) // "bg-red-500 hover:bg-blue-700 text-white" (bg-blue-500이 제거됨)

 

 

cn 유틸리티 함수

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

clsx와 twMerge를 사용하여 중복되는 Tailwind 클래스를 병합합니다.

 

 

3. 라우팅 구조 설계

Next.js의 App Router를 활용하여 로그인, 회원가입, 랜딩 페이지를 공통 레이아웃으로 그룹화합니다.

 

3.1 루트 그룹 설정

라우팅 구조를 효율적으로 구성하기 위해, 로그인 및 회원가입 페이지를 logged-out 그룹으로 묶습니다.

app/
  ├── (logged-out)/
  │   ├── layout.tsx
  │   ├── page.tsx

layout.tsx (공통 레이아웃 적용)

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      {children}
    </div>
  );
}

page.tsx (랜딩 페이지)

export default function LandingPage() {
  return (
    <div>
      <h1>SupportMe</h1>
      <button>로그인</button>
      <button>회원가입</button>
    </div>
  );
}

이제 http://localhost:3000/ 경로에서 랜딩 페이지가 렌더링됩니다.

 

4. 다크 모드 기본 적용

Tailwind CSS의 dark mode 클래스를 활용하여 기본 테마를 다크 모드로 설정합니다.

다크 모드 적용 코드

 // 서버에서 다크 모드 쿠키 가져오기
   const darkModeCookie = (await cookies()).get("dark-mode")?.value;
   const isDarkMode = darkModeCookie === "true"; 
 
  return (
    <html lang="ko" >
      <body
        className={cn(
          `antialiased,
          ${notoSansKR.variable},
          ${poppins.variable},
          ${geistSans.variable},
          ${isDarkMode ? "dark" : ""}
        `)}
      >
        {children}
      </body>
    </html>
  );

dark 클래스를 body 태그에 추가하여 기본적으로 다크 모드를 활성화합니다.

 

5. 결론

본 문서에서는 Next.js 15의 App Router와 shadcn을 활용한 프로젝트 설정 및 구성 방식을 정리했습니다.

  1. 랜딩 페이지 구성
  2. 폰트 변경 (Noto Sans KR, Poppins, Geist Sans 적용)
  3. Tailwind CSS 유틸리티 함수 추가
  4. 라우팅 그룹화 (logged-out 그룹 생성)
  5. 다크 모드 기본 활성화

 

 

 

 

 

 

3. 랜딩 페이지 스타일과 로그인 및 가입 페이지 링크 정리하기

 

LandingPage

import { Button } from "@/components/ui/button";
import { PersonStandingIcon } from "lucide-react";
import Link from "next/link";
import React from "react";

const LandingPage: React.FC = () => {
  return (
    <>
      <h1 className="flex gap-2 items-center">
        <PersonStandingIcon size={50} className="text-pink-500" /> SupportMe
      </h1>
      <p>고객 지원을 관리하는 최고의 대시보드</p>
      <div className="flex gap-2 items-center">
        <Button asChild>
          <Link href="/login">로그인</Link>
        </Button>
        <small>또는</small>
        <Button asChild variant="outline">
          <Link href="/sign-up">로그아웃</Link>
        </Button>
      </div>
    </>
  );
};

export default LandingPage;

 

2. 공통 레이아웃 구현

로그아웃 상태에서 사용할 공통 레이아웃을 정의하여 모든 관련 페이지를 중앙 정렬하도록 설정합니다.

import React from "react";

type LoggedOutLayoutProps = {
  children: React.ReactNode;
};

const LoggedOutLayout: React.FC<LoggedOutLayoutProps> = ({ children }) => {
  return (
    <div className="flex flex-col gap-4 min-h-screen p-24 items-center justify-center">
      {children}
    </div>
  );
};

export default LoggedOutLayout;

 

 

3. globalsCSS 설정

Tailwind CSS의 base layer를 활용하여 모든 제목 태그(H1~H6)에 기본 스타일을 적용합니다.

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
  }

  h1, h2, h3, h4, h5, h6 {
    @apply text-primary font-bold;
  }
  h1 {
    font-size: 3.2rem;
  }
  h2 {
    font-size: 2.8rem;
  }
  h3 {
    font-size: 2.4rem;
  }
  h4 {
    font-size: 2rem;
  }
  h5 {
    font-size: 1.8rem;
  }
  h6 {
    font-size: 1.6rem;
  }
}

 

 

 

 

 

 

4.   shadcn  테마 맞춤형 설정 (Customise shadcn ui theme  )

 

1. 프로젝트 개요

이 프로젝트는 Next.js 15의 App Router 방식을 활용하여 구축되었으며, UI 컴포넌트 스타일링에는 ShadCN UI를 사용하고 있습니다. Tailwind CSS 기반의 ShadCN을 활용하여 다양한 UI 컴포넌트를 직접 커스터마이징할 수 있으며, 이를 통해 일관된 디자인 시스템을 유지하면서도 유연한 스타일링이 가능합니다.

 

https://ui.shadcn.com/themes

 

https://tailwindcss.com/docs/background-color

 

 

2. 글로벌 스타일 설정 (globals.css)

globals.css는 전체 애플리케이션에 적용되는 전역 스타일을 정의합니다. ShadCN UI의 기본 스타일을 기반으로 커스텀 색상 및 디자인을 적용할 수 있습니다.

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 240 10% 3.9%;
    --primary: 333 71% 51%; /* 핑크 계열 기본 색상 */
    --primary-foreground: 0 0% 100%; /* 흰색 텍스트 */
    --border: 240 5.9% 90%;
    --radius: 0.5rem;
  }
  .dark {
    --background: 240 10% 3.9%;
    --foreground: 0 0% 98%;
    --primary: 333 71% 51%;
    --primary-foreground: 0 0% 100%;
  }
}

위와 같이 primary 색상을 HSL 값으로 핑크 계열(333 71% 51%)로 설정하고, primary-foreground를 흰색(0 0% 100%)으로 설정하여 대비를 극대화했습니다.

 

 

3. 버튼 컴포넌트 커스터마이징 (button.tsx)

ShadCN UI의 버튼 컴포넌트는 cva를 활용하여 다양한 스타일 변형을 지원합니다. 아래와 같이 tracking-wider를 추가하여 글자 간격을 조정하고, font-bold와 uppercase 속성을 추가하여 가독성을 높였습니다.

import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
      },
      size: {
        default: "h-9 px-4 py-2",
        sm: "h-8 rounded-md px-3 text-xs",
        lg: "h-10 rounded-md px-8",
        icon: "h-9 w-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        className={cn(
          "tracking-wider", // 글자 간격 증가
          "font-bold",  // 글씨 두께 증가
          "uppercase",  // 대문자 변환
          buttonVariants({ variant, size, className })
        )}
        ref={ref}
        {...props}
      />
    );
  }
);

Button.displayName = "Button";

export { Button, buttonVariants };

 

4. ShadCN 테마 커스터마이징

ShadCN UI의 테마를 커스터마이징하기 위해, globals.css에서 기본적으로 제공되는 변수들을 수정하여 적용할 수 있습니다. 예를 들어, primary 색상을 Tailwind CSS에서 제공하는 pink-600 색상(DB2777)으로 변경하고, 이를 HSL 값으로 변환하여 적용하였습니다.

이를 통해, 기본 버튼 스타일뿐만 아니라 전체적인 UI의 컬러 팔레트를 쉽게 변경할 수 있습니다.

 

5. 프로젝트 적용 후 결과

  • globals.css에서 primary 색상을 핑크 계열로 변경하여 일관된 스타일 유지
  • 버튼 컴포넌트에서 tracking-wider, font-bold, uppercase 적용으로 가독성 향상
  • ShadCN UI의 변수 기반 스타일링을 활용하여 테마 변경을 유연하게 적용

 

 

 

 

 

 

5.   UI 테마 커스터마이징과 다크 모드 토글 기능을 추가

 

 

 

Next.js 15 앱 라우터 방식 + shadcn UI 프로젝트 정리

1. 프로젝트 개요

Next.js 15의 앱 라우터(App Router) 방식과 shadcn UI 라이브러리를 활용하여 프로젝트를 구축하는 방법을 정리합니다. 또한, UI 테마 커스터마이징과 다크 모드 토글 기능을 추가하는 방법도 다룹니다.

 

2. Shadcn UI의 Tooltip 컴포넌트 추가

Shadcn UI에서 제공하는 Tooltip 컴포넌트를 사용하려면 아래 명령어를 실행합니다.

npx shadcn@latest add tooltip

이후, tooltip.tsx 파일을 생성하여 Tooltip 컴포넌트를 정의합니다.

 

3. 다크 모드 토글 기능 구현

3.1 LightDarkToggle 컴포넌트 생성

"use client";
import React, { useEffect, useState } from "react";
import Cookies from "js-cookie";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "./tooltip";
import { MoonIcon, SunIcon } from "lucide-react";

interface LightDarkToggleProps {
  className?: string;
}
const LightDarkToggle: React.FC<LightDarkToggleProps> = ({ className }) => {
  const [isDarkMode, setIsDarkMode] = useState(false);

  //마운트시 쿠키에서 다크 모드상태 불러오기
  useEffect(()=>{
    const savedTheme = Cookies.get("dark-mode");
    if(savedTheme==="true"){
        setIsDarkMode(true);
        document.body.classList.add("dark");
    }
  },[]);

  const toggleTheme = () => {
    const newMode = !isDarkMode;
    setIsDarkMode(newMode);
    document.body.classList.toggle("dark", newMode);
    Cookies.set("dark-mode", newMode.toString(), { expires: 365 }); // 1년 동안 유지
  };

  return (
    <>
      <TooltipProvider>
        <Tooltip>
          <TooltipTrigger
            className={className}
            onClick={toggleTheme}        
          >
            
            {isDarkMode ? <MoonIcon /> : <SunIcon />}
          </TooltipTrigger>
          <TooltipContent>
            {isDarkMode ? "밝은 테마" : "어두운 테마"}
          </TooltipContent>
        </Tooltip>
      </TooltipProvider>
    </>
  );
};

export default LightDarkToggle;

 

4. 레이아웃에 다크 모드 토글 추가

LoggedOutLayout.tsx 파일을 생성하고, 위에서 만든 LightDarkToggle 컴포넌트를 포함합니다.

import LightDarkToggle from "@/components/ui/light-dark-toggle";
import React from "react";

type LoggedOutLayoutProps = {
  children: React.ReactNode;
};

const LoggedOutLayout: React.FC<LoggedOutLayoutProps> = ({ children }) => {
  return (
    <div className="flex flex-col gap-4 min-h-screen p-24 items-center justify-center">
      {children}
      <LightDarkToggle className="fixed top-[calc(50%-12px)] right-2 " />
    </div>
  );
};

export default LoggedOutLayout;

 

 

 

5. UI 테마 커스터마이징

5.1 테마 변경을 위한 기본 설정

Shadcn UI에서 제공하는 기본 테마를 변경하기 위해, global.css 파일을 수정합니다.

  1. Shadcn UI 테마 페이지에서 zinc 테마를 선택합니다.
  2. 테마를 커스터마이징한 후, CSS 변수를 복사합니다.
  3. global.css 파일에서 기본 스타일을 덮어씁니다.

5.2 버튼 스타일 변경

기본 버튼 색상을 Tailwind CSS의 pink-600 색상으로 변경하려면 아래와 같이 설정합니다.

:root {
  --primary: hsl(333, 71%, 51%);
  --primary-foreground: hsl(0, 0%, 100%);
}

.dark {
  --primary: hsl(333, 71%, 51%);
  --primary-foreground: hsl(0, 0%, 100%);
}

 

5.3 버튼 스타일 조정

button.tsx 파일을 열고, Tailwind 클래스를 추가하여 스타일을 개선합니다.

export function Button({ children, className, ...props }: ButtonProps) {
  return (
    <button
      className={cn(
        "tracking-wider font-bold uppercase bg-primary text-primary-foreground",
        className
      )}
      {...props}
    >
      {children}
    </button>
  );
}

 

6. 결론

이번 정리를 통해 Next.js 15의 앱 라우터 방식을 활용하여 Shadcn UI를 적용하고, 다크 모드 토글 기능을 추가하는 방법을 알아보았습니다.

또한, UI 테마를 커스터마이징하여 더 나은 사용자 경험을 제공할 수 있도록 설정하는 방법을 정리했습니다. 이를 활용하여 프로젝트의 UI를 더욱 세밀하게 조정할 수 있습니다.

 

 

 

 

 

 

 

2.로그인 폼 만들기

 

6. 로그인페이지 추가 및 카드 추가

 

 

개요

이 문서는 Next.js 15의 앱 라우터(App Router) 방식과 ShadCN UI 라이브러리를 활용하여 로그인 페이지를 구현하는 방법을 정리한 것입니다. 기본적인 카드(Card) 컴포넌트를 추가하고, 로그인 폼을 구성하며, UI 스타일을 개선하는 과정까지 다룹니다.

 

ShadCN Card 컴포넌트 추가

로그인 UI의 핵심 요소로 Card 컴포넌트를 활용할 것입니다. 이를 위해 먼저 ShadCN의 Card 컴포넌트를 설치해야 합니다.

npx shadcn@latest add card

이제 Card 컴포넌트를 활용하여 로그인 페이지를 구성합니다.

 

로그인 페이지 코드 구현

"use client";

import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import Link from 'next/link';
import React from 'react';

const LoginPage: React.FC = () => {
  return (
    <Card className='w-full max-w-sm'>
      <CardHeader>
        <CardTitle>로그인</CardTitle>
        <CardDescription>SupportMe 계정에 로그인하세요.</CardDescription>
      </CardHeader>
      <CardContent>
        로그인 폼 (추후 추가 예정)
      </CardContent>
      <CardFooter className='flex justify-between'>
        <small>계정이 없으신가요?</small>
        <Button asChild variant='outline' size='sm'>
          <Link href='/sign-up'>회원가입</Link>
        </Button>
      </CardFooter>
    </Card>
  );
};

export default LoginPage;

로그인 경로 설정

로그인 페이지를 만들기 위해 Next.js의 앱 디렉토리 구조에 따라 다음과 같은 경로를 설정해야 합니다.

  1. app/(logged-out)/login/page.tsx 파일을 생성합니다.
  2. 해당 파일에서 LoginPage 컴포넌트를 내보냅니다.
  3. localhost:3000/login으로 접속하면 로그인 페이지가 표시됩니다.

ShadCN UI 문서 참고

ShadCN UI에서 제공하는 다양한 컴포넌트를 활용하기 위해 공식 문서를 참고할 수 있습니다.

  • Card: 기본적인 카드 형태의 컨테이너
  • Form: React Hook Form을 활용한 폼 관리
  • Button: 다양한 스타일과 크기의 버튼

 

 

 

 

 

 

7. 로그인페이지 zod 를 활용한 유효성  추가

 

Next.js 15 + App Router + ShadCN을 이용한 로그인 폼 구축

1. 개요

이 강의에서는 Next.js 15의 App Router를 사용하여 ShadCN UI 라이브러리로 로그인 폼을 구축하는 방법을 설명합니다. 또한 react-hook-form과 zod를 활용한 유효성 검사를 적용하여 보다 안전한 입력 필드를 구성하는 과정도 포함합니다.

 

2. 필수 패키지 설치

로그인 폼을 만들기 위해 다음과 같은 패키지를 설치해야 합니다.

npm i react-hook-form zod
npx shadcn@latest add form
npx shadcn@latest add input

위 명령어를 실행하면 ShadCN UI의 폼 관련 컴포넌트가 자동으로 설치됩니다.

 

3. 로그인 폼 컴포넌트 작성

3.1. LoginPage.tsx

다음은 ShadCN UI 및 react-hook-form을 사용하여 로그인 페이지를 구성한 코드입니다.

"use client";

import { Button } from "@/components/ui/button";
import {
  Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle
} from "@/components/ui/card";
import {
  Form, FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import Link from "next/link";

// 1) 폼 유효성 검사 스키마 정의
const formSchema = z.object({
  email: z.string().email("유효한 이메일을 입력하세요."),
  password: z.string().min(4, "비밀번호는 최소 4자 이상이어야 합니다."),
});

const LoginPage: React.FC = () => {
  // 2) useForm 설정
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema), // zodResolver를 사용하여 유효성 검사를 적용
    defaultValues: { email: "", password: "" }, // 기본값을 빈 문자열로 설정
  });

  // 3) 폼 제출 핸들러
  const handleSubmit = (data: z.infer<typeof formSchema>) => {
    console.log("로그인 확인이 통과되었습니다.", data);
  };

  return (
    <Card className="w-full max-w-sm mx-auto">
      <CardHeader>
        <CardTitle>로그인</CardTitle>
        <CardDescription>SupportMe 계정에 로그인하세요.</CardDescription>
      </CardHeader>
      <CardContent>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-4">
            {/* 이메일 입력 필드 */}
            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>이메일</FormLabel>
                  <FormControl>
                    <Input placeholder="example@email.com" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            
            {/* 비밀번호 입력 필드 */}
            <FormField
              control={form.control}
              name="password"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>비밀번호</FormLabel>
                  <FormControl>
                    <Input type="password" placeholder="비밀번호" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            
            {/* 로그인 버튼 */}
            <Button type="submit" className="w-full">
              로그인
            </Button>
          </form>
        </Form>
      </CardContent>
      <CardFooter className="flex justify-between">
        <small> 계정이 없으신가요?</small>
        <Button asChild variant="outline" size="sm">
          <Link href="/sign-up">회원가입</Link>
        </Button>
      </CardFooter>
    </Card>
  );
};

export default LoginPage;

 

4. 코드 설명

4.1. zod를 이용한 폼 유효성 검사

  • email: 유효한 이메일 형식인지 검사합니다.
  • password: 최소 4자 이상의 비밀번호를 요구합니다.
  •  

4.2. useForm을 활용한 폼 핸들링

  • useForm<z.infer<typeof formSchema>>({...}): Zod 스키마를 기반으로 타입을 추론하여 폼 상태를 관리합니다.
  • resolver: zodResolver(formSchema): Zod를 활용하여 입력값이 유효한지 검사합니다.
  • defaultValues: 폼의 초기값을 설정합니다.
  •  

4.3. react-hook-form을 이용한 입력 필드

  • FormField를 사용하여 name 값을 지정하고, render 속성으로 입력 필드를 렌더링합니다.
  • FormControl 내부에 Input 컴포넌트를 사용하여 사용자가 입력할 수 있도록 설정합니다.
  • FormMessage를 활용하여 유효성 검사 오류 메시지를 표시합니다.

 

 

 

 

 

 

 

3.회원가입 페이지 만들기

 

 

 

8. 선택박스  만들기 및 선택박스 유효성 검사

https://ui.shadcn.com/docs/components/select

 

1. 프로젝트 설정

1.1 shadcn/ui 설치

shadcn/ui는 Tailwind CSS 기반의 UI 컴포넌트 라이브러리입니다. 다음 명령어로 Select 컴포넌트를 설치합니다.

 

npx shadcn@latest add select

 

1.2 폼 유효성 검사 스키마 정의

zod 라이브러리를 사용하여 폼의 유효성을 검사합니다. 아래는 회원가입 폼의 스키마입니다.

 

const formSchema = z.object({
  email: z.string().email("유효한 이메일을 입력하세요."),
  accountType: z.enum(["personal", "company"]), // 개인 또는 회사 선택
  companyName: z.string().optional(), // 회사명 (선택적)
  numberOfEmployees: z.coerce.number().optional(), // 직원 수 (선택적)
});

 

2. 회원가입 폼 구현

2.1 폼 구조

react-hook-form과 zod를 사용하여 폼을 구성합니다. 주요 컴포넌트는 다음과 같습니다.

  • Card: 폼을 감싸는 컨테이너

  • Form: 폼 필드와 유효성 검사를 관리

  • Select: 계정 유형 선택

  • Input: 이메일, 회사명, 직원 수 입력

 

import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";

const SignupPage: React.FC = () => {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      email: "",
      accountType: "personal",
    },
  });

  const handleSubmit = (data: z.infer<typeof formSchema>) => {
    console.log("회원가입 확인이 통과되었습니다.", data);
  };

  return (
    <div className="flex justify-center items-center min-h-screen">
      <Card className="w-full max-w-sm">
        <CardHeader>
          <CardTitle className="text-3xl">회원가입</CardTitle>
          <CardDescription>SupportMe 계정에 회원가입하세요.</CardDescription>
        </CardHeader>
        <CardContent>
          <Form {...form}>
            <form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-4">
              {/* 이메일 필드 */}
              <FormField
                control={form.control}
                name="email"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>이메일</FormLabel>
                    <FormControl>
                      <Input placeholder="example@email.com" {...field} />
                    </FormControl>
                    <FormDescription>SupportMe 계정의 이메일을 입력해주세요.</FormDescription>
                    <FormMessage />
                  </FormItem>
                )}
              />

              {/* 계정 유형 선택 필드 */}
              <FormField
                control={form.control}
                name="accountType"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>회원구분</FormLabel>
                    <Select value={field.value} onValueChange={field.onChange}>
                      <FormControl>
                        <SelectTrigger>
                          <SelectValue placeholder="회원구분을 선택해 주세요." />
                        </SelectTrigger>
                      </FormControl>
                      <SelectContent>
                        <SelectItem value="personal">개인</SelectItem>
                        <SelectItem value="company">기업</SelectItem>
                      </SelectContent>
                    </Select>
                    <FormMessage />
                  </FormItem>
                )}
              />

              {/* 회원가입 버튼 */}
              <Button type="submit" className="w-full">
                회원가입
              </Button>
            </form>
          </Form>
        </CardContent>
        <CardFooter className="flex justify-between">
          <small>이미 계정이 있으신가요?</small>
          <Button asChild variant="outline" size="sm">
            <Link href="/login">로그인</Link>
          </Button>
        </CardFooter>
      </Card>
    </div>
  );
};

export default SignupPage;

 

 

3. 조건부 렌더링 구현

accountType이 company일 때만 회사명과 직원 수 필드를 표시합니다.

{form.watch("accountType") === "company" && (
  <>
    {/* 회사명 필드 */}
    <FormField
      control={form.control}
      name="companyName"
      render={({ field }) => (
        <FormItem>
          <FormLabel>회사명</FormLabel>
          <FormControl>
            <Input placeholder="회사명을 입력하세요." {...field} />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />

    {/* 직원 수 필드 */}
    <FormField
      control={form.control}
      name="numberOfEmployees"
      render={({ field }) => (
        <FormItem>
          <FormLabel>직원 수</FormLabel>
          <FormControl>
            <Input type="number" placeholder="직원 수를 입력하세요." {...field} />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
  </>
)}

 

4. 추가 설명

4.1 z.coerce.number()

z.coerce.number()는 문자열 입력을 숫자로 변환합니다. 예를 들어, 직원 수 필드에서 사용자가 입력한 문자열을 숫자로 자동 변환합니다.

4.2 Select 컴포넌트

Select 컴포넌트는 사용자가 드롭다운 메뉴에서 값을 선택할 수 있도록 합니다. 주요 속성은 다음과 같습니다.

  • SelectTrigger: 드롭다운 메뉴를 열기 위한 버튼

  • SelectContent: 드롭다운 메뉴의 내용

  • SelectItem: 선택 가능한 항목

 

5. 실행 결과

  1. 개인 계정 선택 시:

    • 이메일 필드만 표시됩니다.

  2. 회사 계정 선택 시:

    • 이메일, 회사명, 직원 수 필드가 모두 표시됩니다.

 

 

유효성 검사

6.폼 스키마 정의

Zod를 사용하여 폼의 유효성 검사 스키마를 정의합니다. 이메일, 계정 유형, 회사명, 직원 수 등의 필드를 검증합니다.

 

const formSchema = z.object({
  email: z.string().email("유효한 이메일을 입력하세요."),
  accountType: z.enum(["personal", "company"]),
  companyName: z.string().optional(),
  numberOfEmployees: z.coerce.number().optional(),
}).superRefine((data, ctx) => {
  if (data.accountType === "company" && !data.companyName) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ["companyName"],
      message: "기업명을 입력해주세요.",
    });
  }

  if (data.accountType === "company" && (!data.numberOfEmployees || data.numberOfEmployees < 1)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ["numberOfEmployees"],
      message: "직원수를 입력해주세요.",
    });
  }
});

 

  • superRefine: 사용자 정의 유효성 검사를 추가할 수 있는 Zod의 기능. 계정 유형이 "company"일 때, 회사명과 직원 수가 필수임을 검증.

 

7.폼 컴포넌트 구현

React Hook Form을 사용하여 폼을 관리하고, shadcn/ui의 컴포넌트를 활용해 UI를 구성합니다.

 

        {accountType === "company" && (
                <>
                  <FormField
                    control={form.control}
                    name="companyName"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>기업명</FormLabel>
                        <FormControl>
                          <Input placeholder="기업명을 입력해주세요. " {...field} />
                        </FormControl>                        
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="numberOfEmployees"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>직원수</FormLabel>
                        <FormControl>
                          <Input type="number" placeholder="직원수를 입력해주세요." {...field}  min={1}   />
                        </FormControl>                        
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                
                </> 
              )}
              

 

주요 기능 설명

7.1 조건부 렌더링

  • form.watch("accountType")를 사용하여 계정 유형을 실시간으로 감시.

  • 계정 유형이 "company"일 때만 회사명과 직원 수 필드를 렌더링.

7.2 유효성 검사

  • Zod의 superRefine을 사용하여 사용자 정의 유효성 검사 로직 추가.

  • 계정 유형이 "company"일 때, 회사명과 직원 수가 필수임을 검증.

 

회원구분 선택 박스 유효성 체크 까지 전체 코드 결과

"use client";

import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { PersonStandingIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Form } from "@/components/ui/form";
import {
  Select,
  SelectItem,
  SelectTrigger,
  SelectContent,
  SelectValue,
} from "@/components/ui/select";

// 폼 유효성 검사 스키마 정의
const formSchema = z.object({
  email: z.string().email("유효한 이메일을 입력하세요."),
  accountType: z.enum(["personal", "company"]),
  companyName: z.string().optional(),
  numberOfEmployees: z.coerce.number().optional(),
}).superRefine((data, ctx) => {
 
  if(data.accountType === "company" && !data.companyName) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path:["companyName"],
      message: "기업명을 입력해주세요.",
    });
  }

  if(data.accountType === "company" && (!data.numberOfEmployees  || data.numberOfEmployees < 1 )) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path:["numberOfEmployees"],
      message: "직원수를 입력해주세요.",
    });
  }

});

const SignupPage: React.FC = () => {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      email: "",
      accountType: "personal",
    },
  });

  // 회원가입 핸들러
  const handleSubmit = (data: z.infer<typeof formSchema>) => {
    console.log("회원가입 확인이 통과되었습니다.", data);
  };


  const accountType = form.watch("accountType");




  return (
    <>
      <div className="flex justify-center">
        <PersonStandingIcon size={50} />
      </div>
      <Card className="w-full max-w-sm mx-auto">
        <CardHeader>
          <CardTitle className="text-3xl">회원가입</CardTitle>
          <CardDescription>Macaronics.net 계정에 회원가입하세요.</CardDescription>
        </CardHeader>
        <CardContent>
          <Form {...form}>
            <form
              onSubmit={form.handleSubmit(handleSubmit)}
              className="flex flex-col gap-4"
            >
              {/* 이메일 필드 */}
              <FormField
                control={form.control}
                name="email"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>이메일</FormLabel>
                    <FormControl>
                      <Input placeholder="example@email.com" {...field} />
                    </FormControl>
                    <FormDescription>
                      Macaronics.net 계정의 이메일을 입력해주세요.
                    </FormDescription>
                    <FormMessage />
                  </FormItem>
                )}
              />

              <FormField
                control={form.control}
                name="accountType"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>회원구분</FormLabel>
                    <Select value={field.value} onValueChange={field.onChange}>
                      <FormControl>
                        <SelectTrigger>
                          <SelectValue placeholder="회원구분을 선택해 주세요." />
                        </SelectTrigger>
                      </FormControl>
                      <SelectContent>
                        <SelectItem value="personal">개인</SelectItem>
                        <SelectItem value="company">기업</SelectItem>
                      </SelectContent>
                    </Select>
                  </FormItem>
                )}
              />

              {accountType === "company" && (
                <>
                  <FormField
                    control={form.control}
                    name="companyName"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>기업명</FormLabel>
                        <FormControl>
                          <Input placeholder="기업명을 입력해주세요. " {...field} />
                        </FormControl>                        
                        <FormMessage />
                      </FormItem>
                    )}
                  />

                  <FormField
                    control={form.control}
                    name="numberOfEmployees"
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>직원수</FormLabel>
                        <FormControl>
                          <Input type="number" placeholder="직원수를 입력해주세요." {...field}  min={1}   />
                        </FormControl>                        
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                
                </> 
              )}
              
       



              {/* 회원가입 버튼 */}
              <Button type="submit" className="w-full">
                회원가입
              </Button>
            </form>
          </Form>
        </CardContent>
        <CardFooter className="flex justify-between">
          <small>이미 계정이 있으신가요?</small>
          <Button asChild variant="outline" size="sm">
            <Link href="/login">로그인</Link>
          </Button>
        </CardFooter>
      </Card>
    </>
  );
};

export default SignupPage;

 

 

 

 

9. 생년월일 체크 폼 유효성 검사

 

1.1. refine을 이용한 유효성 검사 로직

생년월일 필드에 대한 유효성 검사는 사용자가 18세 이상인지 확인하는 과정이 포함된다. refine을 사용하면 보다 직관적인 유효성 검사를 적용할 수 있다.

const formSchema = z.object({
  dob: z.date().refine((date) => {
    const today = new Date();
    const eighteenYearsAgo = new Date(
      today.getFullYear() - 18,
      today.getMonth(),
      today.getDate()
    );
    return date <= eighteenYearsAgo;
  }, { message: "18세 이상이어야 합니다." }),
});

1.2. refine의 true/false 반환 방식 이해

refine에서 true를 반환하면 유효성 검사가 통과됨을 의미하고, false를 반환하면 유효성 검사에 실패하여 오류 메시지를 출력하도록 한다.

즉, return date <= eighteenYearsAgo; 부분에서 조건이 충족되지 않으면 오류가 발생하며, message에 설정한 "18세 이상이어야 합니다."가 표시된다.

 

 

// 폼 유효성 검사 스키마 정의
const formSchema = z.object({
  email: z.string().email("유효한 이메일을 입력하세요."),
  accountType: z.enum(["personal", "company"]),
  companyName: z.string().optional(),
  numberOfEmployees: z.coerce.number().optional(),
  dob:z.date().refine((date)=>{
    const today = new Date();
    const eighteedYearsAgo =new Date(
      today.getFullYear() - 18,
      today.getMonth(),
      today.getDate()
    );
    return date <= eighteedYearsAgo;    
  })
  
}).superRefine((data, ctx) => {
 
  if(data.accountType === "company" && !data.companyName) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path:["companyName"],
      message: "기업명을 입력해주세요.",
    });
  }

  if(data.accountType === "company" && (!data.numberOfEmployees  || data.numberOfEmployees < 1 )) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path:["numberOfEmployees"],
      message: "직원수를 입력해주세요.",
    });
  }

});

 

 

2. 생년월일 입력 폼 구현

2.1생년월일을 입력할 때 팝업 형태의 캘린더를 활용하기 위해 calendar 및 popover 컴포넌트를 설치해야 합니다. 아래 명령어를 실행하여 해당 컴포넌트를 추가합니다.

npx shadcn@latest add calendar
npx shadcn@latest add popover

 

 

 

2.2 기본 폼 필드 추가

dob(Date of Birth) 필드를 추가하여 사용자가 생년월일을 선택할 수 있도록 구현합니다. react-hook-form을 이용하여 제어 가능한 폼 필드를 생성합니다.

<FormField
  control={form.control}
  name="dob"
  render={({ field }) => (
    <FormItem className="flex flex-col pt-2">
      <FormLabel className="h-5">생년월일</FormLabel>
      <FormControl>
        <Popover>
          <PopoverTrigger asChild>
            <FormControl>
              <Button variant={"outline"} className="normal-case flex justify-between">
                {field.value ? (format(field.value, "PPP")) : (
                  <span>날짜 선택</span>
                )}
                <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
              </Button>
            </FormControl>
          </PopoverTrigger>
          <PopoverContent className="w-auto p-0" align="start">
            <Calendar
              mode="single"
              selected={field.value}
              onSelect={field.onChange}
              disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
              initialFocus
            />
          </PopoverContent>
        </Popover>
      </FormControl>
      <FormMessage />
    </FormItem>
  )}
/>

 

2.2 주요 코드 설명

  • Popover를 사용하여 날짜 선택 버튼을 클릭하면 캘린더가 팝업되도록 구현합니다.
  • Button 내부에서 field.value가 존재하면 선택한 날짜를 표시하고, 없으면 날짜 선택 문구를 표시합니다.
  • CalendarIcon을 사용하여 UI의 가독성을 높입니다.
  • Calendar 컴포넌트를 활용하여 사용자가 날짜를 선택할 수 있도록 설정합니다.

 

3. 스타일링 및 UX 개선

3.1 버튼 스타일 조정

버튼이 기본적으로 화면 전체 너비를 차지하지 않도록 설정하며, capitalize 스타일을 제거하여 자연스러운 텍스트 표시를 유지합니다.

<Button variant={"outline"} className="normal-case flex justify-between">

 

3.2 아이콘 정렬 조정

아이콘을 오른쪽으로 배치하고, 버튼 내부 요소들의 정렬을 개선합니다.

<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />

 

3.3 폼 필드 간 간격 조정

pt-2 및 mt-2 클래스를 추가하여 각 입력 필드 사이의 여백을 균일하게 유지합니다.

<FormItem className="flex flex-col pt-2">

 

4. 추가적인 기능 개선

  • disabled 속성을 활용하여 1900년 이전 및 현재 날짜 이후의 선택을 방지합니다.
  • PopoverContent를 align="start"로 설정하여 자연스러운 위치에서 캘린더가 나타나도록 조정합니다.
  • initialFocus를 설정하여 캘린더가 팝업될 때 자동으로 초점이 이동하도록 합니다.

 

 

 

 

10. shadcn calendar  custom (shadcn 달력 맞춤형으로 변경히기)

 

1) 변경전

 

2) 변경후

 

 

shadcn Calendar 커스텀 방법 설명

shadcn의 기본 Calendar 컴포넌트를 커스텀하는 방법을 설명합니다. 위 코드에서 적용한 주요 변경 사항 및 추가 기능을 중심으로 정리합니다.

1. Calendar 라이브러리 설치

shadcn은 react-day-picker를 기반으로 Calendar 컴포넌트를 제공합니다. 먼저 필요한 라이브러리를 설치해야 합니다.

npm install date-fns react-day-picker

또한 Tailwind CSS를 사용하여 스타일을 적용하므로, Tailwind 설정이 되어 있어야 합니다.

 

2. Calendar 커스텀 주요 변경 사항

1) Dropdown을 활용한 월/년도 선택 기능 추가

  • react-day-picker의 Dropdown을 오버라이드하여 Select 컴포넌트를 사용하도록 변경.
  • 연도를 내림차순으로 정렬하여 최신 연도가 먼저 보이도록 설정.
  • date-fns의 format 함수를 활용해 월/년도를 한국어 로케일로 표시.
components={{
  Dropdown: (dropdownProps) => {
    const { currentMonth, goToMonth } = useNavigation();
    const { fromYear, toYear } = useDayPicker();
    let selectValues = [];

    if (dropdownProps.name === "months") {
      selectValues = Array.from({ length: 12 }, (_, i) => ({
        value: i.toString(),
        label: format(new Date(new Date().getFullYear(), i), "MMMM", { locale: ko }),
      }));
    } else if (dropdownProps.name === "years") {
      if (fromYear && toYear) {
        selectValues = Array.from({ length: toYear - fromYear + 1 }, (_, i) => ({
          value: (toYear - i).toString(),
          label: (toYear - i).toString(),
        }));
      }
    }

    return (
      <Select onValueChange={(newValue) => {
        const newDate = new Date(currentMonth);
        if (dropdownProps.name === "months") newDate.setMonth(parseInt(newValue));
        else if (dropdownProps.name === "years") newDate.setFullYear(parseInt(newValue));
        goToMonth(newDate);
      }} value={dropdownProps.value?.toString()}>
        <SelectTrigger>{format(currentMonth, dropdownProps.name === "months" ? "MMMM" : "yyyy", { locale: ko })}</SelectTrigger>
        <SelectContent>
          {selectValues.map((value) => (
            <SelectItem key={value.value} value={value.value}>{value.label}</SelectItem>
          ))}
        </SelectContent>
      </Select>
    );
  }
}}

 

2) 주말 색상 변경

  • modifiers와 modifiersClassNames를 사용하여 토요일과 일요일에 각각 파란색과 빨간색 적용.
modifiers={{
  saturday: (date) => date.getDay() === 6,
  sunday: (date) => date.getDay() === 0,
}}
modifiersClassNames={{
  saturday: "text-blue-500",
  sunday: "text-red-500",
}}

3) UI 스타일 변경

  • Tailwind 클래스를 활용하여 스타일을 커스텀.
  • 버튼 스타일과 선택된 날짜 스타일을 변경.
  • shadcn의 buttonVariants를 활용하여 통일된 스타일을 적용.
classNames={{
  nav_button: cn(buttonVariants({ variant: "outline" }), "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"),
  day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
  day_today: "bg-accent text-accent-foreground",
  caption_dropdowns: "flex gap-1 flex-row-reverse", // 년도 드롭다운이 먼저 나오도록 설정
}}

 

3. 기타 옵션 설명

옵션설명

 

 

custom_calendar.tsx 전체 코드

"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker, useDayPicker, useNavigation } from "react-day-picker"

import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { format } from "date-fns"
import { Select, SelectContent, SelectItem, SelectTrigger } from "./select"
import { ko } from "date-fns/locale";  // 한국어 로케일


export type CalendarProps = React.ComponentProps<typeof DayPicker>

function Calendar({
  className,
  classNames,
  showOutsideDays = true,
  ...props
}: CalendarProps) {
  const [open, setOpen] = React.useState(true) // 캘린더 열림/닫힘 상태

  return open ?(
    <DayPicker
      showOutsideDays={showOutsideDays}
      className={cn("p-3", className)}
      onDayClick={() => setOpen(false)}
     
      modifiers={{
        saturday: (date) => date.getDay() === 6, // 토요일
        sunday: (date) => date.getDay() === 0, // 일요일
      }}
      modifiersClassNames={{
        saturday: "text-blue-500", // 토요일 파란색
        sunday: "text-red-500", // 일요일 빨간색
      }}

      classNames={{
        months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
        month: "space-y-4",
        caption: "flex justify-center pt-1 relative items-center",
        caption_label: "text-sm font-medium hidden",
        nav: "space-x-1 flex items-center",
        nav_button: cn(
          buttonVariants({ variant: "outline" }),
          "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
        ),
        nav_button_previous: "absolute left-1",
        nav_button_next: "absolute right-1",
        table: "w-full border-collapse space-y-1",
        head_row: "flex",
        head_cell:
          "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
        row: "flex w-full mt-2",
        cell: cn(
          "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
          props.mode === "range"
            ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
            : "[&:has([aria-selected])]:rounded-md"
        ),
        day: cn(
          buttonVariants({ variant: "ghost" }),
          "h-8 w-8 p-0 font-normal aria-selected:opacity-100"
        ),
        day_range_start: "day-range-start",
        day_range_end: "day-range-end",
        day_selected:
          "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
        day_today: "bg-accent text-accent-foreground",
        day_outside:
          "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
        day_disabled: "text-muted-foreground opacity-50",
        day_range_middle:
          "aria-selected:bg-accent aria-selected:text-accent-foreground",
        day_hidden: "invisible",
        caption_dropdowns: "flex gap-1 flex-row-reverse", // 년도 드롭다운이 먼저 나오도록 flex-r
  
        ...classNames,
      }}
      components={{
      
        IconLeft: ({ className, ...props }) => (
          <ChevronLeft className={cn("h-4 w-4", className)} {...props} />
        ),
        IconRight: ({ className, ...props }) => (
          <ChevronRight className={cn("h-4 w-4", className)} {...props} />
        ),
        Dropdown:(dropdownProps)=>{
         // 1.log(dropdownProps);
          const {currentMonth, goToMonth} =useNavigation();
          const {fromYear, fromMonth, fromDate, toYear, toMonth, toDate}  =useDayPicker();

          let selectValues : {value:string; label:string}[] = [];

          if(dropdownProps.name==="months"){
            selectValues = Array.from({length: 12}, (_, i) => {
              return {
                value:i.toString(),
                label: format(new Date(new Date().getFullYear(), i), "MMMM", { locale: ko })
              }               
            });
          }else if(dropdownProps.name==="years"){
            
            const earliestYear=fromYear || fromMonth?.getFullYear() || fromDate?.getFullYear()
            const latestYear=toYear || toMonth?.getFullYear() || toDate?.getFullYear()
            if(earliestYear && latestYear){
                               
                selectValues = Array.from(
                  { length: latestYear - earliestYear + 1 },
                  (_, i) => {
                    return {
                      value: (latestYear - i).toString(), //년도 내림차순으로 변경
                      label: (latestYear - i).toString(), //년도 내림차순으로 변경
                    };
                  }
                );

            }
          }

          // 캡션을 한국어로 표시하도록 로케일 적용
          const caption = format(currentMonth, dropdownProps.name === "months" ? "MMMM" : "yyyy", { locale: ko }); // 월 또는 연도 캡션 한국어로 변경
          
          return(
            <>
            <Select
              onValueChange={(newValue)=>{
                if(dropdownProps.name==="months"){
                  const newDate = new Date(currentMonth);
                  newDate.setMonth(parseInt(newValue));
                  goToMonth(newDate);
                }else if(dropdownProps.name==="years"){
                  const newDate = new Date(currentMonth);
                  newDate.setFullYear(parseInt(newValue));
                  goToMonth(newDate);
                }
              }}
              value={dropdownProps.value?.toString()}>

              <SelectTrigger>{caption}</SelectTrigger>
              <SelectContent>
                 {selectValues.map((value) => (
                   <SelectItem key={value.value} value={value.value}>{value.label}</SelectItem>
                 ))}
              </SelectContent>
            </Select>
            
            </>
          ) ;
        }
      }}
      {...props}
    />
  ) :null;

}
Calendar.displayName = "Calendar"

export { Calendar }

 

 

 

 

 

 

11. Shadcn 비밀번호 유효성 체크 

 

1. 비밀번호 검증 주요 기능

  1. 비밀번호 길이 제한: 최소 8자 이상 입력해야 함
  2. 복잡성 검증: 특수문자 1개 이상, 대문자 1개 이상 포함 필수
  3. 비밀번호 확인: 입력한 비밀번호와 동일한지 검증
  4. 실시간 검증 피드백: 입력과 동시에 오류 메시지 제공

2. 비밀번호 유효성 검사 코드

2.1. 비밀번호 스키마 정의

const passwordSchema = z.object({
  password: z.string().min(8, "8자 이상 입력하세요.")
    .refine((password) => /^(?=.*[!@#$%^&*])(?=.*[A-Z]).*$/.test(password), "비밀번호는 특수문자 1개 이상, 대문자 1개 이상을 포함해야 합니다."),
  passwordConfirm: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.passwordConfirm) {
    ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["passwordConfirm"], message: "비밀번호와 비밀번호 확인은 일치해야 합니다." });
  }
});

 

5. UI 구현

5.1. 비밀번호 입력 UI 구성

  • shadcn/ui의 Input 컴포넌트 활용
  • 비밀번호 입력 시 실시간 검증 메시지 제공
<FormField control={form.control} name="password" render={({ field }) => (
  <FormItem>
    <FormLabel>비밀번호</FormLabel>
    <FormControl>
      <Input type="password" placeholder="비밀번호를 입력하세요" {...field} />
    </FormControl>
    <FormMessage />
  </FormItem>
)} />

<FormField control={form.control} name="passwordConfirm" render={({ field }) => (
  <FormItem>
    <FormLabel>비밀번호 확인</FormLabel>
    <FormControl>
      <Input type="password" placeholder="비밀번호를 다시 입력하세요" {...field} />
    </FormControl>
    <FormMessage />
  </FormItem>
)} />

 

 

스키마 분리

1)개별 스키마

//1.폼 유효성 검사 기본 스키마 정의
const baseSchema = z.object({
  email: z.string().email("유효한 이메일을 입력하세요."),

  dob:z.date({ required_error: "생년월일을 선택해주세요." }).refine((date)=>{
    const today = new Date();
    const eighteedYearsAgo =new Date(
      today.getFullYear() - 18,
      today.getMonth(),
      today.getDate()
    );
    return date <= eighteedYearsAgo;    
  }, "18세 이상의 사람만 회원가입 가능합니다."),  
  
});

//2.회원구분 스키마 정의
const accountTypeSchema = z.object({
  accountType: z.enum(["personal", "company"]),
  companyName: z.string().optional(),
  numberOfEmployees: z.coerce.number().optional(),
}).superRefine((data, ctx) => { 

  if(data.accountType === "company" && !data.companyName) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path:["companyName"],
      message: "기업명을 입력해주세요.",
    });
  }

  if(data.accountType === "company" && (!data.numberOfEmployees  || data.numberOfEmployees < 1 )) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path:["numberOfEmployees"],
      message: "직원수를 입력해주세요.",
    });
  }
})

//3.비밀번호 스키마 정의
const passwordSchema=z.object({
  password: z.string({ required_error: "비밀번호를 입력해 주세요." }).min(8, "8자 이상 입력하세요.")
  .refine((password) => {
     return /^(?=.*[!@#$%^&*])(?=.*[A-Z]).*$/.test(password);
  },"비밀번호는 특수문자 1개 이상, 대문자 1개 이상을 포함해야 합니다."),

  passwordConfirm: z.string({ required_error: "비밀번호 확인을 입력해 주세요." }),
}).superRefine((data, ctx) => {
 
  if(data.password !== data.passwordConfirm) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path:["passwordConfirm"],
      message: "비밀번호와 비밀번호 확인은 일치해야 하겠습니다.",
    });
  }
});


const formSchema = baseSchema.and(accountTypeSchema).and(passwordSchema);

 

 

2)통합 스키마

//1.유효성 검사 기본 스키마 정의
const formSchema = z.object({
  email: z.string().email("유효한 이메일을 입력하세요."),
  accountType: z.enum(["personal", "company"]),
  companyName: z.string().optional(),
  numberOfEmployees: z.coerce.number().optional(),
  dob:z.date({ required_error: "생년월일을 선택해주세요." }).refine((date)=>{
    const today = new Date();
    const eighteedYearsAgo =new Date(
      today.getFullYear() - 18,
      today.getMonth(),
      today.getDate()
    );
    return date <= eighteedYearsAgo;    
  }, "18세 이상의 사람만 회원가입 가능합니다."),  
  password: z.string({ required_error: "비밀번호를 입력해 주세요." }).min(8, "8자 이상 입력하세요.")
  .refine((password) => {
     return /^(?=.*[!@#$%^&*])(?=.*[A-Z]).*$/.test(password);
  },"비밀번호는 특수문자 1개 이상, 대문자 1개 이상을 포함해야 합니다."),

  passwordConfirm: z.string({ required_error: "비밀번호 확인을 입력해 주세요." }),
}).superRefine((data, ctx) => {
 
  if(data.accountType === "company" && !data.companyName) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path:["companyName"],
      message: "기업명을 입력해주세요.",
    });
  }

  if(data.accountType === "company" && (!data.numberOfEmployees  || data.numberOfEmployees < 1 )) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path:["numberOfEmployees"],
      message: "직원수를 입력해주세요.",
    });
  }

  if(data.password !== data.passwordConfirm) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path:["passwordConfirm"],
      message: "비밀번호와 비밀번호 확인은 일치해야 하겠습니다.",
    });
  }
});

 

 

 

 

 

 

12. shadcn을 활용한 맞춤형 비밀번호 입력 컴포넌트 구현

1. 개요

Next.js 15의 App Router 방식을 사용하여, shadcn UI 라이브러리를 활용한 맞춤형 비밀번호 입력 필드를 구현하는 방법을 다룹니다. 이 컴포넌트는 사용자가 비밀번호를 입력할 때 가독성을 높이기 위해 비밀번호 표시 여부를 전환할 수 있는 기능을 포함하고 있습니다.

 

2. 프로젝트 설정

shadcn을 활용하여 입력 필드를 구성하며, 비밀번호 표시/숨김을 위한 토글 기능을 추가합니다. 이를 위해 useState 훅을 활용하여 상태를 관리하고, lucide-react의 아이콘을 사용하여 UI를 개선합니다.

 

3. 비밀번호 입력 컴포넌트 코드

"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { Input } from "./input";
import { EyeIcon, EyeOffIcon } from "lucide-react";

export interface PasswordInputProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
    type?: "password" | "text";
}

const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
  ({ className, type, ...props }, ref) => {
    const [showPassword, setShowPassword] = React.useState(false);

    return (
      <div className="relative">
        <Input
          type={showPassword ? type : "password"}
          {...props}
          ref={ref}
          className={cn("pr-10", className)}
        />
        <span className="absolute top-[7px] right-1 cursor-pointer select-none">
          {showPassword ? (
            <EyeIcon onClick={() => setShowPassword(false)} />
          ) : (
            <EyeOffIcon onClick={() => setShowPassword(true)} />
          )}
        </span>
      </div>
    );
  }
);
PasswordInput.displayName = "PasswordInput";

export { PasswordInput };

 

4. 코드 설명

  • 클라이언트 컴포넌트 선언: "use client";를 추가하여 클라이언트에서 실행되도록 설정합니다.
  • useState 활용: showPassword 상태를 관리하여 비밀번호 표시 여부를 결정합니다.
  • lucide-react 아이콘 사용: EyeIcon과 EyeOffIcon을 활용하여 비밀번호 표시 여부를 토글할 수 있습니다.
  • 클래스 스타일링: Tailwind CSS를 사용하여 아이콘을 적절한 위치에 배치하고, 입력 필드의 오른쪽 패딩을 조정합니다.
  • forwardRef 활용: React.forwardRef를 사용하여 부모 컴포넌트에서 참조할 수 있도록 설정합니다.

 

5. UI 개선 및 추가 기능

  • 비밀번호 입력 필드에 적절한 패딩 (pr-10) 을 추가하여 아이콘과 텍스트가 겹치지 않도록 합니다.
  • select-none 클래스를 적용하여 아이콘 클릭 시 텍스트가 선택되지 않도록 방지합니다.
  • 비밀번호 확인 필드도 같은 방식으로 적용할 수 있으며, 회원가입 및 로그인 페이지에서 동일한 컴포넌트를 활용할 수 있습니다.

 

6. 적용 방법

이제 위에서 만든 PasswordInput 컴포넌트를 사용하여 로그인 및 회원가입 페이지에서 적용할 수 있습니다.

import { PasswordInput } from "@/components/ui/password-input";

<PasswordInput placeholder="비밀번호를 입력하세요" />
<PasswordInput placeholder="비밀번호 확인" />

 

 

 

 

 

13. 이용약관 확인란 추가 및 유효성 검사 구현

 

1. 체크박스 컴포넌트 설치

먼저, ShadCN UI의 체크박스 컴포넌트를 설치해야 합니다. 다음 명령어를 실행하여 체크박스 컴포넌트를 추가합니다.

npx shadcn-ui@latest add checkbox

설치가 완료되면 components/ui/checkbox.tsx 파일이 생성됩니다. 이제 이 컴포넌트를 가져와서 사용할 수 있습니다.

 

2. 양식 스키마 수정

Zod를 이용해 양식 스키마에 새로운 필드를 추가합니다. terms라는 필드를 추가하고, 불리언 타입으로 설정하며, 필수 입력 값으로 지정합니다.

import { z } from "zod";

const formSchema = z.object({
  name: z.string().min(1, "이름을 입력해주세요."),
  email: z.string().email("유효한 이메일 주소를 입력해주세요."),
  password: z.string().min(6, "비밀번호는 최소 6자리 이상이어야 합니다."),
  confirmPassword: z.string(),
  terms: z.boolean().refine((val) => val === true, {
    message: "이용약관에 동의해야 합니다.",
  }),
}).refine((data) => data.password === data.confirmPassword, {
  message: "비밀번호가 일치하지 않습니다.",
  path: ["confirmPassword"],
});

 

3. 체크박스 추가

가입 양식에서 체크박스를 추가하고, 설명 문구를 표시합니다.

import { Checkbox } from "@/components/ui/checkbox";
import { FormControl, FormDescription, FormLabel } from "@/components/ui/form";
import Link from "next/link";

<div className="flex items-center gap-2">
  <FormControl>
    <Checkbox id="terms" {...register("terms")} />
  </FormControl>
  <FormLabel htmlFor="terms">
    <span>이용약관에 동의합니다.</span>
  </FormLabel>
</div>
<FormDescription>
  가입하면 <Link href="/terms" className="text-primary hover:underline">이용약관</Link>에 동의하는 것으로 간주됩니다.
</FormDescription>

 

4. 유효성 검사 및 가입 처리

가입 버튼을 누를 때, terms 값이 true인지 확인하고, 그렇지 않으면 에러 메시지를 표시하도록 설정합니다.

const onSubmit = (data) => {
  if (!data.terms) {
    alert("이용약관에 동의해야 합니다.");
    return;
  }
  console.log("가입 성공:", data);
  router.push("/dashboard");
};

 

5. 스타일 조정

체크박스와 설명을 더 깔끔하게 정렬하기 위해 Tailwind CSS 클래스를 조정합니다.

<div className="flex items-center gap-2">
  <Checkbox id="terms" {...register("terms")} />
  <FormLabel htmlFor="terms" className="text-sm">
    이용약관에 동의합니다.
  </FormLabel>
</div>

 

 

 

 

 

14. 넥스트 테마 적용하게 -  Shadcn  테마 적용하기

 

 * 설치 및 사용법

https://github.com/pacocoursey/next-themes

테마 변경 적용 라이브러리

npx shadcn@latest add dropdown-menu

npm install next-themes

npm install --save-dev @types/next-themes

 

1. tailwind.config.js에 darkMode: "class"를 추가

// tailwind.config.js
module.exports = {
  darkMode: "class", // ✅ 추가
  content: [
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
    "./app/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

 

2. components/theme-provider.tsx 생성

"use client"
import { useEffect } from "react";
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes";

/**
 * 설치 및 사용법
https://github.com/pacocoursey/next-themes
테마 변경 적용 라이브러리
npx shadcn@latest add dropdown-menu
npm install next-themes
npm install --save-dev @types/next-themes

*/

type ThemeProviderProps = React.ComponentProps<typeof NextThemesProvider>;


const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, ...props }) => {
  const { resolvedTheme } = useTheme();

  useEffect(() => {
    if (resolvedTheme) {
      document.documentElement.setAttribute("data-theme", resolvedTheme);
    }
  }, [resolvedTheme]);

  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
};

export default ThemeProvider;

 

3. components/ThemeToggle.tsx 생성

'use client';
import * as React from 'react';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';

export default function ThemeToggle() {
  const { setTheme } = useTheme();

// ✅ 밝은 테마, 어두운 테마, 기기 테마는 설정 안 해도 자동으로 적용됨
// ✅ 커스텀 테마(sepia, blue 등)는 CSS에서 직접 스타일을 설정해야 적용됨
return (
  <DropdownMenu>
    <DropdownMenuTrigger asChild>
      <Button variant='outline' size='icon'>
        <Sun className='h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
        <Moon className='absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100' />
        <span className='sr-only'>Toggle theme</span>
      </Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent align='end'>
      <DropdownMenuItem onClick={() => setTheme('light')}>☀️ 밝은 테마</DropdownMenuItem>
      <DropdownMenuItem onClick={() => setTheme('zinc')}>???? 어두운 테마</DropdownMenuItem>
      <DropdownMenuItem onClick={() => setTheme('yellow')}> ???? 옐로우 테마</DropdownMenuItem>
      <DropdownMenuItem onClick={() => setTheme('blue')}>???? 블루 테마</DropdownMenuItem>
      <DropdownMenuItem onClick={() => setTheme('green')}>???? 그린 테마</DropdownMenuItem> 
    </DropdownMenuContent>
  </DropdownMenu>
);
}

 

 

4.src/app/provders.tsx 생성

"use client";
import ThemeProvider from "@/components/theme-provider";
import React from "react";
import { Toaster } from "@/components/ui/toaster";

interface ProvidersProps {
  children: React.ReactNode;
}

const Providers: React.FC<ProvidersProps> = ({ children }) => {
  return (
    <ThemeProvider
      attribute="data-theme"
      defaultTheme="system"
      enableSystem
      disableTransitionOnChange
    >
      {children}
      <Toaster />
    </ThemeProvider>
  );
};

export default Providers;

 

 

5. globals.css 에  shadcn 의 테마를 참조해서 다음과 같이 추가

https://ui.shadcn.com/themes

/* ???? blue 테마 설정 */
:root[data-theme='zinc'] {
  --background: 240 10% 3.9%;
  --foreground: 0 0% 98%;
  --card: 240 10% 3.9%;
  --card-foreground: 0 0% 98%;
  --popover: 240 10% 3.9%;
  --popover-foreground: 0 0% 98%;
  --primary:333 71% 51%;
  --primary-foreground: 0 0% 100%;
  --secondary: 240 3.7% 15.9%;
  --secondary-foreground: 0 0% 98%;
  --muted: 240 3.7% 15.9%;
  --muted-foreground: 240 5% 64.9%;
  --accent: 240 3.7% 15.9%;
  --accent-foreground: 0 0% 98%;
  --destructive: 0 72% 51%;
  --destructive-foreground: 0 0% 98%;
  --border: 240 3.7% 15.9%;
  --input: 240 3.7% 15.9%;
  --ring: 240 4.9% 83.9%;
  --chart-1: 220 70% 50%;
  --chart-2: 160 60% 45%;
  --chart-3: 30 80% 55%;
  --chart-4: 280 65% 60%;
  --chart-5: 340 75% 55%;
}

 

 

6.테마 버튼 넣고 싶은 곳에 추가

import ThemeToggle from "@/components/ThemeToggle";
import LightDarkToggle from "@/components/ui/light-dark-toggle";
import React from "react";

type LoggedOutLayoutProps = {
  children: React.ReactNode;
};


const LoggedOutLayout: React.FC<LoggedOutLayoutProps> = ({ children }) => {
  return <div className="flex flex-col gap-4 min-h-screen p-24 items-center justify-center">
     {children}
     {/* <LightDarkToggle className="fixed top-1/2 right-2 -mt-4" /> */}
     <LightDarkToggle className="fixed top-[calc(50%-12px)] right-2 " />

     <div className="fixed top-[calc(50%-102px)] right-2">
         <ThemeToggle />
     </div>

    </div>;
};

export default LoggedOutLayout;

 

 

 

 

16. shadcn  toast 적용하기

 

다음 주소에서 "16. shadcn  toast 적용하기" 검색

https://macaronics.net/m04/react/view/2365#shadcn-toast

 

 

 

최종 회원가입 페이지 뷰

 

 

 

 

 

 

 

 

 

 

 

 

 

4.대시보드 레이아웃 만들기

 

 

17. 대시보드 레이아웃 만들기

 

1. 프로젝트 구조

src/
 ├── app/
 │   ├── dashboard/
 │   │   ├── layout.tsx  <-- 대시보드 레이아웃 (사이드바 + 콘텐츠)
 │   │   ├── page.tsx    <-- 대시보드 기본 페이지
 │   ├── layout.tsx      <-- 전역 레이아웃
 │   ├── page.tsx        <-- 홈 페이지
 

 

2. 기본 대시보드 레이아웃 개발

우리는 대시보드 레이아웃을 먼저 구현한 후, 사이드바와 콘텐츠 영역을 추가할 것입니다.

 1단계: 대시보드 페이지 생성

src/app/dashboard/page.tsx

import React from "react";

const DashboardPage: React.FC = () => {
  return <div>Dashboard Page</div>;
};

export default DashboardPage;

 

2단계: 대시보드 레이아웃 생성

src/app/dashboard/layout.tsx

import React from "react";

interface DashboardLayoutProps {
  children: React.ReactNode;
}

const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children }) => {
  return (
    <div className="grid grid-cols-[250px_1fr] h-screen">
      <div className="bg-muted overflow-auto p-4">Side Panel</div>

      <div className="overflow-auto py-2 px-4">
        <h1 className="pb-4 text-2xl font-bold">환영합니다. 홍길동님!</h1>
        {children}
      </div>
    </div>
  );
};

export default DashboardLayout;

설명

  • grid grid-cols-[250px_1fr] h-screen:
    250px 너비의 사이드바 + 나머지 공간을 차지하는 콘텐츠 영역을 생성합니다.
  • <h1> 태그를 추가하여 "환영합니다. 홍길동님!"을 출력합니다.
  • {children}을 통해 대시보드 내 모든 페이지의 내용을 표시합니다.

 

grid-cols-[250px_1fr]

  • grid-template-columns 속성을 사용자 정의 값(브래킷 [] 안의 값) 으로 설정합니다.
  • 250px 1fr는 각 열(column)의 크기를 의미합니다.

 

grid-template-columns: 250px 1fr;
 

250px : 첫 번째 열의 너비를 250px로 고정

1fr : 두 번째 열은 남은 공간을 모두 차지 

 

예제: 사이드바 + 콘텐츠 레이아웃

<div class="grid grid-cols-[250px_1fr] gap-4 h-screen">
  <aside class="bg-gray-200 p-4">사이드바</aside>
  <main class="bg-gray-100 p-4">메인 콘텐츠</main>
</div>

 

결과

  • 왼쪽(aside): 250px 고정된 사이드바
  • 오른쪽(main): 남은 공간을 전부 차지하는 콘텐츠 영역

렌더링 결과

|  250px  |  남은 모든 공간 (1fr)  |
| Sidebar |      Main Content      |

 

1fr 250px로 바꾸면?

<div class="grid grid-cols-[1fr_250px]">
  <main class="bg-gray-100 p-4">메인 콘텐츠</main>
  <aside class="bg-gray-200 p-4">사이드바</aside>
</div>

이렇게 하면 메인 콘텐츠가 먼저 배치되고 사이드바가 오른쪽에 배치됩니다.

렌더링 결과

|  남은 모든 공간 (1fr)  |  250px  |
|      Main Content      | Sidebar |

 

grid-cols-[250px_1fr]에서는 _가 없어도 되지만 보통 공백대신에  공백을 _로 변환해서 사용

 

 

 

 

 

18. 메뉴 제목 만들기

 

 

1. 프로젝트 구조

src/
 ├── app/
 │   ├── dashboard/
 │   │   ├── components/
 │   │   │   ├── main-menu.tsx  <-- 메인 메뉴 컴포넌트
 │   │   │   ├── menu-title.tsx <-- 로고 및 사이트 제목 컴포넌트
 │   │   ├── layout.tsx         <-- 대시보드 레이아웃
 │   │   ├── page.tsx           <-- 대시보드 기본 페이지
 ├── components/
 │   ├── ui/
 │   │   ├── button.tsx         <-- 공통 버튼 컴포넌트

 

 

2. 코드 및 설명

1단계: MainMenu.tsx 생성

 src/app/dashboard/components/main-menu.tsx

import React from "react";
import MenuTitle from "./menu-title";

const MainMenu: React.FC = () => {
  return (
    <div className="bg-muted overflow-auto p-4">          
      <div className="border-b dark:border-b-black border-b-zinc-300 pb-4">      
        <MenuTitle />
      </div>
    </div>
  );
};

export default MainMenu;

설명

  • MainMenu 컴포넌트는 왼쪽 패널의 메인 메뉴를 구성합니다.
  • MenuTitle 컴포넌트를 포함하여 사이트 제목과 로고를 표시합니다.
  • 어두운 테마(dark:border-b-black)와 밝은 테마(border-b-zinc-300)의 구분선이 추가됨.

 2단계: MenuTitle.tsx 생성

 src/app/dashboard/components/menu-title.tsx

import { PersonStandingIcon } from 'lucide-react';
import React from 'react';

const MenuTitle: React.FC = () => {
  return (
    <h4 className='flex items-center text-2xl'>
      <PersonStandingIcon size={40} className="text-pink-500" /> SupportMe
    </h4>
  );
};

export default MenuTitle;

설명

  • MenuTitle은 대시보드의 로고 및 사이트 제목을 표시합니다.
  • PersonStandingIcon(Lucide-react 사용)을 아이콘으로 표시하며 크기 40 적용.
  • text-pink-500으로 핑크색 아이콘 스타일 적용.

 3단계: layout.tsx에서 MainMenu.tsx 사용

 src/app/dashboard/layout.tsx

import React from "react";
import MainMenu from "@/app/dashboard/components/main-menu";

interface DashboardLayoutProps {
  children: React.ReactNode;
}

const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children }) => {
  return (
    <div className="grid grid-cols-[250px_1fr] h-screen">
      <MainMenu />
      <div className="overflow-auto py-2 px-4">
        <h1 className="pb-4 text-2xl font-bold">환영합니다. 홍길동님!</h1>
        {children}
      </div>
    </div>
  );
};

export default DashboardLayout;

설명

  • MainMenu를 DashboardLayout에 추가하여 사이드 패널을 구성함.
  • grid-cols-[250px_1fr]을 사용하여 왼쪽 250px 고정 사이드바, 나머지 콘텐츠 영역을 가변 배치함.

 

 

 

 

 

19.개별 메뉴 항목 컴포넌트 만들기

 

 

프로젝트 구조

src/
 ├── app/
 │   ├── dashboard/
 │   │   ├── components/
 │   │   │   ├── main-menu.tsx  <-- 메인 메뉴 컴포넌트
 │   │   │   ├── menu-title.tsx <-- 로고 및 사이트 제목 컴포넌트
 │   │   │   ├── menu-item.tsx  <-- 개별 메뉴 항목 컴포넌트
 │   │   ├── layout.tsx         <-- 대시보드 레이아웃
 │   │   ├── page.tsx           <-- 대시보드 기본 페이지
 ├── components/
 │   ├── ui/
 │   │   ├── button.tsx         <-- 공통 버튼 컴포넌트

 

1. MenuItem.tsx 생성 (개별 메뉴 항목 컴포넌트)

 src/app/dashboard/components/menu-item.tsx

"use client";

import { cn } from '@/lib/utils';
import Link from 'next/link';
import { usePathname } from 'next/navigation'
import React from 'react'


interface MenuItemProps {
    children: React.ReactNode,
    href: string
}

const MenuItem:React.FC<MenuItemProps> = ({children, href}) => {
  const pathname=usePathname();
  const isActive = pathname === href;

  return (
    <li>
      <Link href={href} 
          className={cn("block p-2 hover:bg-white dark:hover:bg-zinc-700  rounded-md text-muted-foreground  text-sm font-medium",
                          isActive && 
                          "bg-primary hover:bg-primary dark:hover:bg-primary hover:text-primary-foreground text-primary-foreground ")}
                        >
          {children}
      </Link>
    </li>
  )

}

export default MenuItem;

설명

  • MenuItem은 왼쪽 패널에서 개별 메뉴 항목을 렌더링하는 역할을 합니다.
  • 현재 페이지의 경로 (usePathname())를 가져와 현재 활성화된 메뉴인지 확인 후 스타일 적용합니다.
  • cn() 함수를 사용하여 Tailwind 클래스를 동적으로 적용합니다.

 

2. MainMenu.tsx 생성 (메인 메뉴 컴포넌트)

 src/app/dashboard/components/main-menu.tsx

import React from "react";
import MenuTitle from "./menu-title";
import MenuItem from "./menu-item";

const MainMenu: React.FC = () => {
    return (
  <div className="bg-muted overflow-auto p-4">          
    <div className="border-b dark:border-b-black border-b-zinc-300 pb-4">      
      <MenuTitle />
    </div>

     <div className="py-4">  
        <MenuItem href="/dashboard">대시보드</MenuItem>
        <MenuItem href="/dashboard/teams">팀</MenuItem>
        <MenuItem href="/dashboard/employee">직원</MenuItem>
        <MenuItem href="/dashboard/account">사용자정보</MenuItem>
        <MenuItem href="/dashboard/settings">설정</MenuItem>        
     </div>
  </div>);
};

export default MainMenu;

설명

  • MainMenu는 왼쪽 패널의 네비게이션 메뉴를 렌더링합니다.
  • MenuTitle을 포함하여 사이트 제목과 로고를 표시합니다.
  • MenuItem을 사용하여 각 메뉴 항목을 동적으로 생성합니다.
  • 어두운 모드(dark:border-b-black)와 밝은 모드(border-b-zinc-300)를 지원.

 

3. layout.tsx에서 MainMenu.tsx 사용

src/app/dashboard/layout.tsx

import React from 'react'
import MainMenu from './components/main-menu'

interface DashboardLayoutProps {
    children: React.ReactNode
}

const DashboardLayout:React.FC<DashboardLayoutProps> = ({children}) => {
  return (
    <div className='grid  grid-cols-[250px_1fr]  h-screen'>
        <div className='bg-muted overflow-auto p-3'>
          <MainMenu />
        </div>
           
        <div className='overflow-auto py-2 px-4'>
            <h1 className='pb-4 text-2xl font-bold'>환영합니다. 홍길동님!</h1>
            {children}
        </div>        
    </div>
  )
}

export default DashboardLayout

설명

  • MainMenu를 DashboardLayout에 추가하여 사이드 패널을 구성함.
  • grid-cols-[250px_1fr]을 사용하여 왼쪽 250px 고정 사이드바, 나머지 콘텐츠 영역을 가변 배치함.

 

 

 

 

 

 

20. 추가  sidebar footer with Avatar

 

 

https://ui.shadcn.com/docs/components/avatar

avatar 설치


npx shadcn@latest add avatar
 

 

 

1. 프로젝트 구조

src/
 ├── app/
 │   ├── dashboard/
 │   │   ├── components/
 │   │   │   ├── main-menu.tsx  <-- 메인 메뉴 컴포넌트
 │   │   │   ├── menu-item.tsx  <-- 개별 메뉴 항목 컴포넌트
 │   │   │   ├── menu-title.tsx <-- 로고 및 사이트 제목 컴포넌트
 │   │   ├── layout.tsx         <-- 대시보드 레이아웃
 │   │   ├── page.tsx           <-- 대시보드 기본 페이지
 ├── components/
 │   ├── ui/
 │   │   ├── avatar.tsx         <-- 사용자 아바타 UI 컴포넌트
 │   │   ├── theme-toggle.tsx   <-- 다크/라이트 모드 토글

2. 코드 및 설명

 1단계: MenuItem.tsx 생성

 src/app/dashboard/components/menu-item.tsx

"use client";

import { cn } from '@/lib/utils';
import Link from 'next/link';
import { usePathname } from 'next/navigation'
import React from 'react'

interface MenuItemProps {
    children: React.ReactNode,
    href: string
}

const MenuItem: React.FC<MenuItemProps> = ({ children, href }) => {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <Link href={href}
        className={cn("block p-2 hover:bg-white dark:hover:bg-zinc-700 rounded-md text-muted-foreground text-sm font-medium",
                        isActive && "bg-primary hover:bg-primary dark:hover:bg-primary hover:text-foreground text-white")}
                      >
        {children}
    </Link>
  )
}

export default MenuItem;

설명

  • 현재 경로(usePathname)를 가져와 해당 메뉴 항목이 활성화(isActive) 되었는지 확인합니다.
  • 활성화된 메뉴 항목은 bg-primary(기본색)으로 스타일이 적용됩니다.
  • cn()을 활용해 클래스명을 조건부로 적용해 코드 가독성을 높였습니다.

 2단계: MainMenu.tsx에 MenuItem.tsx 추가

 src/app/dashboard/components/main-menu.tsx

import React from "react";
import MenuTitle from "./menu-title";
import MenuItem from "./menu-item";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import Link from "next/link";
import ThemeToggle from "@/components/ThemeToggle";

const MainMenu: React.FC = () => {
  return (
    <nav className="bg-muted overflow-auto p-4 flex flex-col h-full">
      <header className="border-b dark:border-b-black border-b-zinc-300  pb-4">
        <MenuTitle />
      </header>

      <ul className="py-4 grow flex flex-col gap-1">
        <MenuItem href="/dashboard">대시보드</MenuItem>
        <MenuItem href="/dashboard/teams">팀</MenuItem>
        <MenuItem href="/dashboard/employee">직원</MenuItem>
        <MenuItem href="/dashboard/account">시용자정보</MenuItem>
        <MenuItem href="/dashboard/settings">설정</MenuItem>
      </ul>

      <footer className="flex gap-2 items-center">
        <Avatar>
          <AvatarFallback className="bg-pink-300 dark:bg-pink-800">
            {" "}
            TP{" "}
          </AvatarFallback>
        </Avatar>
        <Link href="/" className="hover:underline">
          로그아웃
        </Link>
        <ThemeToggle className="ml-auto" />
      </footer>
    </nav>
  );
};

export default MainMenu;

설명

  • AvatarFallback을 이용하여 기본 아바타를 표시합니다.
  • grow 클래스를 사용하여 메뉴가 전체 높이를 차지하도록 설정.
  • ThemeToggle을 추가하여 다크/라이트 모드 전환 가능.

 

 

 

 

 

 

 

 

 

5.대시보드 페이지 만들기

 

21. shadcn 탭 컴포넌트 사용방법

 

1. 대시보드 페이지 구축 시작하기

이를 위해 shadcn의 특정 컴포넌트 중 Tabs 컴포넌트를 활용합니다. 이 컴포넌트는 활성 탭에 따라 서로 다른 뷰를 렌더링할 수 있는 기능을 제공합니다.


 

설치

pnpm dlx shadcn@latest add tabs


 

 

2. 탭 컴포넌트 설치 및 구

우선 shadcn UI의 탭 컴포넌트를 설치해야 합니다. CLI 명령어를 사용하여 설치한 후, 프로젝트에서 해당 컴포넌트를 사용할 수 있습니다.

설치가 완료되면 components/ui/tabs.tsx 파일이 생성됩니다. 이후 대시보드 페이지에 탭을 추가하여 UI를 구성합니다.

 

코드 예제

import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TabsContent } from "@radix-ui/react-tabs";
import React from "react";

const DashboardPage: React.FC = () => {
  return (
    <Tabs defaultValue="employees">
      <TabsList>
          <TabsTrigger value="employees">직원 상태</TabsTrigger>
          <TabsTrigger value="teams">팀 상태</TabsTrigger>
      </TabsList>
      <TabsContent value="employees">
          <h2>직원 상태</h2>
      </TabsContent>
      <TabsContent value="teams">
          <h2>팀 상태</h2>
      </TabsContent>
    </Tabs>
  );
};

export default DashboardPage;

 

3. 주요 기능 설명

1) Tabs 컴포넌트 구성

  • Tabs : 기본적으로 활성화될 탭을 설정할 수 있는 컨테이너.
  • TabsList : 탭 버튼들을 감싸는 역할.
  • TabsTrigger : 각 탭을 클릭할 수 있도록 설정하는 버튼.
  • TabsContent : 선택된 탭에 따라 렌더링될 내용.
  •  

2) Tabs 기본값 설정

  • defaultValue="employees" 설정을 통해 기본적으로 직원 상태 탭이 활성화되도록 합니다.
  • TabsTrigger의 value 값과 TabsContent의 value 값을 동일하게 설정해야 합니다.

 

3) 탭 전환 시 동작 방식

  • 특정 탭을 클릭하면 해당 value에 맞는 TabsContent가 렌더링됩니다.
  • 이를 통해 직원 상태와 팀 상태를 토글할 수 있습니다.

 

 

 

 

 

22.카드 컨텐츠 추가 방법

 

 

 

카드 레이아웃 구현

먼저, EmployeesStats.tsx 파일을 생성하여 직원 통계 뷰를 별도의 컴포넌트로 분리합니다. 이 컴포넌트는 기본적으로 Card 컴포넌트를 활용하여 직원 통계를 표시하는 역할을 합니다.

 

src/app/dashboard/components/employees-stats.tsx

import { Card } from '@/components/ui/card';
import React from 'react';

const EmployeesStats: React.FC = () => {
  return (
    <div className='grid lg:grid-cols-3 gap-4'>
      <Card>card 1</Card>
      <Card>card 2</Card>
      <Card>card 3</Card>
    </div>
  );
};

export default EmployeesStats;

위 코드에서 grid 클래스를 적용하여 기본적으로 카드들이 한 줄로 배치되도록 하고, 큰 화면에서는 lg:grid-cols-3을 사용하여 세 개의 카드를 한 줄에 배치하도록 설정했습니다. gap-4를 추가하여 카드 사이의 간격을 조정했습니다.

 

 

대시보드에 컴포넌트 추가

이제 DashboardPage.tsx에서 EmployeesStats 컴포넌트를 추가하여 직원 상태 탭에서 해당 컴포넌트를 렌더링하도록 설정합니다.

import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TabsContent } from '@radix-ui/react-tabs';
import React from 'react';
import EmployeesStats from './components/employees-stats';

const DashboardPage: React.FC = () => {
  return (
    <Tabs defaultValue='employees'>
      <TabsList className='mb-4'>
        <TabsTrigger value='employees'>직원 상태</TabsTrigger>
        <TabsTrigger value='teams'>팀 상태</TabsTrigger>
      </TabsList>

      <TabsContent value='employees'>
        <h2 style={{ fontSize: '18px' }}>직원 상태</h2>
        <EmployeesStats />
      </TabsContent>

      <TabsContent value='teams'>
        <h2 style={{ fontSize: '18px' }}>팀 상태</h2>
      </TabsContent>
    </Tabs>
  );
};

export default DashboardPage;

반응형 레이아웃 적용

  • 화면 크기가 작아지면 카드가 한 줄로 나열되는 것이 아니라 세로로 쌓이도록 설정되었습니다.
  • lg:grid-cols-3를 통해 큰 화면에서는 카드가 3개씩 정렬됩니다.
  • gap-4를 사용하여 카드 간격을 조정하여 더 깔끔한 UI를 제공합니다.

여백 조정

탭 목록과 콘텐츠 사이의 간격을 조정하기 위해 TabsList에 mb-4 클래스를 추가하였습니다. 이를 통해 아래쪽 여백을 16px로 설정하여 콘텐츠가 보다 균형 있게 배치됩니다.

 

 

 

 

 

 

23. 카드 만들기 상세 구현

 

카드 UI 컴포넌트 구현

import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { UserIcon } from 'lucide-react';
import Link from 'next/link';
import React from 'react'

const EmployeesStats: React.FC = () => {
  return (
    <div className='grid lg:grid-cols-3 gap-4'>
        <Card>
            <CardHeader>
                <CardTitle className='text-base'>전체 직원</CardTitle>    
            </CardHeader>   
            <CardContent className='flex justify-between items-center'>
                <div className='flex gap-2'>
                    <UserIcon />
                </div>
                <div className='text-5xl font-bold'>
                    100
                </div>
                <div>
                    <Button size="sm" asChild>
                        <Link href="/dashboard/employees">전체 보기</Link>
                    </Button>
                </div>
            </CardContent>
        </Card>
        <Card>
            <CardHeader>
                <CardTitle className='text-base'>직원 출석</CardTitle>    
            </CardHeader>    
        </Card>
        <Card className='border-pink-500'>
            <CardHeader>
                <CardTitle className='text-base'>이달의 직원</CardTitle>
            </CardHeader>    
        </Card>
    </div>
  )
}

export default EmployeesStats;

주요 구현 사항

  1. 카드 레이아웃

    • lg:grid-cols-3 클래스를 사용하여 3개의 카드가 가로로 정렬됩니다.
    • gap-4를 추가하여 카드 간 간격을 줍니다.
  2. 카드 내용 구성

    • 카드 헤더(CardHeader) 내부에 카드 제목(CardTitle)을 추가합니다.
    • CardContent 내부에 직원 수, 아이콘, "전체 보기" 버튼을 배치합니다.
    • 직원 수(100)는 text-5xl font-bold 클래스를 사용하여 크고 굵게 표시합니다.
  3. 아이콘 및 버튼 추가

    • UserIcon을 추가하여 시각적인 요소를 강화합니다.
    • Button 컴포넌트를 사용해 Link를 감싸 "전체 보기" 버튼을 생성합니다.
    • 버튼 크기를 size="sm"으로 설정하여 작게 표시합니다.
  4. 특정 카드 스타일링

    • border-pink-500 클래스를 사용하여 "이달의 직원" 카드의 테두리를 분홍색으로 지정합니다.

 

개선 사항 및 추가 구현 계획

  • 카드 제목의 글꼴 크기를 text-base로 설정하여 크기를 통일합니다.
  • justify-between을 활용하여 아이콘, 직원 수, 버튼을 균형 있게 정렬합니다.
  • 버튼 크기가 너무 크면 shadcn UI 버튼을 확장하여 맞춤 스타일을 추가할 예정입니다.
  • 다음 강의에서는 Shadcn UI 버튼을 확장하는 방법을 다룰 예정입니다.

 

 

 

 

24. Shadcn UI 버튼을 확장하는 방법

1. 개요

Shadcn UI의 버튼 컴포넌트를 확장하여 추가적인 크기 옵션을 제공하는 방법을 알아보겠습니다. 특히, 엑스트라 스몰(XS) 크기를 추가하고 이에 맞는 스타일을 설정해보겠습니다.

 

2. 기존 버튼 크기 설정

Shadcn UI의 button.tsx 파일에서 버튼의 크기는 다음과 같이 설정되어 있습니다.

size: {
  default: "h-10 px-4 py-2",
  sm: "h-9 rounded-md px-3 text-xs",
  xs: "text-xs py-2 px-3 tracking-normal",
  lg: "h-10 rounded-md px-8",
  icon: "h-9 w-9",
},

여기서 xs 크기가 존재하지만, 이를 확장하여 사용자 정의 스타일을 추가해보겠습니다.

 

3. 버튼 크기 확장

버튼 크기를 확장할 때 고려해야 할 사항:

  • X축과 Y축 패딩 조정
  • 글꼴 크기 조정
  • 글자 간격(Tracking) 조정

 

새로운 xs 크기 변형을 추가하여 더 작은 크기의 버튼을 지원하도록 설정합니다.

size: {
  default: "h-10 px-4 py-2",
  sm: "h-9 rounded-md px-3 text-xs",
  xs: "text-xs py-2 px-3 tracking-normal",
  xxs: "text-xs py-2 px-3 tracking-tight",
  lg: "h-10 rounded-md px-8",
  icon: "h-9 w-9",
},
  • xxs(엑스트라 엑스트라 스몰) 크기를 추가
  • tracking-tight(글자 간격을 더 좁게 설정)
  • 패딩을 최소화하여 작은 버튼에서 균형 잡힌 스타일 유지

 

4. 스타일 적용 확인

이제 새로운 xxs 크기의 버튼을 추가했으므로, 이를 적용하여 스타일을 확인합니다.

  1. button.tsx 파일을 저장합니다.
  2. 버튼이 사용되는 대시보드 컴포넌트(dashboard.tsx)에서 버튼 크기를 xxs로 설정합니다.
  3. localhost:3000/dashboard를 새로고침하여 적용된 모습을 확인합니다.

 

 

 

 

 

 

25. 카드 만들기 상세 구현 2

 

카드 구성 및 데이터 추가

이 강의에서는 카드의 나머지 데이터를 작성하는 방법을 알아보겠습니다. 브라우저에서 localhost:3000을 실행하고, 총 직원 수를 추가하는 것이 좋은 시작점입니다.

완성된 프로젝트를 참조하면 첫 번째 카드와 유사한 아이콘을 추가할 수 있습니다. 직원 수를 나타내는 작은 체크 아이콘이 있으며, 큰 숫자로 직원 수를 표시합니다.

코드로 돌아가서 첫 번째 카드의 카드 콘텐츠를 복사하여 두 번째 카드에 적용합니다. 두 번째 카드의 카드 헤더 아래에 CardContent 컴포넌트를 추가하고, 첫 번째 카드의 내용을 붙여넣은 후, 몇 가지 값을 변경합니다.

  • 출석 직원 수를 80으로 설정
  • 아이콘을 UserCheck2Icon으로 변경
  • UI 변화를 적용하기 위해 다양한 아이콘을 조건부 렌더링

출근율 표시 및 조건부 렌더링

출석한 직원 수에 따라 아이콘과 텍스트를 변경합니다.

 

const totalEmployees = 100;
const employeesPresent = 35;
const employeesPresentPercentage = (employeesPresent / totalEmployees) * 100;

 

출석률이 75% 이상이면 UserCheck2Icon을 렌더링하고, 75% 미만이면 UserRoundIcon을 렌더링합니다. 카드 푸터에는 출석률을 기반으로 메시지를 표시합니다.

  • 출석률 75% 이상: 초록색 BadgeCheckIcon과 함께 "직원의 X%가 현재 근무 중" 텍스트 표시
  • 출석률 75% 미만: 빨간색 AlertTriangleIcon과 함께 "직원의 X%만 현재 근무 중" 텍스트 표시
<CardFooter>
  {employeesPresentPercentage > 75 ? (
    <span className='text-xs text-green-500 flex gap-1 items-center'>
      <BadgeCheckIcon /> 직원의 {employeesPresentPercentage}% 가 현재 근무 중
    </span>
  ) : (
    <span className='text-xs text-red-500 flex gap-1 items-center'>
      <AlertTriangleIcon /> 직원의 {employeesPresentPercentage}% 만 현재 근무 중
    </span>
  )}
</CardFooter>

이제 localhost:3000/dashboard에서 결과를 확인하면, 출석률이 75% 이하일 때 아이콘과 텍스트 색상이 변하는 것을 볼 수 있습니다.

 

 

 

 

 

 

26. 카드 만들기 상세 구현 3

 

카드 구현 개요

  1. 전체 직원 수를 표시하는 카드
  2. 출근한 직원 수와 출근율을 나타내는 카드
  3. '이달의 직원'을 보여주는 카드

코드 설명

import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { AlertTriangleIcon, BadgeCheckIcon, PartyPopperIcon, UserCheck2Icon, UserIcon, UserRoundIcon } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import React from 'react';

const EmployeesStats: React.FC = () => {
  const totalEmployees = 100;
  const employeesPresent = 85;
  const employeesPresentPercentage = (employeesPresent / totalEmployees) * 100;

  return (
    <div className='grid lg:grid-cols-3 gap-4'>
      {/* 전체 직원 카드 */}
      <Card>
        <CardHeader className='pb-2'>
          <CardTitle className='text-base'>전체 직원</CardTitle>
        </CardHeader>
        <CardContent className='flex justify-between items-center'>
          <UserIcon />
          <div className='text-5xl font-bold'>{totalEmployees}</div>
          <Button size="xs" asChild>
            <Link href="/dashboard/employees">전체 보기</Link>
          </Button>
        </CardContent>
      </Card>

      {/* 출근율 카드 */}
      <Card>
        <CardHeader className='pb-2'>
          <CardTitle className='text-base'>직원 출근율</CardTitle>
        </CardHeader>
        <CardContent className='flex justify-between items-center'>
          {employeesPresentPercentage > 75 ? <UserCheck2Icon /> : <UserRoundIcon />}
          <div className='text-5xl font-bold'>{employeesPresent}</div>
        </CardContent>
        <CardFooter>
          {employeesPresentPercentage > 75 ? (
            <span className='text-xs text-green-500 flex gap-1 items-center'>
              <BadgeCheckIcon /> 직원의 {employeesPresentPercentage}% 가 현재 근무 중
            </span>
          ) : (
            <span className='text-xs text-red-500 flex gap-1 items-center'>
              <AlertTriangleIcon /> 직원의 {employeesPresentPercentage}% 만 현재 근무 중
            </span>
          )}
        </CardFooter>
      </Card>

      {/* 이달의 직원 카드 */}
      <Card className='border-pink-500 flex flex-col'>
        <CardHeader className='pb-2'>
          <CardTitle className='text-base'>이달의 직원</CardTitle>
        </CardHeader>
        <CardContent className='flex gap-2 items-center'>
          <Avatar>
            <Image src="/images/tw.png" alt="이달의 직원 아바타" width={40} height={40} />
            <AvatarFallback>CM</AvatarFallback>
          </Avatar>
          <span>이달의 직원</span>
        </CardContent>
        <CardFooter className='flex gap-2 items-center text-xs text-muted-foreground mt-auto'>
          <PartyPopperIcon className='text-pink-500' />
          <span>홍길동님! 축하합니다</span>
        </CardFooter>
      </Card>
    </div>
  );
};

export default EmployeesStats;

주요 포인트

1. 전체 직원 카드

  • 직원 수(totalEmployees)를 표시하며, UserIcon과 함께 숫자를 보여줍니다.
  • '전체 보기' 버튼을 누르면 /dashboard/employees 페이지로 이동합니다.

 

2. 출근율 카드

  • 출근한 직원 수(employeesPresent)를 보여주며, 출근율(employeesPresentPercentage)에 따라 다른 아이콘이 표시됩니다.
  • 출근율이 75% 이상이면 UserCheck2Icon, 그렇지 않으면 UserRoundIcon이 나타납니다.
  • 카드 바닥글에는 출근율이 75% 이상인지 여부에 따라 녹색 또는 빨간색 메시지가 표시됩니다.

 

3. 이달의 직원 카드

  • Avatar 컴포넌트를 사용해 직원 아바타를 표시합니다.
  • 카드 바닥글에는 PartyPopperIcon과 함께 축하 메시지를 보여줍니다.
  • mt-auto 클래스를 활용해 카드 바닥글을 하단에 정렬했습니다.

 

 

 

 

 

27. Recharts를 이용한 차트 만들기-막대형 차트

1. Recharts 설치하기

Recharts를 사용하려면 먼저 프로젝트에 라이브러리를 설치해야 합니다. Next.js 15 및 shadcn과 함께 사용하려면 다음 명령어를 실행하세요.

pnpm install recharts

설치가 완료되면 package.json 파일에서 Recharts가 의존성으로 추가된 것을 확인할 수 있습니다.

 

2. Recharts를 이용한 차트 만들기

아래는 직원들의 근무 위치 트렌드를 보여주는 막대 차트(Bar Chart)를 만드는 예제입니다.

 

2.1. 차트 데이터 생성

먼저 차트에 사용할 더미 데이터를 생성합니다.

const data = [
  { name: "1월", office: 82, wfh: 44 },
  { name: "2월", office: 80, wfh: 40 },
  { name: "3월", office: 83, wfh: 42 },
  { name: "4월", office: 50, wfh: 50 },
  { name: "5월", office: 40, wfh: 60 },
  { name: "6월", office: 60, wfh: 40 },
  { name: "7월", office: 55, wfh: 55 },
  { name: "8월", office: 49, wfh: 61 },
  { name: "9월", office: 44, wfh: 70 },
  { name: "10월", office: 40, wfh: 40 },
  { name: "11월", office: 50, wfh: 50 },
  { name: "12월", office: 50, wfh: 50 },
];

 

2.2. Recharts 컴포넌트 만들기

다음으로 WorkLocationTrends.tsx 파일을 생성하고, 데이터를 시각화하는 차트를 구현합니다.

"use client";
import React from "react";
import { BarChart, Bar, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts";

const WorkLocationTrends: React.FC = () => {
  const fontStyle = {
    fontFamily: "'Noto Sans KR', sans-serif",
    fontSize: "14px",
    fill: "#fff",
  };

  return (
    <ResponsiveContainer height={350} width={"100%"}>
      <BarChart data={data}>
        <XAxis dataKey="name" stroke="#888888" tick={{ ...fontStyle }} />
        <YAxis stroke="#888888" tick={{ ...fontStyle }} />
        <Tooltip
          contentStyle={{ fontFamily: "'Noto Sans KR', sans-serif" }}
          formatter={(value, name, props) => {
            if (Array.isArray(props?.payload)) {
              const barEntry = props.payload.find((entry) => entry.dataKey === name);
              if (barEntry) {
                const translatedName = name === "office" ? "사무실 근무" : "재택 근무";
                return [value, translatedName];
              }
            }
            return [value, name];
          }}
        />
        <Bar dataKey="office" stackId="1" fill="#ec4889" name="사무실 근무" />
        <Bar dataKey="wfh" stackId="2" fill="#6b7280" radius={[4, 4, 0, 0]} name="재택 근무" />
      </BarChart>
    </ResponsiveContainer>
  );
};

export default WorkLocationTrends;

 

3. 차트 컴포넌트 적용하기

위에서 만든 WorkLocationTrends.tsx 컴포넌트를 원하는 위치에서 렌더링합니다. 예를 들어, 대시보드 페이지에서 다음과 같이 사용할 수 있습니다.

import WorkLocationTrends from "./components/employees/WorkLocationTrends";

export default function Dashboard() {
  return (
    <div>
      <h2>직원 근무 위치 트렌드</h2>
      <WorkLocationTrends />
    </div>
  );
}

 

4. 추가적인 스타일 조정

shadcn을 이용하여 카드 내에 차트를 배치할 경우, Card 컴포넌트 내부에 WorkLocationTrends를 배치할 수 있습니다.

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

<Card className="p-4">
  <CardHeader>
    <CardTitle className="text-lg">직원 근무 위치 트렌드</CardTitle>
  </CardHeader>
  <CardContent>
    <WorkLocationTrends />
  </CardContent>
</Card>

위 코드에서는 CardTitle을 사용하여 타이틀의 크기를 18px로 설정하였으며, CardContent 내부에 차트를 배치하여 깔끔한 레이아웃을 유지할 수 있습니다.

 

 

Recharts 문서: https://recharts.org/en-US

 

 

 

 

 

28. Recharts를 이용한 차트 만들기-2 옵션 수정

 

-막대형 차트 Bar stackId 를 동일하게 설정합니다.

 

"use client";
import React from "react";
import { BarChart, Bar, ResponsiveContainer, XAxis, YAxis, Tooltip, Legend } from "recharts";

const data = [
  { name: "1월", office: 82, wfh: 44 },
  { name: "2월", office: 80, wfh: 40 },
  { name: "3월", office: 83, wfh: 42 },
  { name: "4월", office: 50, wfh: 50 },
  { name: "5월", office: 40, wfh: 60 },
  { name: "6월", office: 60, wfh: 40 },
  { name: "7월", office: 55, wfh: 55 },
  { name: "8월", office: 49, wfh: 61 },
  { name: "9월", office: 44, wfh: 70 },
  { name: "10월", office: 40, wfh: 40 },
  { name: "11월", office: 50, wfh: 50 },
  { name: "12월", office: 50, wfh: 50 },
];

const fontStyle = {
  fontFamily: "'Noto Sans KR', sans-serif",
  fontSize: "14px",
  fill: "#fff",
};

const WorkLocationTrends: React.FC = () => {
  return (
    <ResponsiveContainer height={350} width="100%">
      <BarChart data={data} className="[&_.recharts-tooltip-cursor]:fill-gray-300 dark:[&_.recharts-tooltip-cursor]:fill-gray-800">
        <XAxis dataKey="name" stroke="#888" tick={{ ...fontStyle }} />
        <YAxis stroke="#888" tick={{ ...fontStyle }} />
        <Tooltip
          cursor={{ fill: "transparent" }}
          separator=": "
          wrapperClassName="!text-sm dark:!bg-black !bg-white !text-black dark:!text-white 
          !         rounded-md dark:!border-gray-700 border-gray-300"
          labelFormatter={(label) => `???? ${label}`}
          formatter={(value, name) => {
            return name === "office" ? [value, "사무실 근무"] : [value, "재택 근무"];
          }}
        />
        <Legend
          iconType="circle"
          formatter={(value) => (value === "office" ? "사무실 근무" : "재택 근무")}
        />
        <Bar dataKey="office" stackId="1" fill="#ec4889" name="사무실 근무" />
        <Bar dataKey="wfh" stackId="1" fill="#6b7280" radius={[4, 4, 0, 0]} name="재택 근무" />
      </BarChart>
    </ResponsiveContainer>
  );
};

export default WorkLocationTrends;

 

주요 작업 정리:

  1. 스타일 개선

    • Noto Sans KR 폰트 적용.
    • fill, stroke 등 색상 조정.
    • 다크 모드와 라이트 모드에서의 툴팁 배경 조정.
    • 툴팁과 범례의 가독성을 높이기 위해 커스텀 스타일 적용.
  2. 툴팁 (Tooltip) 수정

    • 툴팁의 배경과 테두리 스타일을 Tailwind CSS 클래스로 조정.
    • 다크 모드에서는 배경을 bg-black, 라이트 모드에서는 bg-white로 설정.
    • !important를 사용하여 Tailwind CSS 클래스가 라이브러리의 기본 스타일을 재정의하도록 설정.
  3. 범례 (Legend) 수정

    • formatter를 활용해 한글로 "재택 근무", "사무실 근무"를 표시.
    • 원형 아이콘을 사용하여 더 보기 쉽게 변경.
  4. 막대 차트 (BarChart) 수정

    • stackId를 활용하여 같은 범주의 데이터를 쌓아 보이도록 설정.
    • 막대 위에 마우스를 올렸을 때 강조 스타일 적용.

 필요한 개선 사항:

  • Tooltip에서 formatter 함수 내에서 name 값을 기준으로 "사무실 근무"와 "재택 근무"를 올바르게 매핑하도록 수정 필요.
  • Legend에서도 formatter를 활성화하고 한국어 텍스트를 제대로 렌더링하도록 개선.
  • 다크 모드에서의 툴팁 스타일을 더 부드럽게 조정.

 

 

[&_.recharts-tooltip-cursor]:fill-gray-300 설명

이 구문은 Tailwind CSS의 중첩 선택자 기능을 활용한 스타일링 방식입니다.

1. [&_] : 상위 요소에서 특정 하위 요소를 선택

  • & 기호는 현재 요소를 의미합니다.
  • &_.recharts-tooltip-cursor는 현재 요소(&) 내부에 있는 .recharts-tooltip-cursor 클래스를 가진 요소를 선택하는 역할을 합니다.
  • 즉, 현재 컴포넌트 안에서 .recharts-tooltip-cursor 클래스를 가진 요소가 있을 경우, 해당 요소의 스타일을 지정하는 것입니다.

2. .recharts-tooltip-cursor란?

recharts-tooltip-cursor는 Recharts 라이브러리에서 툴팁이 활성화될 때 표시되는 배경(커서 효과) 요소입니다.

  • 마우스를 특정 막대 그래프 위에 올리면 해당 막대 영역이 하이라이트(배경색이 변경됨) 됩니다.
  • .recharts-tooltip-cursor 클래스가 있는 요소가 해당 하이라이트 효과를 담당합니다.
  • 기본적으로 Recharts가 자동으로 추가하는 클래스입니다.

3. :fill-gray-300 : 색상 적용

  • fill-gray-300은 SVG 요소(예: <rect> 태그)의 채우기 색상(fill) 을 지정하는 Tailwind CSS 클래스입니다.
  • gray-300은 Tailwind에서 제공하는 색상 팔레트 중 회색 계열 (#D1D5DB) 을 의미합니다.
  • 즉, 툴팁이 활성화될 때 커서 배경을 회색(Gray-300)으로 설정하는 역할을 합니다.

 

 

 

 

 

 

 

29.팀 리더 꾸미

 

1. 팀 리더 데이터 준비

const teamLeaders = [
  { firstName: "Colin", lastName: "Murray", avatar: "/images/cm.jpg" },
  { firstName: "Tina", lastName: "Fey", avatar: "/images/tf.jpg" },
  { firstName: "Ryan", lastName: "Lopez", avatar: "/images/rl.jpg" },
  { firstName: "Tom", lastName: "Phillips" },
  { firstName: "Liam", lastName: "Fuentes" },
];

위 데이터는 팀 리더의 이름, 성, 아바타(선택 사항)을 포함합니다. 이 정보는 UI에서 팀 리더 목록을 표시할 때 사용됩니다.

 

 

2. 팀 통계 카드 UI 구성

<Card>
    <CardHeader className='pb-2'>
        <CardTitle className='text-base'>전체 팀</CardTitle>    
    </CardHeader>   
    <CardContent className='flex justify-between items-center'>
        <UsersIcon />
        <div className='text-5xl font-bold'>100</div>
        <Button size="xs" asChild>
            <Link href="/dashboard/employees">전체 보기</Link>
        </Button>
    </CardContent>
</Card>

이 코드는 전체 팀원의 수를 표시하는 카드입니다.

  • UsersIcon을 활용하여 아이콘을 추가
  • text-5xl font-bold로 강조된 숫자 스타일링
  • Button을 사용해 전체 목록 페이지로 이동

 

3. 팀 리더 목록 렌더링

<Card>
  <CardHeader className="pb-2">
    <CardTitle className="text-base flex justify-between items-center">
      <span>팀 리더</span>
      <StarIcon className="text-yellow-500" />
    </CardTitle>
  </CardHeader>
  <CardContent className="flex flex-wrap gap-2">
    {teamLeaders.map((teamLeader) => (
      <TooltipProvider key={`${teamLeader.firstName}${teamLeader.lastName}`}>
        <Tooltip>
          <TooltipTrigger asChild>
            <Avatar>
              {teamLeader.avatar ? (
                <Image src={teamLeader.avatar} width={40} height={40} alt={`${teamLeader.firstName} ${teamLeader.lastName}`} />
              ) : (
                <AvatarFallback>
                  {teamLeader.firstName[0]}{teamLeader.lastName[0]}
                </AvatarFallback>
              )}
            </Avatar>
          </TooltipTrigger>
          <TooltipContent>
            {teamLeader.firstName} {teamLeader.lastName}
          </TooltipContent>
        </Tooltip>
      </TooltipProvider>
    ))}
  </CardContent>
</Card>

이 코드는 팀 리더들의 아바타 및 정보를 렌더링합니다.

  • TooltipProvider를 사용하여 팀 리더의 이름을 툴팁으로 제공
  • Avatar를 활용해 팀 리더의 사진 또는 이니셜을 표시
  • StarIcon을 추가해 팀 리더 강조

 

4. 데이터 표시 및 스타일링

팀 리더의 목록이 많을 경우 flex-wrap을 사용하여 자동으로 줄바꿈 처리합니다.

<CardContent className="flex flex-wrap gap-2">

5. 추가 기능: 툴팁과 아이콘

  • TooltipProvider와 TooltipTrigger를 활용하여 아바타 위에 마우스를 올리면 이름이 표시되도록 설정
  • AvatarFallback을 활용하여 아바타가 없는 경우 이름의 첫 글자를 표시
  • StarIcon을 추가하여 팀 리더 카드 제목에 강조 효과 적용

 

 

 

 

 

 

 

 

30.팀 리더 분포 차트 (Team Distribution Chart)

1. 개요

이 프로젝트에서는 Next.js 15의 앱 라우터 방식을 사용하고, UI 구성에는 shadcn을 활용하여 대시보드에서 팀 리더 및 팀 통계를 시각적으로 표현하는 기능을 구현합니다. 이를 위해 Recharts를 이용한 원형 차트, 아바타 표시, 툴팁(tooltip) 제공 등의 기능을 추가합니다.

 

2. 주요 기능

2.1 팀 리더 분포 차트 (Team Distribution Chart)

팀 리더들의 분포를 원형 차트(PieChart)로 시각화하는 기능을 구현합니다.

"use client";

import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";

export default function TeamDistributionChart() {
  const data = [
    {
      name: "홍길동",
      value: 55,
      color: "#84cc16",
    },
    {
      name: "이순신",
      value: 34,
      color: "#3b82f6",
    },
    {
      name: "일지매",
      value: 11,
      color: "#f97316",
    },
  ];
  return (
    <ResponsiveContainer width="100%" height={150}>
      <PieChart>
        <Tooltip
          labelClassName="font-bold"
          wrapperClassName="dark:[&_.recharts-tooltip-item]:!text-white [&_.recharts-tooltip-item]:!text-black !text-sm dark:!bg-black rounded-md dark:!border-border"
        />
        <Pie data={data} dataKey="value" nameKey="name">
          {data.map((dataItem, i) => (
            <Cell key={i} fill={dataItem.color} />
          ))}
        </Pie>
      </PieChart>
    </ResponsiveContainer>
  );
}

 

2.2 팀 리더 카드 UI 구현

팀 리더 카드에는 다음과 같은 요소들이 포함됩니다.

  • 팀 리더의 이름, 성, 아바타(선택 사항)
  • 팀 리더의 아바타를 마우스로 가져가면 툴팁에 전체 이름 표시
  • 팀 리더 카드 제목 우측에 별 아이콘 추가

툴팁(tooltip) 적용

아바타에 툴팁을 추가하여 마우스를 올릴 때 팀 리더의 전체 이름을 보여줍니다.

별 아이콘 추가

카드 제목의 오른쪽에 별 아이콘을 추가하여 UI를 개선합니다.

import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Star } from "lucide-react";

function TeamLeaderCard({ leader }) {
  return (
    <div className="card">
      <div className="flex justify-between items-center">
        <span>{leader.name}</span>
        <Star className="text-yellow-500" />
      </div>
      <Tooltip>
        <TooltipTrigger>
          <Avatar>
            <AvatarImage src={leader.avatar} alt={`${leader.name}의 아바타`} />
            <AvatarFallback>{leader.initials}</AvatarFallback>
          </Avatar>
        </TooltipTrigger>
        <TooltipContent>{leader.name}</TooltipContent>
      </Tooltip>
    </div>
  );
}

 

3. 결과

이제 대시보드에서 팀 리더들의 분포를 원형 차트로 시각화할 수 있으며, 각 팀 리더의 정보를 아바타 및 툴팁과 함께 효과적으로 제공할 수 있습니다. 또한, 팀 리더 카드의 UI가 개선되어 직관적으로 팀 정보를 파악할 수 있습니다.

 

 

 

 

 

 

 

 

31.지원 티켓 해결 선 그래프 추가

 

 

2. 주요 기능

  • 지원 티켓 해결 선 그래프 추가
  • 팀 통계 페이지 구성
  • Recharts를 활용한 데이터 시각화

 

3. 코드 분석

3.1. 클라이언트 컴포넌트 설정

Recharts는 클라이언트에서만 실행되므로, 컴포넌트를 클라이언트 컴포넌트로 지정해야 합니다.

"use client";

 

3.2. Recharts를 활용한 선 그래프 구현

import {
  CartesianGrid,
  Legend,
  Line,
  LineChart,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from "recharts";

export default function SupportTicketsResolved() {
  const data = [
    { name: "Jan", delta: 40, alpha: 24, canary: 24 },
    { name: "Feb", delta: 30, alpha: 13, canary: 22 },
    { name: "Mar", delta: 20, alpha: 58, canary: 29 },
    { name: "Apr", delta: 14, alpha: 30, canary: 15 },
    { name: "May", delta: 29, alpha: 28, canary: 18 },
    { name: "Jun", delta: 19, alpha: 19, canary: 10 },
    { name: "Jul", delta: 34, alpha: 24, canary: 14 },
    { name: "Aug", delta: 21, alpha: 20, canary: 19 },
    { name: "Sep", delta: 49, alpha: 43, canary: 20 },
    { name: "Oct", delta: 43, alpha: 55, canary: 4 },
    { name: "Nov", delta: 39, alpha: 40, canary: 25 },
    { name: "Dec", delta: 34, alpha: 43, canary: 11 },
  ];

  return (
    <ResponsiveContainer height={350} width="100%">
      <LineChart data={data}>
        <Tooltip labelClassName="font-bold" wrapperClassName="!text-sm dark:!bg-black rounded-md dark:!border-border" />
        <XAxis fontSize={12} dataKey="name" stroke="#888888" />
        <YAxis fontSize={12} stroke="#888888" />
        <CartesianGrid strokeDasharray="3 3" />
        <Line type="monotone" dataKey="delta" stroke="#84cc16" strokeWidth={2} dot={{ r: 4 }} />
        <Line type="monotone" dataKey="alpha" stroke="#3b82f6" strokeWidth={2} dot={{ r: 4 }} />
        <Line type="monotone" dataKey="canary" stroke="#f97316" strokeWidth={2} dot={{ r: 4 }} />
        <Legend formatter={(value) => <span className="capitalize">{value}</span>} />
      </LineChart>
    </ResponsiveContainer>
  );
}

 

4. 구현 과정

4.1. 데이터 구조 정의

12개월 동안 각 팀(Delta, Alpha, Canary)의 지원 티켓 해결 개수를 나타내는 JSON 배열을 생성합니다.

 

4.2. Recharts 컴포넌트 추가

  • Tooltip: 데이터 포인트에 마우스를 올릴 때 상세 정보 표시
  • CartesianGrid: 배경 격자 추가 (점선 스타일 적용)
  • XAxis, YAxis: 축 스타일 조정 및 라벨 설정
  • Line: 각 팀별 색상 및 데이터 연결 (Monotone 타입 적용, 두께 조정 및 점 크기 추가)
  • Legend: 범례 추가하여 팀별 색상 표시

 

5. 최적화 및 개선 사항

  • 색상 통일 및 가독성 개선: 각 팀별 색상을 명확히 구분
  • 폰트 크기 조정: 가독성 향상
  • 데이터 키 설정: X축에 월 이름을 표시하도록 설정
  • 선 두께 및 점 크기 조정: 시각적으로 더욱 명확하게 구분

 

 

 

 

 

 

 

5.모바일 화면 꾸미기

32.모바일 메뉴 꾸미기

 

 

 

1. 반응형 레이아웃 설정

먼저, CSS Grid를 사용하여 모바일과 데스크톱에서 다른 레이아웃을 적용하는 방법을 살펴보겠습니다.

<div className="grid md:grid-cols-[250px_1fr] min-h-screen">
  {/* 데스크톱에서는 사이드 메뉴가 보이고, 모바일에서는 숨김 */}
  <MainMenu className="hidden md:flex" />
  
  <div className='p-4 flex justify-between md:hidden sticky top-0 left-0 bg-background 
            border-b border-border'>
    <MenuTitle />
  </div>
  
  {/* 메인 콘텐츠 영역 */}
  <main className="p-4">컨텐츠 영역</main>
</div>

 

2. 코드 설명

  1. Grid 레이아웃 구성

    • grid md:grid-cols-[250px_1fr] → 모바일에서는 1열, 데스크톱에서는 250px 사이드바 + 나머지 영역을 차지하는 2열 구조 적용.

  2. 사이드 메뉴 (MainMenu)

    • hidden md:flex → 모바일에서는 숨기고(md:hidden), 데스크톱(md)이 되면 보이도록(md:flex) 설정.

  3. 모바일 전용 헤더 (MenuTitle)

    • md:hidden → 모바일에서만 보이게 설정. 데스크톱에서는 숨김.

    • sticky top-0 left-0 → 모바일에서는 스크롤해도 항상 상단에 고정되도록(sticky) 설정.

    • border-b border-border → 하단에 구분선을 추가하여 가독성을 높임.

 

 

 

 

dashboard/layout.tsx

import React from 'react'
import MainMenu from './components/main-menu'
import MenuTitle from './components/menu-title'

interface DashboardLayoutProps {
    children: React.ReactNode
}

const DashboardLayout:React.FC<DashboardLayoutProps> = ({children}) => {
  return (
    <div className='grid md:grid-cols-[250px_1fr]  px-3 md:px-0  h-screen'>
     
        <MainMenu  className="hidden md:flex" />
        
        <div className='p-4 flex justify-between   md:hidden sticky top-0 left-0 bg-background 
            border-b border-border'>
           <MenuTitle />       
        </div> 

        <div className='overflow-auto py-2 px-6'>
            <h1 className='pb-4 text-2xl font-bold'>환영합니다. 홍길동님!</h1>
            {children}
        </div>        
    </div>
  )
}

export default DashboardLayout

 

타일윈드 cn 클래스  머지 라이브러리를  이용해서  className  props 전달해 주도록 변경합니다.

import React from "react";
import MenuTitle from "./menu-title";
import MenuItem from "./menu-item";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import Link from "next/link";
import ThemeToggle from "@/components/ThemeToggle";
import {cn} from "@/lib/utils";

interface MainMenuProps {
  className?: string; 
}

const MainMenu: React.FC<MainMenuProps> = ({className}) => {
  return (
    <nav className={cn(`bg-muted overflow-auto p-4 flex flex-col`,className)}  >

      <header className="border-b dark:border-b-black border-b-zinc-300  pb-4">
        <MenuTitle />
      </header>

      <ul className="py-4 grow flex flex-col gap-1">
        <MenuItem href="/dashboard">대시보드</MenuItem>
        <MenuItem href="/dashboard/teams">팀</MenuItem>
        <MenuItem href="/dashboard/employee">직원</MenuItem>
        <MenuItem href="/dashboard/account">시용자정보</MenuItem>
        <MenuItem href="/dashboard/settings">설정</MenuItem>
      </ul>

      <footer className="flex gap-2 items-center">
        <Avatar>
          <AvatarFallback className="bg-pink-300 dark:bg-pink-800">
            {" "}
            TP{" "}
          </AvatarFallback>
        </Avatar>
        <Link href="/" className="hover:underline">
          로그아웃
        </Link>
        <ThemeToggle className="ml-auto" />
      </footer>
    </nav>
  );
};

export default MainMenu;

 

 

33.햄버그 메뉴 추가하기

다음을 참조

https://macaronics.net/m04/react/view/2386

 

 

 

 

 

34. Shadcn Skeleton 컴포넌트로 로딩 UI 구현

직원 페이지 로딩 UI 구현

1. 목표

  • 직원 데이터를 로딩하는 동안 Skeleton 컴포넌트를 활용해 사용자 경험을 향상
  • Next.js의 UI 스트리밍을 이용하여 페이지가 먼저 로드되고, 데이터가 준비될 때까지 Skeleton을 표시
  • 기본적인 데이터 테이블을 만들고 페이지네이션 기능 추가 예정

 

2. Skeleton 설치

  • 프로젝트에서 shadcn의 Skeleton 컴포넌트를 사용하려면 먼저 shadcn/ui를 설치해야 합니다.
  • 설치 명령어:
npx shadcn-ui@latest add skeleton
  • 설치 후 @/components/ui/skeleton 경로에서 Skeleton 컴포넌트를 불러와 사용합니다.

 

3. 구현 방식

(1) Skeleton 컴포넌트 활용

  • shadcn의 Skeleton 컴포넌트를 사용하여 테이블 레이아웃을 모방
  • 직원 정보가 로딩되는 동안 Skeleton을 표시하여 부드러운 사용자 경험 제공

(2) Next.js UI 스트리밍 적용

  • loading.tsx 파일을 생성하여 직원 데이터가 로딩되는 동안 Skeleton UI를 표시
  • page.tsx에서 setTimeout을 사용하여 로딩을 모방 (5초 대기 후 데이터 렌더링)

 

4. 코드 구현

(1) loading.tsx - Skeleton UI 렌더링

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import React from 'react';

const Loading = () => {
  return (
    <Card>
        <CardHeader className='pb-3'>
            <CardTitle className='text-base'>직원</CardTitle>                
        </CardHeader>
        <CardContent className='grid grid-cols-[60px_1fr_1fr_1fr_1fr] gap-4 items-center'>
            {[...Array(2)].map((_, index) => (
              <React.Fragment key={index}>
                <Skeleton className='size-10 rounded-full ' />
                <Skeleton className='h-8 w-full ' />
                <Skeleton className='h-8 w-full ' />
                <Skeleton className='h-8 w-full ' />
                <Skeleton className='h-8 w-full' />
              </React.Fragment>
            ))}
        </CardContent>
    </Card>
  );
};

export default Loading;

 

(2) page.tsx - 직원 데이터 로딩 처리

import React from 'react';
import { setTimeout } from 'timers/promises';

const EmployeePage: React.FC = async () => {
  await setTimeout(5000);
  
  return (
    <div>
      <h2>직원</h2>
      {/* 여기에 실제 직원 데이터 테이블 추가 예정 */}
    </div>
  );
};

export default EmployeePage;

 

5. 결과 및 기대 효과

✅ 페이지가 로드될 때 Skeleton이 먼저 나타나고, 데이터가 준비되면 자동으로 교체됨

✅ UI 스트리밍을 활용하여 페이지 자체는 빠르게 로드됨

✅ 사용자 경험을 고려한 로딩 처리 방식 적용 완료

 

 

 

 

★data table 데이터 테이블 설정

다음 참조 :  https://macaronics.net/m04/react/view/2388

 

 

35. 직원 데이터 테이블 만들기 -data table

 

https://ui.shadcn.com/docs/components/data-table

 

설치 :

pnpm dlx shadcn@latest add table
pnpm add @tanstack/react-table

 

디렉토리 구조

src
└── app
    └── dashboard
        ├── employee
        │   ├── page.tsx
        │   ├── loading.tsx
        │   ├── columns.tsx
        └── components
            └── employees
                └── employees-data-table.tsx

 

  • page.tsx → 직원 대시보드의 메인 페이지
  • loading.tsx → 직원 페이지에서 로딩 상태 처리
  • columns.tsx → 테이블의 컬럼 정의
  • employees-data-table.tsx → 직원 데이터를 테이블로 렌더링하는 컴포넌트

현재 여기 data-table.tsx 코드는 특별히 변경없이   ui.shadcn.com 에서 제공하는 기본 소스를 사용했습니다.

 

1) src/app/dashboard/employee/page.tsx

import React from "react";
import { setTimeout } from "timers/promises";
import { type EmployeeType ,columns } from "./columns";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DataTable } from "../components/employees/employees-data-table";


const EmployeePage: React.FC = async () => {
  await setTimeout(500);

  const employees:EmployeeType[] = [
    {
      id: 1,
      firstName: "Colin",
      lastName: "Murray",
      teamName: "alpha",
      isTeamLeader: true,
      avatar: "/images/cm.jpg",
    },
    {
      id: 2,
      firstName: "Tom",
      lastName: "Phillips",
      teamName: "alpha",
      isTeamLeader: false,
    },
    {
      id: 3,
      firstName: "Liam",
      lastName: "Fuentes",
      teamName: "alpha",
      isTeamLeader: false,
    },
    {
      id: 4,
      firstName: "Tina",
      lastName: "Fey",
      teamName: "canary",
      isTeamLeader: true,
      avatar: "/images/tf.jpg",
    },
    {
      id: 5,
      firstName: "Katie",
      lastName: "Johnson",
      teamName: "canary",
      isTeamLeader: false,
    },
    {
      id: 6,
      firstName: "Tina",
      lastName: "Jones",
      teamName: "canary",
      isTeamLeader: false,
    },
    {
      id: 7,
      firstName: "Amy",
      lastName: "Adams",
      teamName: "delta",
      isTeamLeader: true,
    },
    {
      id: 8,
      firstName: "Ryan",
      lastName: "Lopez",
      teamName: "delta",
      isTeamLeader: false,
      avatar: "/images/rl.jpg",
    },
    {
      id: 9,
      firstName: "Jenny",
      lastName: "Jones",
      teamName: "delta",
      isTeamLeader: false,
    },
  ];
  
  return (
  
    <Card>
      <CardHeader>
        <CardTitle>직원 테이블</CardTitle>
      </CardHeader>
      <CardContent >
        <DataTable columns={columns} data={employees} />
      </CardContent>
    </Card>

  );

};

export default EmployeePage;

 

2)src/app/dashboard/employee/loading.tsx

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import React from 'react';

const Loading = () => {
  return (
    <Card>
        <CardHeader className='pb-3'>
            <CardTitle className='text-base'>직원</CardTitle>                
        </CardHeader>
        <CardContent className='grid grid-cols-[60px_1fr_1fr_1fr_1fr] gap-4 items-center'>
            {[...Array(2)].map((_, index) => (
              <React.Fragment key={index}>
                <Skeleton className='size-10 rounded-full ' />
                <Skeleton className='h-8 w-full ' />
                <Skeleton className='h-8 w-full ' />
                <Skeleton className='h-8 w-full ' />
                <Skeleton className='h-8 w-full' />
              </React.Fragment>
            ))}
        </CardContent>
    </Card>
  );
};

export default Loading;

 

3)src/app/dashboard/employee/columns.tsx

"use client"

import { ColumnDef } from "@tanstack/react-table"


export type EmployeeType={
    id: number | string;
    firstName: string;
    lastName: string;
    teamName: string;
    isTeamLeader: boolean;
    avatar?: undefined | string;
}


export const columns: ColumnDef<EmployeeType>[] = [
  {
    accessorKey: "avatar",
    header: "",
  },
  {
    accessorKey: "firstName",
    header: "First Name",
  },
  {
    accessorKey: "lastName",
    header: "Last Name",
  },
  {
    accessorKey: "teamName",
    header: "Team Name",
  },
  {
    accessorKey: "isTeamLeader",
    header: "Team Leader",
  },

]

 

 

 

 

 

 

 

36. ShadCN/UI 데이터 테이블 페이징 적용 가이드

 

4) src/app/dashboard/components/employees/employees-data-table.tsx

"use client";

import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  getPaginationRowModel,
  useReactTable,
} from "@tanstack/react-table";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

import {
  ChevronFirstIcon,
  ChevronLastIcon,
  ChevronLeftIcon,
  ChevronRightIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
}

export function DataTable<TData, TValue>({
  columns,
  data,
}: DataTableProps<TData, TValue>) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    initialState: {
      pagination: {
        pageSize: 5,
      },
    },
  });

  return (
    <div>
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => {
                return (
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </TableHead>
                );
              })}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows?.length ? (
            table.getRowModel().rows.map((row) => (
              <TableRow
                key={row.id}
                data-state={row.getIsSelected() && "selected"}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className="h-24 text-center">
                No results.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
      <div className="flex items-center space-x-2 justify-end pt-10">
        <Button
          variant="outline"
          onClick={() => table.setPageIndex(0)}
          disabled={!table.getCanPreviousPage()}
        >
          <span className="sr-only">Go to first page</span>
          <ChevronFirstIcon className="h-4 w-4" />
        </Button>
        <Button
          variant="outline"
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          <span className="sr-only">Go to previous page</span>
          <ChevronLeftIcon className="h-4 w-4" />
        </Button>
        <Button
          variant="outline"
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          <span className="sr-only">Go to next page</span>
          <ChevronRightIcon className="h-4 w-4" />
        </Button>
        <Button
          variant="outline"
          onClick={() => table.setPageIndex(table.getPageCount() - 1)}
          disabled={!table.getCanNextPage()}
        >
          <span className="sr-only">Go to last page</span>
          <ChevronLastIcon className="h-4 w-4" />
        </Button>
      </div>
    </div>
  );
}

 

 

소개

이 튜토리얼에서는 @tanstack/react-table과 shadcn/ui를 사용하여 데이터 테이블에 페이징 기능을 추가하는 방법을 설명합니다.

목표는 한 번에 표시되는 행 수를 제한하고, 페이지 번호 표시페이지 크기 선택 기능을 추가하여 사용자 경험을 개선하는 것입니다.

데이터 테이블 페이징 설정

1. useReactTable에서 페이징 초기화

@tanstack/react-table의 useReactTable 훅을 사용하여 테이블을 설정하고, getPaginationRowModel()을 추가하여 페이징을 활성화합니다.

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  initialState: {
    pagination: {
      pageSize: 5,
    },
  },
});
  • data: 표시할 데이터셋
  • columns: 테이블 컬럼 정의
  • getCoreRowModel(): 기본 행 모델 가져오기
  • getPaginationRowModel(): 페이징 활성화
  • initialState.pagination.pageSize: 페이지당 5개 행 제한

이 설정을 통해 대량의 데이터를 효율적으로 페이징할 수 있습니다.

 

 

2. 테이블 헤더 및 행 렌더링

getHeaderGroups()를 사용하여 동적으로 테이블 헤더를 생성합니다.

<TableHeader>
  {table.getHeaderGroups().map((headerGroup) => (
    <TableRow key={headerGroup.id}>
      {headerGroup.headers.map((header) => (
        <TableHead key={header.id}>
          {flexRender(header.column.columnDef.header, header.getContext())}
        </TableHead>
      ))}
    </TableRow>
  ))}
</TableHeader>

테이블 행은 getRowModel().rows를 활용하여 동적으로 렌더링합니다.

<TableBody>
  {table.getRowModel().rows.length ? (
    table.getRowModel().rows.map((row) => (
      <TableRow key={row.id}>
        {row.getVisibleCells().map((cell) => (
          <TableCell key={cell.id}>
            {flexRender(cell.column.columnDef.cell, cell.getContext())}
          </TableCell>
        ))}
      </TableRow>
    ))
  ) : (
    <TableRow>
      <TableCell colSpan={columns.length} className="h-24 text-center">
        결과가 없습니다.
      </TableCell>
    </TableRow>
  )}
</TableBody>
  • 데이터가 있으면 getRowModel().rows를 순회하며 행을 생성
  • 데이터가 없으면 "결과가 없습니다." 메시지를 표시

 

 

3. 페이징 컨트롤 추가

lucide-react 아이콘을 사용하여 페이지 이동 버튼을 추가합니다.

<div className="flex items-center space-x-2 justify-end pt-10">
  <Button
    variant="outline"
    onClick={() => table.setPageIndex(0)}
    disabled={!table.getCanPreviousPage()}
  >
    <ChevronFirstIcon className="h-4 w-4" />
  </Button>
  <Button
    variant="outline"
    onClick={() => table.previousPage()}
    disabled={!table.getCanPreviousPage()}
  >
    <ChevronLeftIcon className="h-4 w-4" />
  </Button>
  <span className="px-2">
    페이지 {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
  </span>
  <Button
    variant="outline"
    onClick={() => table.nextPage()}
    disabled={!table.getCanNextPage()}
  >
    <ChevronRightIcon className="h-4 w-4" />
  </Button>
  <Button
    variant="outline"
    onClick={() => table.setPageIndex(table.getPageCount() - 1)}
    disabled={!table.getCanNextPage()}
  >
    <ChevronLastIcon className="h-4 w-4" />
  </Button>
</div>
  • 현재 페이지 번호 표시 (table.getState().pagination.pageIndex + 1)
  • 첫 페이지 이동 (setPageIndex(0)) 버튼
  • 이전 페이지 이동 (previousPage()) 버튼
  • 다음 페이지 이동 (nextPage()) 버튼
  • 마지막 페이지 이동 (setPageIndex(table.getPageCount() - 1)) 버튼
  • 첫 페이지일 경우 disabled={!table.getCanPreviousPage()} 비활성화
  • 마지막 페이지일 경우 disabled={!table.getCanNextPage()} 비활성화

 

4. 페이지 크기 선택 기능 추가

사용자가 한 페이지에 표시할 행 개수를 선택할 수 있도록 설정합니다.

<div className="flex items-center space-x-2 pt-4">
  <span>페이지당 행 수:</span>
  <select
    className="border p-1 rounded"
    value={table.getState().pagination.pageSize}
    onChange={(e) => table.setPageSize(Number(e.target.value))}
  >
    {[5, 10, 20, 50].map((size) => (
      <option key={size} value={size}>
        {size}
      </option>
    ))}
  </select>
</div>
  • 5, 10, 20, 50개 행 표시 옵션 제공
  • 선택 시 table.setPageSize(Number(e.target.value))를 호출하여 행 개수 변경

UI 및 반응형 개선 사항

  1. 페이징 컨트롤 위치 조정
    • 하단 **오른쪽 정렬 (justify-end pt-10)**으로 배치
  2. 테이블 외곽선 제거
    • UI를 깔끔하게 유지하기 위해 불필요한 테두리 제거
  3. 버튼 항상 표시
    • 작은 화면에서도 버튼이 사라지지 않도록 hidden 클래스 제거
  4. 페이지 크기 선택 기능 추가
    • 사용자 맞춤형 행 표시 설정 가능
    •  

 

 

 

 

 

 

37. ShadCN UI Data Table 컬럼 변경 방법

 

2. 데이터 테이블 구성 요소

1) Employee 타입 정의

먼저 Employee 타입을 정의하여 데이터의 구조를 명확히 합니다.

export type Employee = {
  id: number;
  firstName: string;
  lastName: string;
  teamName: string;
  isTeamLeader: boolean;
  avatar?: string;
};
  • 직원의 id, firstName, lastName, teamName, isTeamLeader 및 avatar URL을 포함합니다.

 

2) 컬럼 정의 (columns.tsx)

테이블의 컬럼을 정의하는 columns.tsx 파일에서 각 컬럼의 액세스 키(accessorKey)와 header를 설정합니다.

export const columns: ColumnDef<Employee>[] = [
  {
    accessorKey: "avatar",
    header: "",
    cell: ({ row }) => {
      const avatar: string = row.getValue("avatar");
      const firstName: string = row.getValue("firstName");
      const lastName: string = row.getValue("lastName");
      
      return (
        <Avatar>
          {!!avatar && (
            <Image
              height={40}
              width={40}
              src={avatar}
              alt={`${firstName} ${lastName} avatar`}
            />
          )}
          <AvatarFallback className="uppercase">
            {firstName[0]}{lastName[0]}
          </AvatarFallback>
        </Avatar>
      );
    },
  },
  {
    accessorKey: "firstName",
    header: "First name",
  },
  {
    accessorKey: "lastName",
    header: "Last name",
  },
  {
    accessorKey: "teamName",
    header: "Team",
  },
  {
    accessorKey: "isTeamLeader",
    header: "",
    cell: ({ row }) => {
      const isTeamLeader: boolean = row.getValue("isTeamLeader");
      return isTeamLeader ? <Badge variant="success">Team Leader</Badge> : null;
    },
  },
];

설명:

  • avatar 컬럼: 직원의 프로필 사진을 표시하며, 사진이 없을 경우 이니셜을 표시합니다.
  • firstName, lastName, teamName 컬럼: 기본 텍스트 값을 표시합니다.
  • isTeamLeader 컬럼: 팀 리더인 경우 Team Leader 배지를 표시합니다.

 

3. ShadCN UI의 Badge 컴포넌트 확장

 

설치:

https://ui.shadcn.com/docs/components/badge

pnpm dlx shadcn@latest add badge

 

기본적으로 ShadCN UI에는 success 배지 스타일이 존재하지 않으므로, badge.tsx 파일에서 직접 추가합니다.

const badgeVariants = cva(
  "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
  {
    variants: {
      variant: {
        default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
        success: "bg-green-400 text-black border-transparent",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
);

export function Badge({ className, variant, ...props }: BadgeProps) {
  return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}

설명:

  • success 변형 스타일을 추가하여 배경을 녹색(bg-green-400), 글자를 검은색(text-black)으로 지정합니다.
  • border-transparent를 사용하여 테두리가 보이지 않도록 설정합니다.

 

 

 

 

 

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

악을 행한 자는 두 번 뉘우친다. 이승에서 뉘우치고, 저승에서 뉘우치고. 악을 행한 자는 두 번 번민한다. 악을 행했다는 생각에 번민하고, 벌받을 생각에 번민하고. 악을 행한 자는 두 번 고통받는다. 이승에서 고통받고, 저승에서 고통받고. 그러므로, 어떠한 경우에 있어서도 악을 행해서는 안 된다. 이를 명심하자. -법구경

댓글 ( 0)

댓글 남기기

작성