diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index e8a4e04c..bacdc183 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -3,8 +3,12 @@ services: plm-backend: build: context: ../../backend-node - dockerfile: ../docker/prod/backend.Dockerfile - container_name: plm-backend + dockerfile: ../docker/prod/backend.Dockerfile # 운영용 Dockerfile + container_name: pms-backend-prod + ports: + - "8080:8080" # 호스트:컨테이너 포트 매핑 + networks: + - pms-network environment: - NODE_ENV=production - PORT=8080 @@ -25,6 +29,5 @@ services: start_period: 60s networks: - default: - name: toktork_server_default - external: true + pms-network: + external: true # 외부에서 생성된 네트워크 사용 diff --git a/docs/shadcn-ui-완전가이드.md b/docs/shadcn-ui-완전가이드.md new file mode 100644 index 00000000..0c575e76 --- /dev/null +++ b/docs/shadcn-ui-완전가이드.md @@ -0,0 +1,1215 @@ +# shadcn/ui 완전 구현 가이드 + +> 이 문서는 shadcn/ui 공식 문서(https://ui.shadcn.com)를 철저히 분석하여 작성되었습니다. + +## 목차 + +1. [설치 및 초기 설정](#1-설치-및-초기-설정) +2. [CSS 변수 및 테마 설정](#2-css-변수-및-테마-설정) +3. [컴포넌트별 구현 가이드](#3-컴포넌트별-구현-가이드) +4. [문제 해결 (Troubleshooting)](#4-문제-해결-troubleshooting) + +--- + +## 1. 설치 및 초기 설정 + +### 1.1 필수 패키지 설치 + +```bash +npm install tailwindcss@latest +npm install class-variance-authority clsx tailwind-merge +npm install @radix-ui/react-* # 필요한 Radix UI 컴포넌트들 +npm install lucide-react # 아이콘 +``` + +### 1.2 Tailwind CSS 설정 + +**중요**: shadcn/ui는 Tailwind CSS v4를 사용할 수 있지만, 기본적으로 v3 스타일을 따릅니다. + +```js +// tailwind.config.js (v3 스타일) +module.exports = { + darkMode: ["class"], + content: [ + "./pages/**/*.{ts,tsx}", + "./components/**/*.{ts,tsx}", + "./app/**/*.{ts,tsx}", + "./src/**/*.{ts,tsx}", + ], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; +``` + +**Tailwind CSS v4 스타일 (@theme 사용):** + +```css +/* app/globals.css */ +@import "tailwindcss"; + +@theme { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + /* ... 나머지 색상 */ +} +``` + +### 1.3 globals.css 필수 설정 + +```css +/* app/globals.css */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} +``` + +**중요한 점:** + +1. CSS 변수는 **HSL 형식**으로 작성 (예: `222.2 47.4% 11.2%`) +2. `hsl()` 함수는 **사용하지 않음** (Tailwind가 자동으로 추가) +3. 공백으로 구분 (쉼표 없음) + +### 1.4 유틸리티 함수 (lib/utils.ts) + +```typescript +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +이 함수는 **모든 shadcn/ui 컴포넌트에서 필수**입니다. + +--- + +## 2. CSS 변수 및 테마 설정 + +### 2.1 색상 시스템 이해 + +shadcn/ui는 **시맨틱 색상 시스템**을 사용합니다: + +| 색상 변수 | 용도 | 예시 | +| ---------------------- | ------------------------- | ---------------------------- | +| `--background` | 페이지 배경 | 흰색 (라이트), 검은색 (다크) | +| `--foreground` | 기본 텍스트 | 검은색 (라이트), 흰색 (다크) | +| `--primary` | 주요 액션 (버튼, 선택 등) | 진한 네이비/검은색 | +| `--primary-foreground` | Primary 위의 텍스트 | 흰색 | +| `--secondary` | 보조 액션 | 연한 회색 | +| `--accent` | 강조, 호버 효과 | 연한 파란색 | +| `--muted` | 비활성/보조 배경 | 밝은 회색 | +| `--muted-foreground` | 보조 텍스트 (설명 등) | 중간 회색 | +| `--destructive` | 삭제/에러 액션 | 빨간색 | +| `--border` | 테두리 | 밝은 회색 | +| `--input` | 입력 필드 테두리 | 밝은 회색 | +| `--ring` | 포커스 링 | 검은색 | + +### 2.2 HSL vs OKLCH 주의사항 + +**공식 shadcn/ui는 HSL 형식을 사용합니다:** + +```css +/* ✅ 올바른 방법 (HSL) */ +--primary: 222.2 47.4% 11.2%; + +/* ❌ 잘못된 방법 (OKLCH) */ +--primary: oklch(0.205 0 0); +``` + +만약 OKLCH를 사용하려면: + +1. 모든 컴포넌트에서 `hsl()` → `oklch()` 변경 필요 +2. Tailwind 설정 수정 필요 +3. 공식 문서와 다른 색상으로 보일 수 있음 + +**권장: 공식 HSL 형식 그대로 사용** + +### 2.3 Border Radius 설정 + +```css +:root { + --radius: 0.5rem; /* 8px, 공식 기본값 */ +} +``` + +컴포넌트에서 사용: + +- `rounded-lg`: `var(--radius)` = 8px +- `rounded-md`: `calc(var(--radius) - 2px)` = 6px +- `rounded-sm`: `calc(var(--radius) - 4px)` = 4px + +--- + +## 3. 컴포넌트별 구현 가이드 + +### 3.1 Button 컴포넌트 + +```tsx +// components/ui/button.tsx +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 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; +``` + +**사용법:** + +```tsx +import { Button } from "@/components/ui/button"; + + + + + + + +``` + +### 3.2 Calendar 컴포넌트 (자체 제작 - 완벽 구현) + +#### 3.2.1 문제점 및 해결 방안 + +**react-day-picker v9 문제:** + +- 요일 헤더 간격이 이상하게 보임 (MoTuWeThFrSa) +- 클래스명이 변경되어 스타일 적용이 어려움 +- `captionLayout="dropdown"`이 제대로 작동하지 않음 + +**해결책: 자체 Calendar 컴포넌트 제작** + +- shadcn/ui 스타일을 완벽하게 재현 +- 월/연도 드롭다운 선택 기능 내장 +- 크기 조절 가능 (`sm`, `default`, `lg`) + +#### 3.2.2 패키지 설치 + +```bash +# shadcn/ui 기본 컴포넌트만 필요 +npm install lucide-react +# react-day-picker는 불필요! +``` + +#### 3.2.2 컴포넌트 코드 + +**파일 위치:** `components/ui/custom-calendar.tsx` + +```tsx +"use client"; + +import * as React from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface CustomCalendarProps { + selected?: Date; + onSelect?: (date: Date | undefined) => void; + className?: string; + mode?: "single"; + size?: "sm" | "default" | "lg"; +} + +const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +export function CustomCalendar({ + selected, + onSelect, + className, + mode = "single", + size = "default", +}: CustomCalendarProps) { + // 크기별 클래스 정의 + const sizeClasses = { + sm: { + cell: "h-7 w-7 text-xs", + header: "text-xs", + day: "text-[0.7rem]", + nav: "h-6 w-6", + }, + default: { + cell: "h-9 w-9 text-sm", + header: "text-[0.8rem]", + day: "text-sm", + nav: "h-7 w-7", + }, + lg: { + cell: "h-11 w-11 text-base", + header: "text-sm", + day: "text-base", + nav: "h-8 w-8", + }, + }; + + const currentSize = sizeClasses[size]; + const [currentDate, setCurrentDate] = React.useState(selected || new Date()); + const [viewYear, setViewYear] = React.useState(currentDate.getFullYear()); + const [viewMonth, setViewMonth] = React.useState(currentDate.getMonth()); + + const getDaysInMonth = (year: number, month: number) => { + return new Date(year, month + 1, 0).getDate(); + }; + + const getFirstDayOfMonth = (year: number, month: number) => { + return new Date(year, month, 1).getDay(); + }; + + const generateCalendarDays = () => { + const daysInMonth = getDaysInMonth(viewYear, viewMonth); + const firstDay = getFirstDayOfMonth(viewYear, viewMonth); + const daysInPrevMonth = getDaysInMonth(viewYear, viewMonth - 1); + + const days: Array<{ + date: number; + month: "prev" | "current" | "next"; + fullDate: Date; + }> = []; + + // Previous month days + for (let i = firstDay - 1; i >= 0; i--) { + const date = daysInPrevMonth - i; + days.push({ + date, + month: "prev", + fullDate: new Date(viewYear, viewMonth - 1, date), + }); + } + + // Current month days + for (let i = 1; i <= daysInMonth; i++) { + days.push({ + date: i, + month: "current", + fullDate: new Date(viewYear, viewMonth, i), + }); + } + + // Next month days + const remainingDays = 42 - days.length; + for (let i = 1; i <= remainingDays; i++) { + days.push({ + date: i, + month: "next", + fullDate: new Date(viewYear, viewMonth + 1, i), + }); + } + + return days; + }; + + const handlePrevMonth = () => { + if (viewMonth === 0) { + setViewMonth(11); + setViewYear(viewYear - 1); + } else { + setViewMonth(viewMonth - 1); + } + }; + + const handleNextMonth = () => { + if (viewMonth === 11) { + setViewMonth(0); + setViewYear(viewYear + 1); + } else { + setViewMonth(viewMonth + 1); + } + }; + + const handleDateClick = (date: Date) => { + if (onSelect) { + onSelect(date); + } + }; + + const isToday = (date: Date) => { + const today = new Date(); + return ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ); + }; + + const isSelected = (date: Date) => { + if (!selected) return false; + return ( + date.getDate() === selected.getDate() && + date.getMonth() === selected.getMonth() && + date.getFullYear() === selected.getFullYear() + ); + }; + + const calendarDays = generateCalendarDays(); + + return ( +
+ {/* Header with Month/Year Dropdowns */} +
+ + +
+ {/* Month Select */} + + + {/* Year Select */} + +
+ + +
+ + {/* Days of week */} +
+ {DAYS.map((day) => ( +
+ {day} +
+ ))} +
+ + {/* Calendar grid */} +
+ {calendarDays.map((day, index) => { + const isOutside = day.month !== "current"; + const isTodayDate = isToday(day.fullDate); + const isSelectedDate = isSelected(day.fullDate); + + return ( + + ); + })} +
+
+ ); +} + +CustomCalendar.displayName = "CustomCalendar"; +``` + +#### 3.2.3 주요 기능 + +**1. 월/연도 드롭다운 선택** + +- 월: January ~ December 전체 선택 가능 +- 연도: 현재 연도 ±50년 범위 (총 100년) +- shadcn/ui Select 컴포넌트 사용 + +**2. 크기 조절 (`size` prop)** + +- `sm`: 28px × 28px 셀 (작은 크기) +- `default`: 36px × 36px 셀 (기본 크기) +- `lg`: 44px × 44px 셀 (큰 크기) + +**3. 완벽한 요일 간격** + +- `grid grid-cols-7`로 정확히 7개 컬럼 +- Su Mo Tu We Th Fr Sa 간격 완벽 + +**4. shadcn/ui 스타일** + +- 선택된 날짜: Primary 배경 (검은색) +- 오늘 날짜: Accent 배경 (연한 파란색) +- 외부 날짜: Muted 색상, 50% 투명도 +- 호버 효과: Ghost 버튼 스타일 + +#### 3.2.4 기본 사용법 + +```tsx +import { CustomCalendar } from "@/components/ui/custom-calendar"; +import { useState } from "react"; + +export function CalendarDemo() { + const [date, setDate] = useState(new Date()); + + return ( + + ); +} +``` + +#### 3.2.5 크기 조절 + +```tsx +{ + /* 작은 크기 */ +} +; + +{ + /* 기본 크기 */ +} +; + +{ + /* 큰 크기 */ +} +; +``` + +#### 3.2.6 Date Picker (Popover 결합) + +```tsx +import { CustomCalendar } from "@/components/ui/custom-calendar"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useState } from "react"; + +export function DatePickerDemo() { + const [date, setDate] = useState(); + + return ( + + + + + + + + + ); +} +``` + +**장점:** + +- ✅ 월/연도 드롭다운 선택 가능 +- ✅ 완벽한 요일 간격 +- ✅ shadcn/ui 스타일 완벽 재현 +- ✅ `date-fns` 불필요 (순수 JavaScript) + +### 3.3 Dialog (Modal) 컴포넌트 + +#### 3.3.1 패키지 설치 + +```bash +npm install @radix-ui/react-dialog +``` + +#### 3.3.2 컴포넌트 코드 + +```tsx +// components/ui/dialog.tsx +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; +``` + +#### 3.3.3 표준 사용 패턴 + +```tsx +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export function DialogDemo() { + return ( + + + + + + + 프로필 수정 + + 프로필 정보를 수정합니다. 완료되면 저장을 클릭하세요. + + +
+
+ + +
+
+ + +
+
+ + + +
+
+ ); +} +``` + +--- + +## 4. 문제 해결 (Troubleshooting) + +### 4.1 Calendar가 이상하게 보이는 경우 + +#### 증상 1: 드롭다운이 두 개로 보임 + +- **원인**: `react-day-picker` v8 스타일과 v9 스타일 혼용 +- **해결**: + ```tsx + // 올바른 v9 스타일 classNames 사용 + classNames={{ + caption: "flex justify-center pt-1 relative items-center", + caption_label: "text-sm font-medium", + // ... v9 클래스명 사용 + }} + ``` + +#### 증상 2: 선택된 날짜가 파란 테두리로 보임 + +- **원인**: `react-day-picker/style.css`를 import함 +- **해결**: + + ```css + /* ❌ 제거해야 함 */ + @import "react-day-picker/style.css"; + + /* ✅ shadcn/ui는 자체 Tailwind 스타일만 사용 */ + ``` + +#### 증상 3: 색상이 공식 문서와 다름 + +- **원인**: CSS 변수가 잘못 설정됨 +- **해결**: + + ```css + /* ✅ 올바른 HSL 형식 */ + --primary: 222.2 47.4% 11.2%; + + /* ❌ 잘못된 OKLCH 형식 */ + --primary: oklch(0.205 0 0); + ``` + +#### 증상 4: 드롭다운 화살표가 없음 + +- **원인**: `captionLayout="dropdown"`을 사용했지만 커스텀 CSS가 없음 +- **해결**: `react-day-picker` v9는 자동으로 네이티브 ` setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+ +
+ + {/* 대시보드 목록 */} + {loading ? ( +
+
+
로딩 중...
+
대시보드 목록을 불러오고 있습니다
+
+
+ ) : error ? ( +
+
+
+ +
+
+

데이터를 불러올 수 없습니다

+

{error}

+
+ +
+
+ ) : dashboards.length === 0 ? ( +
+
+

대시보드가 없습니다

+
+
+ ) : ( +
+ + + + 제목 + 설명 + 생성일 + 수정일 + 작업 + + + + {dashboards.map((dashboard) => ( + + {dashboard.title} + + {dashboard.description || "-"} + + + {formatDate(dashboard.createdAt)} + + + {formatDate(dashboard.updatedAt)} + + + + + + + + router.push(`/admin/dashboard/edit/${dashboard.id}`)} + className="gap-2 text-sm" + > + + 편집 + + handleCopy(dashboard)} className="gap-2 text-sm"> + + 복사 + + handleDeleteClick(dashboard.id, dashboard.title)} + className="text-destructive focus:text-destructive gap-2 text-sm" + > + + 삭제 + + + + + + ))} + +
+
+ )} + + {/* 페이지네이션 */} + {!loading && dashboards.length > 0 && ( + + )} + + {/* 삭제 확인 모달 */} + + + + 대시보드 삭제 + + "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? +
이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+ + ); +} diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index 16e2ed6a..8d78600c 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -1,307 +1,74 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { dashboardApi } from "@/lib/api/dashboard"; -import { Dashboard } from "@/lib/api/dashboard"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { useToast } from "@/hooks/use-toast"; -import { Pagination, PaginationInfo } from "@/components/common/Pagination"; -import { Plus, Search, Edit, Trash2, Copy, MoreVertical } from "lucide-react"; +import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient"; +import { cookies } from "next/headers"; /** - * 대시보드 관리 페이지 - * - 대시보드 목록 조회 - * - 대시보드 생성/수정/삭제/복사 + * 서버에서 초기 대시보드 목록 fetch */ -export default function DashboardListPage() { - const router = useRouter(); - const { toast } = useToast(); - const [dashboards, setDashboards] = useState([]); - const [loading, setLoading] = useState(true); - const [searchTerm, setSearchTerm] = useState(""); +async function getInitialDashboards() { + try { + // 서버 사이드 전용: 백엔드 API 직접 호출 + // 도커 네트워크 내부에서는 서비스 이름 사용, 로컬에서는 127.0.0.1 + const backendUrl = process.env.SERVER_API_URL || "http://backend:8080"; - // 페이지네이션 상태 - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [totalCount, setTotalCount] = useState(0); + // 쿠키에서 authToken 추출 + const cookieStore = await cookies(); + const authToken = cookieStore.get("authToken")?.value; - // 모달 상태 - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null); - - // 대시보드 목록 로드 - const loadDashboards = async () => { - try { - setLoading(true); - const result = await dashboardApi.getMyDashboards({ - search: searchTerm, - page: currentPage, - limit: pageSize, - }); - setDashboards(result.dashboards); - setTotalCount(result.pagination.total); - } catch (err) { - console.error("Failed to load dashboards:", err); - toast({ - title: "오류", - description: "대시보드 목록을 불러오는데 실패했습니다.", - variant: "destructive", - }); - } finally { - setLoading(false); + if (!authToken) { + // 토큰이 없으면 빈 데이터 반환 (클라이언트에서 로드) + return { + dashboards: [], + pagination: { total: 0, page: 1, limit: 10 }, + }; } - }; - useEffect(() => { - loadDashboards(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchTerm, currentPage, pageSize]); - - // 페이지네이션 정보 계산 - const paginationInfo: PaginationInfo = { - currentPage, - totalPages: Math.ceil(totalCount / pageSize), - totalItems: totalCount, - itemsPerPage: pageSize, - startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1, - endItem: Math.min(currentPage * pageSize, totalCount), - }; - - // 페이지 변경 핸들러 - const handlePageChange = (page: number) => { - setCurrentPage(page); - }; - - // 페이지 크기 변경 핸들러 - const handlePageSizeChange = (size: number) => { - setPageSize(size); - setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로 - }; - - // 대시보드 삭제 확인 모달 열기 - const handleDeleteClick = (id: string, title: string) => { - setDeleteTarget({ id, title }); - setDeleteDialogOpen(true); - }; - - // 대시보드 삭제 실행 - const handleDeleteConfirm = async () => { - if (!deleteTarget) return; - - try { - await dashboardApi.deleteDashboard(deleteTarget.id); - setDeleteDialogOpen(false); - setDeleteTarget(null); - toast({ - title: "성공", - description: "대시보드가 삭제되었습니다.", - }); - loadDashboards(); - } catch (err) { - console.error("Failed to delete dashboard:", err); - setDeleteDialogOpen(false); - toast({ - title: "오류", - description: "대시보드 삭제에 실패했습니다.", - variant: "destructive", - }); - } - }; - - // 대시보드 복사 - const handleCopy = async (dashboard: Dashboard) => { - try { - const fullDashboard = await dashboardApi.getDashboard(dashboard.id); - - await dashboardApi.createDashboard({ - title: `${fullDashboard.title} (복사본)`, - description: fullDashboard.description, - elements: fullDashboard.elements || [], - isPublic: false, - tags: fullDashboard.tags, - category: fullDashboard.category, - settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string }, - }); - toast({ - title: "성공", - description: "대시보드가 복사되었습니다.", - }); - loadDashboards(); - } catch (err) { - console.error("Failed to copy dashboard:", err); - toast({ - title: "오류", - description: "대시보드 복사에 실패했습니다.", - variant: "destructive", - }); - } - }; - - // 포맷팅 헬퍼 - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", + const response = await fetch(`${backendUrl}/api/dashboards/my?page=1&limit=10`, { + cache: "no-store", // 항상 최신 데이터 + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, // Authorization 헤더로 전달 + }, }); - }; - if (loading) { - return ( -
-
-
로딩 중...
-
대시보드 목록을 불러오고 있습니다
-
-
- ); + if (!response.ok) { + throw new Error(`Failed to fetch dashboards: ${response.status}`); + } + + const data = await response.json(); + return { + dashboards: data.data || [], + pagination: data.pagination || { total: 0, page: 1, limit: 10 }, + }; + } catch (error) { + console.error("Server-side fetch error:", error); + // 에러 발생 시 빈 데이터 반환 (클라이언트에서 재시도 가능) + return { + dashboards: [], + pagination: { total: 0, page: 1, limit: 10 }, + }; } +} + +/** + * 대시보드 관리 페이지 (서버 컴포넌트) + * - 페이지 헤더 + 초기 데이터를 서버에서 렌더링 + * - 클라이언트 컴포넌트로 초기 데이터 전달 + */ +export default async function DashboardListPage() { + const initialData = await getInitialDashboards(); return (
- {/* 페이지 헤더 */} + {/* 페이지 헤더 (서버에서 렌더링) */}

대시보드 관리

대시보드를 생성하고 관리할 수 있습니다

- {/* 검색 및 액션 */} -
-
- - setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" - /> -
- -
- - {/* 대시보드 목록 */} - {dashboards.length === 0 ? ( -
-
-

대시보드가 없습니다

-
-
- ) : ( -
- - - - 제목 - 설명 - 생성일 - 수정일 - 작업 - - - - {dashboards.map((dashboard) => ( - - {dashboard.title} - - {dashboard.description || "-"} - - - {formatDate(dashboard.createdAt)} - - - {formatDate(dashboard.updatedAt)} - - - - - - - - router.push(`/admin/dashboard/edit/${dashboard.id}`)} - className="gap-2 text-sm" - > - - 편집 - - handleCopy(dashboard)} className="gap-2 text-sm"> - - 복사 - - handleDeleteClick(dashboard.id, dashboard.title)} - className="text-destructive focus:text-destructive gap-2 text-sm" - > - - 삭제 - - - - - - ))} - -
-
- )} - - {/* 페이지네이션 */} - {!loading && dashboards.length > 0 && ( - - )} + {/* 나머지 컨텐츠 (클라이언트 컴포넌트 + 서버 데이터) */} +
- - {/* 삭제 확인 모달 */} - - - - 대시보드 삭제 - - "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? -
이 작업은 되돌릴 수 없습니다. -
-
- - 취소 - - 삭제 - - -
-
); } diff --git a/frontend/app/(main)/admin/ui-components-demo/page.tsx b/frontend/app/(main)/admin/ui-components-demo/page.tsx new file mode 100644 index 00000000..35c30b2d --- /dev/null +++ b/frontend/app/(main)/admin/ui-components-demo/page.tsx @@ -0,0 +1,837 @@ +"use client"; + +import { useState } from "react"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { CustomCalendar } from "@/components/ui/custom-calendar"; +import { ExampleFormDialog } from "@/components/examples/ExampleFormDialog"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Progress } from "@/components/ui/progress"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Slider } from "@/components/ui/slider"; +import { Switch } from "@/components/ui/switch"; +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { + AlertCircle, + Check, + ChevronDown, + Info, + Loader2, + MoreHorizontal, + Plus, + Search, + Trash2, + User, +} from "lucide-react"; +import { toast } from "sonner"; + +export default function UIComponentsDemoPage() { + const [date, setDate] = useState(new Date()); + const [progress, setProgress] = useState(45); + const [switchOn, setSwitchOn] = useState(false); + const [checkboxChecked, setCheckboxChecked] = useState(false); + const [sliderValue, setSliderValue] = useState([50]); + const [radioValue, setRadioValue] = useState("option1"); + + return ( +
+
+ {/* 헤더 */} +
+
+

shadcn/ui 컴포넌트 데모

+

프로젝트에서 사용 가능한 모든 UI 컴포넌트를 확인하세요

+
+ + {/* 실전 예시 폼 */} + + + + 🎯 + 실전 예시: 완전한 입력 폼 + + + 모든 shadcn/ui 컴포넌트를 활용한 완전한 폼 예시입니다. 유효성 검사, 에러 처리, 반응형 디자인이 모두 + 포함되어 있습니다. + + + + + + +
+ + {/* 버튼 섹션 */} + + + Button (버튼) + 다양한 스타일의 버튼 컴포넌트 + + +
+ + + + + + +
+ +
+ + + + +
+ +
+ + + +
+
+
+ + {/* Badge 섹션 */} + + + Badge (배지) + 상태 표시 및 태그용 배지 + + +
+ Default + Secondary + Destructive + Outline +
+
+
+ + {/* Alert 섹션 */} + + + Alert (알림) + 정보 표시용 알림 박스 + + + + + 기본 알림 + + 이것은 기본 알림 메시지입니다. 사용자에게 정보를 전달할 때 사용합니다. + + + + + 오류 발생 + 문제가 발생했습니다. 다시 시도해주세요. + + + + + {/* Input & Form 섹션 */} + + + Input & Form (입력 필드) + 폼 입력 컴포넌트들 + + + {/* Text Input */} +
+ + +
+ + {/* Textarea */} +
+ +