diff --git a/.eslintrc.json b/.eslintrc.json index 6b10a5b..2b5b39b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,7 @@ { "extends": [ "next/core-web-vitals", - "next/typescript" + "next/typescript", + "plugin:storybook/recommended" ] -} +} \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a0ea417 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,56 @@ +name: Deploy to Surge + +on: + push: + branches: + - develop + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Build Next.js app + run: pnpm run build + env: + NEXT_PUBLIC_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_API_BASE_URL }} + + - name: Build Storybook + run: pnpm run build-storybook + + - name: Deploy Next.js app to Surge + run: npx surge ./out kmu-sdbms-admin-page.surge.sh --token ${{ secrets.SURGE_TOKEN }} + + - name: Deploy Storybook to Surge + run: npx surge ./storybook-static kmu-sdbms-admin-storybook.surge.sh --token ${{ secrets.SURGE_TOKEN }} + + - name: Comment deployment URLs + uses: actions/github-script@v7 + if: github.event_name == 'push' + with: + script: | + const output = `### 🚀 배포 완료! + + - **메인 앱**: https://kmu-sdbms-admin-page.surge.sh + - **Storybook**: https://kmu-sdbms-admin-storybook.surge.sh + + 배포 시간: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`; + + console.log(output); diff --git a/.gitignore b/.gitignore index 2f7108a..6bf75b9 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,8 @@ next-env.d.ts .vscode/ -*task.md \ No newline at end of file +task.md +tasks.md +*storybook.log +storybook-static +*tokens.json \ No newline at end of file diff --git a/.storybook/README.md b/.storybook/README.md new file mode 100644 index 0000000..7c709f4 --- /dev/null +++ b/.storybook/README.md @@ -0,0 +1,208 @@ +# Storybook 설정 가이드 + +이 프로젝트의 Storybook 설정 및 사용법을 안내합니다. + +## 🚀 실행 방법 + +```bash +# 개발 모드로 Storybook 실행 +pnpm storybook + +# 정적 빌드 (배포용) +pnpm build-storybook +``` + +Storybook은 기본적으로 `http://localhost:6006`에서 실행됩니다. + +## 📁 프로젝트 구조 + +``` +.storybook/ +├── main.ts # Storybook 메인 설정 +├── preview.tsx # 전역 데코레이터 및 파라미터 +└── vitest.setup.ts # Vitest 설정 (테스트용) + +stories/ # 예제 스토리들 +components/ # 실제 프로젝트 컴포넌트 스토리 +└── ui/ + ├── button.stories.tsx + └── card.stories.tsx +``` + +## 🎨 주요 기능 + +### 1. 테마 지원 + +- **라이트/다크 모드**: ThemeProvider를 통한 자동 테마 전환 +- **시스템 테마 감지**: 운영체제 설정에 따른 자동 테마 적용 + +### 2. 애드온 + +- **@storybook/addon-docs**: 자동 문서 생성 +- **@storybook/addon-a11y**: 접근성 테스트 +- **@storybook/addon-vitest**: Vitest 통합 테스트 +- **@chromatic-com/storybook**: 비주얼 리그레션 테스트 + +### 3. Next.js 통합 + +- **@storybook/nextjs-vite**: Next.js 14 App Router와 완벽 통합 +- **자동 경로 처리**: Next.js의 `@/` import alias 지원 +- **CSS 모듈**: Tailwind CSS 및 globals.css 자동 로드 + +## 📝 스토리 작성 방법 + +### 기본 스토리 구조 + +```typescript +import type { Meta, StoryObj } from "@storybook/react"; +import { YourComponent } from "./your-component"; + +const meta = { + title: "Category/YourComponent", + component: YourComponent, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + variant: { + control: "select", + options: ["default", "primary", "secondary"], + description: "컴포넌트 변형", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: "예제", + }, +}; +``` + +### 스토리 파일 위치 + +스토리 파일은 다음 위치에서 자동으로 감지됩니다: + +- `stories/**/*.stories.@(js|jsx|ts|tsx)` +- `components/**/*.stories.@(js|jsx|ts|tsx)` +- `app/**/*.stories.@(js|jsx|ts|tsx)` + +### 예제 스토리 + +#### Button 컴포넌트 + +```typescript +export const Variants: Story = { + render: () => ( +
+ + + +
+ ), +}; +``` + +#### Card 컴포넌트 + +```typescript +export const Complete: Story = { + render: () => ( + + + 제목 + 설명 + + 내용 + + + + + ), +}; +``` + +## 🎯 베스트 프랙티스 + +### 1. 스토리 구성 + +- **Default**: 가장 기본적인 사용 예시 +- **Variants**: 모든 변형을 한눈에 보여주는 스토리 +- **States**: 다양한 상태(비활성화, 로딩 등) +- **Interactive**: 인터랙션이 필요한 복잡한 예시 + +### 2. ArgTypes 활용 + +- 모든 주요 props에 대한 설명 추가 +- `control` 타입 명시 (select, boolean, text 등) +- 기본값 설정으로 사용자 편의성 향상 + +### 3. 접근성 테스트 + +- a11y 애드온 활용하여 접근성 이슈 확인 +- ARIA 속성 올바른 사용 검증 +- 키보드 네비게이션 테스트 + +### 4. 반응형 테스트 + +- Viewport 애드온으로 다양한 화면 크기 테스트 +- 모바일/태블릿/데스크탑 각각 확인 +- 브레이크포인트별 레이아웃 검증 + +## 🔧 커스터마이징 + +### 글로벌 데코레이터 추가 + +`.storybook/preview.tsx`에서 전역 데코레이터를 추가할 수 있습니다: + +```typescript +const withProvider: Decorator = (Story) => ( + + + +); + +export const decorators = [withProvider]; +``` + +### 파라미터 설정 + +```typescript +export const parameters = { + layout: "centered", // centered, fullscreen, padded + backgrounds: { + default: "light", + values: [ + { name: "light", value: "#ffffff" }, + { name: "dark", value: "#000000" }, + ], + }, +}; +``` + +## 🐛 트러블슈팅 + +### 스타일이 적용되지 않음 + +- `preview.tsx`에서 `globals.css` import 확인 +- Tailwind config가 올바른지 확인 + +### 컴포넌트를 찾을 수 없음 + +- `main.ts`의 `stories` 경로 패턴 확인 +- 스토리 파일명이 `*.stories.tsx` 형식인지 확인 + +### 타입 에러 + +- `@storybook/react` 타입 정의 설치 확인 +- TypeScript 버전 호환성 확인 + +## 📚 참고 자료 + +- [Storybook 공식 문서](https://storybook.js.org/) +- [Next.js + Storybook 가이드](https://storybook.js.org/docs/get-started/nextjs) +- [shadcn/ui 컴포넌트](https://ui.shadcn.com/) diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..4d4b263 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,18 @@ +import type { StorybookConfig } from "@storybook/nextjs-vite"; + +const config: StorybookConfig = { + stories: ["../components/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: [ + "@chromatic-com/storybook", + "@storybook/addon-docs", + "@storybook/addon-onboarding", + "@storybook/addon-a11y", + "@storybook/addon-vitest", + ], + framework: { + name: "@storybook/nextjs-vite", + options: {}, + }, + staticDirs: ["../public"], +}; +export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000..e459ff1 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,46 @@ +import "../app/globals.css"; +import { ThemeProvider } from "../components/theme-provider"; +import type { Preview, Decorator } from "@storybook/nextjs-vite"; +import React from "react"; + +// 전역 데코레이터 - Theme Provider 추가 +const withTheme: Decorator = (Story) => ( + +
+ +
+
+); + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: "todo", + }, + backgrounds: { + default: "light", + values: [ + { + name: "light", + value: "#ffffff", + }, + { + name: "dark", + value: "#0f0f10", + }, + ], + }, + }, + decorators: [withTheme], +}; + +export default preview; diff --git a/.storybook/vitest.setup.ts b/.storybook/vitest.setup.ts new file mode 100644 index 0000000..c5ed05f --- /dev/null +++ b/.storybook/vitest.setup.ts @@ -0,0 +1,7 @@ +import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; +import { setProjectAnnotations } from '@storybook/nextjs-vite'; +import * as projectAnnotations from './preview'; + +// This is an important step to apply the right configuration when testing your stories. +// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); \ No newline at end of file diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..8e8f27d --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +kmu-sdbms-admin-page.surge.sh diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md new file mode 100644 index 0000000..ab70c3e --- /dev/null +++ b/DESIGN_SYSTEM.md @@ -0,0 +1,174 @@ +# 디자인 시스템 적용 가이드 + +이 문서는 admin-page 프로젝트에 적용된 디자인 시스템에 대한 가이드입니다. + +## 📋 개요 + +디자인 시스템은 `design-tokens.json` 파일을 기반으로 구축되었으며, 일관된 사용자 경험을 제공하기 위해 모든 컴포넌트와 페이지에 적용되었습니다. + +## 🎨 디자인 토큰 구조 + +### 1. Atomic Colors (원자 색상) + +- **Blue**: 주요 브랜드 색상 (#070913 ~ #e3e7f5) +- **Green**: 성공 상태 (#00230b ~ #d9ffe6) +- **Neutral**: 중성 색상 (#0f0f10 ~ #f7f7f8) +- **Orange**: 경고 상태 (#361e00 ~ #fef4e6) +- **Purple**: 보조 색상 (#0d002f ~ #f0eaff) +- **Red**: 오류 상태 (#3a0000 ~ #fffafa) +- **Warm Neutral**: 따뜻한 중성 색상 (#09090c ~ #f7f7f8) +- **Common**: 기본 색상 (검정, 흰색) + +### 2. Semantic Colors (의미론적 색상) + +- **Background**: 배경 색상 +- **Fill**: 채움 색상 +- **Inverse**: 반전 색상 +- **Label**: 텍스트 색상 +- **Line**: 선 색상 +- **Material**: 재질 색상 +- **Primary**: 주요 색상 +- **Secondary**: 보조 색상 +- **Static**: 정적 색상 +- **Status**: 상태 색상 (성공, 경고, 오류) + +### 3. Typography (타이포그래피) + +- **Display**: 대형 표시 텍스트 (56px, 40px) +- **Title**: 제목 텍스트 (36px, 28px, 24px) +- **Heading**: 섹션 제목 (22px, 20px) +- **Headline**: 강조 텍스트 (18px, 17px) +- **Body**: 본문 텍스트 (16px, 15px) +- **Label**: 라벨 텍스트 (14px, 13px) +- **Caption**: 캡션 텍스트 (12px, 11px) + +## 🛠 구현된 컴포넌트 + +### 1. Typography 컴포넌트 + +```tsx +import { Typography, Title1, Body1, Label1 } from "@/components/ui/typography"; + +// 기본 사용법 + + 제목 텍스트 + + +// 편의 컴포넌트 +제목 +본문 텍스트 +라벨 +``` + +### 2. 업데이트된 UI 컴포넌트들 + +- **Button**: 디자인 토큰 기반 색상 및 타이포그래피 적용 +- **Card**: 일관된 보더 및 배경 색상 적용 +- **Input**: 디자인 시스템 기반 스타일링 +- **Header**: 타이포그래피 시스템 적용 +- **Sidebar**: 색상 토큰 기반 스타일링 + +## 🎯 사용 가이드 + +### 1. 색상 사용 + +```tsx +// ✅ 권장: Semantic 색상 사용 +
+
+
+ +// ❌ 비권장: Atomic 색상 직접 사용 +
+``` + +### 2. 타이포그래피 사용 + +```tsx +// ✅ 권장: Typography 컴포넌트 사용 +페이지 제목 +본문 내용 + +// ✅ 권장: CSS 클래스 사용 +

+

+ +``` + +### 3. 반응형 디자인 + +```tsx +// 화면 크기별 타이포그래피 +

+

+``` + +## 📁 파일 구조 + +``` +├── lib/ +│ └── design-tokens.ts # 디자인 토큰 정의 +├── components/ +│ └── ui/ +│ ├── typography.tsx # 타이포그래피 컴포넌트 +│ ├── typography.stories.tsx # 타이포그래피 스토리 +│ ├── design-tokens.stories.tsx # 색상 토큰 스토리 +│ ├── button.tsx # 업데이트된 버튼 컴포넌트 +│ ├── card.tsx # 업데이트된 카드 컴포넌트 +│ └── input.tsx # 업데이트된 인풋 컴포넌트 +├── app/ +│ ├── globals.css # 디자인 토큰 기반 CSS 변수 +│ └── components/ +│ ├── header.tsx # 업데이트된 헤더 +│ └── sidebar.tsx # 업데이트된 사이드바 +└── tailwind.config.js # 디자인 토큰 기반 설정 +``` + +## 🚀 Storybook에서 확인 + +디자인 시스템은 Storybook을 통해 시각적으로 확인할 수 있습니다: + +```bash +pnpm storybook +``` + +다음 스토리들을 확인하세요: + +- **Design System/Typography**: 모든 타이포그래피 레벨과 스타일 +- **Design System/Design Tokens**: 색상 팔레트와 의미론적 색상 +- **UI/Button**: 업데이트된 버튼 컴포넌트 + +## 🔧 커스터마이징 + +### 1. 새로운 색상 추가 + +`lib/design-tokens.ts`에서 색상을 추가하고 `tailwind.config.js`에 매핑하세요. + +### 2. 새로운 타이포그래피 레벨 추가 + +`design-tokens.json`에 새로운 레벨을 추가하고 `typography.tsx`에 컴포넌트를 생성하세요. + +### 3. 다크 모드 지원 + +`app/globals.css`의 `.dark` 클래스에서 다크 모드 색상을 정의하세요. + +## 📝 베스트 프랙티스 + +1. **일관성**: 항상 디자인 토큰을 사용하여 일관된 스타일을 유지하세요. +2. **의미론적 사용**: Atomic 색상보다는 Semantic 색상을 우선 사용하세요. +3. **접근성**: 충분한 대비를 위해 색상 조합을 신중하게 선택하세요. +4. **반응형**: 다양한 화면 크기에서 적절한 타이포그래피를 사용하세요. +5. **성능**: 불필요한 CSS 클래스를 피하고 최적화된 스타일을 사용하세요. + +## 🔄 업데이트 가이드 + +디자인 토큰이 변경될 때마다 다음 단계를 따르세요: + +1. `design-tokens.json` 업데이트 +2. `lib/design-tokens.ts` 업데이트 +3. `tailwind.config.js` 업데이트 +4. `app/globals.css` CSS 변수 업데이트 +5. Storybook 스토리 업데이트 +6. 컴포넌트 테스트 및 검증 + +이 디자인 시스템을 통해 일관되고 확장 가능한 사용자 인터페이스를 구축할 수 있습니다. diff --git a/README.md b/README.md index 6980d67..fea7ece 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,14 @@ pnpm start - **Prettier**: 코드 포맷팅 자동화 - **TypeScript**: 타입 안정성 보장 +### 컴포넌트 개발 + +- **Storybook**: UI 컴포넌트 문서화 및 독립적 개발 환경 + - 포트 6006에서 실행 (`pnpm storybook`) + - 다크모드/라이트모드 지원 + - 접근성(a11y) 테스트 통합 + - 인터랙티브 컴포넌트 테스트 + ### 사용 가능한 스크립트 ```bash @@ -169,6 +177,12 @@ pnpm build # 서버 시작 pnpm start + +# Storybook 실행 +pnpm storybook + +# Storybook 빌드 +pnpm build-storybook ``` ## 🎨 디자인 시스템 @@ -196,6 +210,10 @@ pnpm start /* 15% 크기 감소된 반응형 폰트 */ /* 15% 크기 감소된 반응형 폰트 */ /* 15% 크기 감소된 반응형 폰트 */ +/* 15% 크기 감소된 반응형 폰트 */ +/* 15% 크기 감소된 반응형 폰트 */ +/* 15% 크기 감소된 반응형 폰트 */ +/* 15% 크기 감소된 반응형 폰트 */ .text-responsive-base /* 기본 반응형 폰트 */ /* 뷰포트 기반 간격 */ diff --git a/app/bill/CaptureView.tsx b/app/bill/CaptureView.tsx index 74ad4b7..49de9eb 100644 --- a/app/bill/CaptureView.tsx +++ b/app/bill/CaptureView.tsx @@ -13,6 +13,7 @@ const CaptureView: React.FC = ({ selectedRoom, selectedYear, selectedMonth, + selectedFloor, onBack, onSaveComplete, }) => { @@ -226,7 +227,7 @@ const CaptureView: React.FC = ({ }, [selectedRoom, currentPhotoType, selectedYear, selectedMonth]); return ( -

+
{/* 헤더 */}
+ // 통계 계산 + const totalRooms = serverRooms.length || 99; // 실제 호실 수 또는 99 (API 연결되지 않음) + const paidRooms = roomPayments.filter((r) => r.status === "paid").length; + const unpaidRooms = roomPayments.filter((r) => r.status === "unpaid").length; + + // 필터링된 호실 목록 + const filteredRooms = roomPayments.filter((room) => { + switch (filterStatus) { + case "paid": + return room.status === "paid"; + case "unpaid": + return room.status === "unpaid"; + case "all": + default: + return true; + } + }); + + // 현재 년도 기준으로 동적 생성 + const currentYear = new Date().getFullYear(); + const months = [9, 10, 11, 12, 1, 2, 3, 4, 5, 6, 7, 8]; + const days = [11, 15, 19, 17, 12, 19, 26, 19, 20, 24, 29, 76, 25, 11]; + + // 드롭다운 외부 클릭 시 닫기 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Element; + + if (isDropdownOpen && !target.closest(".dropdown-container")) { + setIsDropdownOpen(false); + } + + if (isMonthDropdownOpen && !target.closest(".month-dropdown-container")) { + setIsMonthDropdownOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isDropdownOpen, isMonthDropdownOpen]); + + // 호실 카드 렌더링 + const renderRoomCard = (room: RoomPaymentInfo) => { + const getCardStyle = () => { + switch (room.status) { + case "paid": + return "bg-green-100 border-green-200"; + case "unpaid": + return "bg-orange-100 border-orange-200"; + case "action_required": + return "bg-white border-gray-200"; + default: + return "bg-white border-gray-200"; + } + }; + + const getStatusIcon = () => { + switch (room.status) { + case "paid": + return ; + case "unpaid": + return ; + case "action_required": + return null; + default: + return null; + } + }; + + const getStatusText = () => { + switch (room.status) { + case "paid": + return "납부 완료"; + case "unpaid": + return "▲ 미납"; + case "action_required": + return null; + default: + return null; + } + }; + + const handleRoomClick = () => { + setSelectedRoomForModal(room.roomNumber); + setIsModalOpen(true); + }; + + return ( +
+
+
{room.roomNumber}호
+
-

관리비 사진 관리

- - {/* 년도와 월 선택기 */} -
-
- - -
-
- - + + {room.status === "action_required" ? ( +
+ 납부 사진 보기
-
- - {selectedYear}년 {selectedMonth}월 관리비 - + ) : ( +
+ {getStatusIcon()} + {getStatusText()}
-
+ )}
- - {loadingRooms &&
호실 목록을 불러오는 중...
} - {roomsError &&
{roomsError}
} - - {floors.map(floor => ( -
-

{floor}층

-
- {rooms - .filter(room => room.floor === floor) - .map(room => ( -
onRoomChange(room.roomNumber)} + ); + }; + + return ( +
+ {/* 상단 헤더 */} +
+
+
+

관리비 관리

+ + {/* 날짜/년도 선택 */} +
+
{selectedFloor}층
+ + {/* 년도 슬라이더 */} +
+ {currentYear} +
+ + {/* 월 선택 드롭다운 */} +
+ - {selectedRoom === room.roomNumber && ( -
- - + {isMonthDropdownOpen && ( +
+
+ {months.map((month) => ( + + ))}
- )} +
+ )} +
+
+
+
+
+ + {/* 메인 콘텐츠 */} +
+ {/* 요약 통계 */} +
+

+ {selectedYear}년 {selectedMonth}월 납부 현황 +

+
+ 총 {totalRooms}세대 중 {paidRooms}세대 납부, {unpaidRooms}세대 미납 +
+
+ + {/* 필터 드롭다운 */} +
+
+ + + {isDropdownOpen && ( +
+
+ + +
- ))} +
+ )}
- ))} + + {/* 로딩/에러 상태 */} + {loadingRooms && ( +
+
+

호실 목록을 불러오는 중...

+
+ )} + + {roomsError && ( +
+

{roomsError}

+ +
+ )} + + {/* 호실 그리드 */} + {!loadingRooms && !roomsError && ( +
+ {filteredRooms.map(renderRoomCard)} +
+ )} +
+ + {/* 호실 상세 모달 */} + { + setIsModalOpen(false); + setSelectedRoomForModal(null); + }} + selectedRoom={selectedRoomForModal} + selectedYear={selectedYear} + selectedMonth={selectedMonth} + selectedFloor={selectedFloor} + onRoomChange={() => {}} + onYearChange={onYearChange} + onMonthChange={onMonthChange} + onFloorChange={onFloorChange} + />
); }; -export default ListView; \ No newline at end of file +export default ListView; diff --git a/app/bill/ReviewView.tsx b/app/bill/ReviewView.tsx index 9327baa..1438c89 100644 --- a/app/bill/ReviewView.tsx +++ b/app/bill/ReviewView.tsx @@ -13,6 +13,7 @@ const ReviewView: React.FC = ({ selectedRoom, selectedYear, selectedMonth, + selectedFloor, onBack, onNavigateToCapture, }) => { @@ -123,7 +124,7 @@ const ReviewView: React.FC = ({ }; return ( -
+
{/* 헤더 */}
+
+ + {/* 컨텐츠 */} +
+ {isCaptureMode ? ( + /* 촬영 모드 */ +
+
+

{currentPhotoType} 요금 촬영

+

사진을 촬영하거나 파일을 업로드하세요

+
+ + {/* 촬영 영역 */} +
+ {previewImage ? ( + Preview + ) : ( +
+ +

사진을 업로드하세요

+
+ )} +
+ + {/* 파일 업로드 */} +
+ +
+ + {/* 액션 버튼들 */} + {previewImage && ( +
+ + +
+ )} + + {/* 사진 타입 선택 */} +
+ {(['수도', '전기', '가스'] as const).map(type => ( + + ))} +
+
+ ) : ( + /* 확인 모드 */ +
+
+

등록된 사진

+ +
+ + {loadingPhotos ? ( +
+
+

사진을 불러오는 중...

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

등록된 사진이 없습니다

+
+ ) : ( +
+ {(['수도', '전기', '가스'] as const).map(type => { + const photo = serverPhotos.find(p => p.type === type); + + return ( +
+
+ {type} 요금 +
+ + {photo ? ( + <> +
+ {`${type} +
+
+ + {new Date(photo.timestamp).toLocaleString('ko-KR')} + +
+ + +
+
+ + ) : ( +
+ +

사진 없음

+ +
+ )} +
+ ); + })} +
+ )} +
+ )} +
+
+
+ ); +}; + +export default RoomDetailModal; diff --git a/app/bill/page.tsx b/app/bill/page.tsx index 910feec..a6fd5bd 100644 --- a/app/bill/page.tsx +++ b/app/bill/page.tsx @@ -1,84 +1,44 @@ -'use client'; +"use client"; -import React, { useState } from 'react'; -import { ViewMode } from './types'; -import ListView from './ListView'; -import CaptureView from './CaptureView'; -import ReviewView from './ReviewView'; +import React, { useState } from "react"; +import { Layout } from "@/components/layout"; +import ListView from "./ListView"; const Bill: React.FC = () => { - const [viewMode, setViewMode] = useState('list'); const [selectedRoom, setSelectedRoom] = useState(null); - const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); - const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1); - - // 네비게이션 핸들러들 - const handleNavigateToCapture = (roomNumber: string) => { - setSelectedRoom(roomNumber); - setViewMode('capture'); - }; - - const handleNavigateToReview = (roomNumber: string) => { - setSelectedRoom(roomNumber); - setViewMode('review'); - }; - - const handleBack = () => { - setViewMode('list'); - setSelectedRoom(null); - }; - - const handleSaveComplete = () => { - setViewMode('list'); - setSelectedRoom(null); - }; - - const handleNavigateToCaptureFromReview = (roomNumber: string, photoType: '수도' | '전기' | '가스') => { - setSelectedRoom(roomNumber); - setViewMode('capture'); - // TODO: photoType을 CaptureView에 전달하는 방법 구현 - }; + const [selectedYear, setSelectedYear] = useState( + new Date().getFullYear() + ); + const [selectedMonth, setSelectedMonth] = useState( + new Date().getMonth() + 1 + ); + const [selectedFloor, setSelectedFloor] = useState(1); // 공통 Props const commonProps = { selectedRoom, selectedYear, selectedMonth, + selectedFloor, onRoomChange: setSelectedRoom, onYearChange: setSelectedYear, onMonthChange: setSelectedMonth, + onFloorChange: setSelectedFloor, }; - // 네비게이션 콜백들 + // 더미 네비게이션 콜백들 (모달에서 사용하지 않음) const navigationCallbacks = { - onNavigateToCapture: handleNavigateToCapture, - onNavigateToReview: handleNavigateToReview, - onBack: handleBack, + onNavigateToCapture: () => {}, + onNavigateToReview: () => {}, + onBack: () => {}, }; return ( -
- {viewMode === 'list' && ( - - )} - {viewMode === 'capture' && selectedRoom && ( - - )} - {viewMode === 'review' && selectedRoom && ( - - )} -
+ +
+ +
+
); }; diff --git a/app/bill/types.tsx b/app/bill/types.tsx index aa07767..3a249cb 100644 --- a/app/bill/types.tsx +++ b/app/bill/types.tsx @@ -43,7 +43,19 @@ export interface CommonProps { selectedRoom: string | null; selectedYear: number; selectedMonth: number; + selectedFloor: number; onRoomChange: (roomNumber: string | null) => void; onYearChange: (year: number) => void; onMonthChange: (month: number) => void; + onFloorChange: (floor: number) => void; +} + +// 납부 상태 타입 +export type PaymentStatus = 'paid' | 'unpaid' | 'action_required'; + +// 호실 납부 정보 타입 +export interface RoomPaymentInfo { + roomNumber: string; + status: PaymentStatus; + hasPhotos: boolean; } \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index 0ad6f00..9eb6ee4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,79 +3,84 @@ @custom-variant dark (&:is(.dark *)); +/* Design Tokens CSS Variables */ +@import "../lib/design-tokens.css"; + :root { - --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); - --destructive-foreground: 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); + /* 디자인 시스템 기반 CSS 변수 */ + --background: #ffffff; + --foreground: #16161d; + --card: #ffffff; + --card-foreground: #16161d; + --popover: #ffffff; + --popover-foreground: #16161d; + --primary: #374a95; + --primary-foreground: #ffffff; + --secondary: #e5dcff; + --secondary-foreground: #16161d; + --muted: #67678b14; + --muted-foreground: #39394e9c; + --accent: #67678b29; + --accent-foreground: #16161d; + --destructive: #ff4242; + --destructive-foreground: #ffffff; + --border: #e0e0e8; + --input: #e0e0e8; + --ring: #374a95; + --chart-1: #00bf40; + --chart-2: #374a95; + --chart-3: #ff9200; + --chart-4: #ff4242; + --chart-5: #c3acff; --radius: 0.625rem; - --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); + --sidebar: #f7f7f8; + --sidebar-foreground: #16161d; + --sidebar-primary: #374a95; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #67678b14; + --sidebar-accent-foreground: #16161d; + --sidebar-border: #e0e0e8; + --sidebar-ring: #374a95; } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.145 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.145 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.985 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.396 0.141 25.723); - --destructive-foreground: oklch(0.637 0.237 25.331); - --border: oklch(0.269 0 0); - --input: oklch(0.269 0 0); - --ring: oklch(0.439 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(0.269 0 0); - --sidebar-ring: oklch(0.439 0 0); + /* 다크 모드용 디자인 시스템 기반 CSS 변수 */ + --background: #1a1a23; + --foreground: #f7f7f8; + --card: #1a1a23; + --card-foreground: #f7f7f8; + --popover: #1a1a23; + --popover-foreground: #f7f7f8; + --primary: #374a95; + --primary-foreground: #f7f7f8; + --secondary: #67678b29; + --secondary-foreground: #f7f7f8; + --muted: #67678b14; + --muted-foreground: #39394e9c; + --accent: #67678b29; + --accent-foreground: #f7f7f8; + --destructive: #ff4242; + --destructive-foreground: #f7f7f8; + --border: #67678b29; + --input: #67678b29; + --ring: #374a95; + --chart-1: #00bf40; + --chart-2: #374a95; + --chart-3: #ff9200; + --chart-4: #ff4242; + --chart-5: #c3acff; + --sidebar: #1a1a23; + --sidebar-foreground: #f7f7f8; + --sidebar-primary: #374a95; + --sidebar-primary-foreground: #f7f7f8; + --sidebar-accent: #67678b14; + --sidebar-accent-foreground: #f7f7f8; + --sidebar-border: #67678b29; + --sidebar-ring: #374a95; } @theme inline { - --font-sans: var(--font-geist-sans); + --font-sans: "Pretendard", system-ui, sans-serif; --font-mono: var(--font-geist-mono); --color-background: var(--background); --color-foreground: var(--foreground); @@ -121,331 +126,5 @@ } body { @apply bg-background text-foreground; - font-size: var(--font-size-base); /* 반응형 기본 폰트 사이즈 */ - } - - /* 기본 HTML 요소들 - QHD와 FHD 반응형 폰트 사이즈 */ - h1, - h2, - h3, - h4, - h5, - h6, - p, - small { - font-size: clamp(1rem, 0.5rem + 1.5vw, 1.5rem); /* FHD 16px, QHD 24px */ - } - - /* QHD와 FHD 반응형 폰트 사이즈 - FHD 16px, QHD 24px */ - :root { - --font-size-base: clamp( - 1rem, - 0.5rem + 1.5vw, - 1.5rem - ); /* FHD 16px, QHD 24px */ - } - - /* 기본 폰트 클래스 - 모두 동일한 반응형 사이즈 */ - .text-responsive-xxs, - .text-responsive-sm, - .text-responsive-base, - .text-responsive-lg, - .text-responsive-xl, - .text-responsive-2xl, - .text-responsive-3xl, - .text-responsive-4xl, - .text-responsive-5xl, - .text-responsive-6xl { - font-size: var(--font-size-base); - } - - /* text-responsive-xs - QHD에서 1.2배로 조정, 15% 크기 감소 */ - .text-responsive-xs { - font-size: clamp( - 0.85rem, - 0.425rem + 1.53vw, - 1.53rem - ); /* FHD 13.6px (15% 감소), QHD 24.48px (15% 감소) */ - } - - /* 공지사항 테이블 반응형 패딩 - FHD에서 px-2 py-1과 정확히 동일, QHD에서 1.5배 */ - .notice-table-padding { - padding: 0.25rem 0.5rem; /* FHD에서 px-2 py-1과 정확히 동일 */ - } - - /* QHD에서만 1.5배로 조정 */ - @media (min-width: 2560px) { - .notice-table-padding { - padding: 0.375rem 0.75rem; /* QHD에서 1.5배 */ - } - } - - /* 공지사항 테이블 반응형 마진 - FHD 기준, QHD에서 1.5배 */ - .notice-table-margin { - margin: clamp(0.25rem, 0.25rem + 0.375vw, 0.375rem); - } - - /* 공지사항 테이블 반응형 간격 - FHD 기준, QHD에서 1.5배 */ - .notice-table-gap { - gap: clamp(0.25rem, 0.25rem + 0.375vw, 0.375rem); - } - - /* 공지사항 테이블 반응형 아이콘 크기 - FHD 기준, QHD에서 1.5배 */ - .notice-table-icon { - width: clamp(1rem, 1rem + 0.75vw, 1.5rem); - height: clamp(1rem, 1rem + 0.75vw, 1.5rem); - } - - /* 공지사항 테이블 반응형 버튼 크기 - FHD 기준, QHD에서 1.5배 */ - .notice-table-button { - height: clamp(1.5rem, 1.5rem + 0.75vw, 2.25rem); - padding: clamp(0.25rem, 0.25rem + 0.375vw, 0.375rem) - clamp(0.5rem, 0.5rem + 0.75vw, 0.75rem); - } - - /* 공지사항 테이블 반응형 최대 너비 - FHD 기준, QHD에서 1.5배 */ - .notice-table-max-width { - max-width: clamp(7.5rem, 7.5rem + 11.25vw, 11.25rem); /* 120px -> 180px */ - } - - .notice-table-max-width-lg { - max-width: clamp(12.5rem, 12.5rem + 18.75vw, 18.75rem); /* 200px -> 300px */ - } - - /* 뷰포트 높이 기반 compact 레이아웃 */ - .compact-height { - height: 100vh; - max-height: 100vh; - min-height: 100vh; /* 최소 높이 보장 */ - overflow: hidden; - } - - .compact-content { - height: calc(100vh - 4rem); /* 헤더 높이 제외 */ - max-height: calc(100vh - 4rem); - min-height: calc(100vh - 4rem); /* 최소 높이 보장 */ - overflow-y: auto; - } - - .compact-main { - height: calc(100vh - 4rem); /* 헤더 높이 제외 */ - max-height: calc(100vh - 4rem); - min-height: calc(100vh - 4rem); /* 최소 높이 보장 */ - overflow-y: auto; - padding: clamp(0.5rem, 0.5rem + 0.5vh, 1.5rem); - } - - /* 뷰포트 높이를 완전히 활용하는 컨테이너 */ - .viewport-fill { - height: 100%; - min-height: 100%; - display: flex; - flex-direction: column; - } - - .viewport-fill-content { - flex: 1; - min-height: 0; - overflow-y: auto; - } - - /* 뷰포트 높이에 따른 간격 조정 */ - .spacing-compact { - gap: clamp(0.25rem, 0.25rem + 0.25vh, 0.75rem); - } - - .spacing-normal { - gap: clamp(0.5rem, 0.5rem + 0.5vh, 1.5rem); - } - - .spacing-loose { - gap: clamp(0.75rem, 0.75rem + 0.75vh, 2.25rem); - } - - /* 뷰포트 높이에 따른 패딩 조정 */ - .padding-compact { - padding: clamp(0.25rem, 0.25rem + 0.25vh, 0.75rem); - } - - .padding-normal { - padding: clamp(0.5rem, 0.5rem + 0.5vh, 1.5rem); - } - - .padding-loose { - padding: clamp(0.75rem, 0.75rem + 0.75vh, 2.25rem); - } - - /* 뷰포트 높이에 따른 마진 조정 */ - .margin-compact { - margin: clamp(0.25rem, 0.25rem + 0.25vh, 0.75rem); - } - - .margin-normal { - margin: clamp(0.5rem, 0.5rem + 0.5vh, 1.5rem); - } - - .margin-loose { - margin: clamp(0.75rem, 0.75rem + 0.75vh, 2.25rem); - } - - /* 뷰포트 높이 기반 테이블 행 높이 조정 */ - .table-row-compact { - height: clamp(1.5rem, 1.5rem + 0.5vh, 2rem); - } - - .table-row-normal { - height: clamp(1.75rem, 1.75rem + 0.75vh, 2.5rem); - } - - .table-row-loose { - height: clamp(2rem, 2rem + 1vh, 3rem); - } - - /* 공지사항 테이블 행 높이 - FHD에서 5% 감소, QHD에서 1.3배로 조정 */ - .notice-table-row { - height: clamp( - 1.6rem, - 1.6rem + 2.275vw, - 6.1425rem - ) !important; /* FHD 25.6px (5% 감소), QHD 98px (1.3배) */ - min-height: clamp(1.6rem, 1.6rem + 2.275vw, 6.1425rem) !important; - max-height: clamp(1.6rem, 1.6rem + 2.275vw, 6.1425rem) !important; - } - - /* 테이블 셀 높이도 함께 조정 */ - .notice-table-row td { - height: clamp(1.6rem, 1.6rem + 2.275vw, 6.1425rem) !important; - min-height: clamp(1.6rem, 1.6rem + 2.275vw, 6.1425rem) !important; - max-height: clamp(1.6rem, 1.6rem + 2.275vw, 6.1425rem) !important; - } - - /* 테이블이 컨테이너를 완전히 채우도록 설정 */ - .notice-table-container { - height: 100%; - display: flex; - flex-direction: column; - } - - .notice-table-container .min-w-full { - flex: 1; - height: 100%; - } - - /* 뷰포트 높이 기반 버튼 크기 조정 - QHD와 FHD 반응형 */ - .btn-compact { - height: clamp(1.75rem, 1.75rem + 0.5vh, 2.25rem); - padding: clamp(0.25rem, 0.25rem + 0.25vh, 0.5rem) - clamp(0.5rem, 0.5rem + 0.5vh, 1rem); - font-size: clamp(1rem, 0.5rem + 1.5vw, 1.5rem); /* FHD 16px, QHD 24px */ - } - - .btn-normal { - height: clamp(2rem, 2rem + 0.75vh, 2.75rem); - padding: clamp(0.375rem, 0.375rem + 0.375vh, 0.75rem) - clamp(0.75rem, 0.75rem + 0.75vh, 1.5rem); - font-size: clamp(1rem, 0.5rem + 1.5vw, 1.5rem); /* FHD 16px, QHD 24px */ - } - - .btn-loose { - height: clamp(2.25rem, 2.25rem + 1vh, 3.25rem); - padding: clamp(0.5rem, 0.5rem + 0.5vh, 1rem) clamp(1rem, 1rem + 1vh, 2rem); - font-size: clamp(1rem, 0.5rem + 1.5vw, 1.5rem); /* FHD 16px, QHD 24px */ - } - - /* 뷰포트 높이 기반 카드 패딩 조정 */ - .card-compact { - padding: clamp(0.5rem, 0.5rem + 0.5vh, 1rem); - } - - .card-normal { - padding: clamp(0.75rem, 0.75rem + 0.75vh, 1.5rem); - } - - .card-loose { - padding: clamp(1rem, 1rem + 1vh, 2rem); - } - - /* 공지작성 버튼 - 텍스트와 아이콘 흰색 */ - button[type="submit"] { - color: oklch(0.985 0 0) !important; - } - - button[type="submit"] svg { - color: oklch(0.985 0 0) !important; - } - - /* 페이지네이션 현재 페이지 버튼 - 텍스트 흰색 */ - button[aria-current="page"] { - color: oklch(0.985 0 0) !important; - } - - button[aria-current="page"] svg { - color: oklch(0.985 0 0) !important; - } - - /* 페이지네이션 버튼들 중 default variant (현재 페이지) */ - .flex.items-center.gap-1 button[data-variant="default"] { - color: oklch(0.985 0 0) !important; - } - - /* 공지사항 목록의 페이지네이션 현재 페이지 버튼 */ - .space-y-3 button[data-variant="default"], - .space-y-4 button[data-variant="default"] { - color: oklch(0.985 0 0) !important; - } - - /* 모든 default variant 버튼 (현재 페이지) */ - button[data-variant="default"] { - color: oklch(0.985 0 0) !important; - } - - /* 페이지네이션 컨테이너 내의 default variant 버튼 */ - .flex.items-center.gap-1 button[data-variant="default"], - .flex.items-center.gap-1 button[data-variant="default"] * { - color: oklch(0.985 0 0) !important; - } - - /* 공지사항 페이지네이션 버튼들 - 더 구체적인 선택자 */ - .w-6.h-6[data-variant="default"], - .w-7.h-7[data-variant="default"], - .sm\\:w-7.sm\\:h-7[data-variant="default"] { - color: oklch(0.985 0 0) !important; - } - - /* 페이지네이션 버튼의 텍스트 */ - .flex.items-center.gap-1 button { - color: inherit; - } - - .flex.items-center.gap-1 button[data-variant="default"] { - color: oklch(0.985 0 0) !important; - } - - /* 페이지네이션 버튼 강제 흰색 - 최고 우선순위 */ - .flex.items-center.gap-1 button[data-variant="default"], - .flex.items-center.gap-1 button[data-variant="default"] *, - .flex.items-center.gap-1 button[data-variant="default"] span { - color: oklch(0.985 0 0) !important; - } - - /* 공지사항 페이지네이션 특정 선택자 */ - .space-y-3 .flex.items-center.gap-1 button[data-variant="default"], - .space-y-4 .flex.items-center.gap-1 button[data-variant="default"] { - color: oklch(0.985 0 0) !important; - } - - /* 최고 우선순위 - 페이지네이션 현재 페이지 버튼 */ - div.flex.items-center.gap-1.flex-wrap button[data-variant="default"] { - color: oklch(0.985 0 0) !important; - } - - /* 모든 default variant 버튼 강제 흰색 */ - button[data-variant="default"] { - color: oklch(0.985 0 0) !important; - } - - /* 페이지네이션 버튼 내부 모든 요소 */ - button[data-variant="default"] * { - color: oklch(0.985 0 0) !important; } } diff --git a/app/inquiries/page.tsx b/app/inquiries/page.tsx index 389d913..1ea9957 100644 --- a/app/inquiries/page.tsx +++ b/app/inquiries/page.tsx @@ -1,8 +1,6 @@ import { Suspense } from "react"; import { MessageSquare, Filter, RefreshCw } from "lucide-react"; -// 동적 렌더링 강제 -export const dynamic = "force-dynamic"; import { Layout } from "@/components/layout"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; diff --git a/app/login/layout.tsx b/app/login/layout.tsx new file mode 100644 index 0000000..a472009 --- /dev/null +++ b/app/login/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "로그인 - 기숙사 관리 시스템", + description: "기숙사 관리 시스템 로그인", +}; + +/** + * 로그인 페이지 전용 레이아웃 + * 사이드바 없이 중앙 정렬된 로그인 폼만 표시 + */ +export default function LoginLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..d9ba99c --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; + +/** + * 로그인 페이지 + * 간단한 로그인 버튼만 있는 페이지 + */ +export default function LoginPage() { + const router = useRouter(); + + const handleLogin = () => { + // 로그인 처리 (추후 구현) + router.push("/"); + }; + + return ( +
+
+
+

+ 기숙사 관리 시스템 +

+

+ 관리자 페이지에 로그인하세요 +

+
+ +
+ +
+
+
+ ); +} diff --git a/app/notices/notices-page-client.tsx b/app/notices/notices-page-client.tsx index 94e9075..8ce7cb9 100644 --- a/app/notices/notices-page-client.tsx +++ b/app/notices/notices-page-client.tsx @@ -4,27 +4,21 @@ import type React from "react"; import { useState, useEffect } from "react"; import { Plus, - Eye, - Send, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, + Check, + Clock, + Calendar, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; import { Checkbox } from "@/components/ui/checkbox"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { Table, TableBody, @@ -35,18 +29,13 @@ import { } from "@/components/ui/table"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { NoticePreviewModal } from "@/components/notices/notice-preview-modal"; +import { NoticeCreateModal } from "@/components/notices/notice-create-modal"; import { NoticeEditModal } from "@/components/notices/notice-edit-modal"; import { NoticeDeleteDialog } from "@/components/notices/notice-delete-dialog"; import { useNotices } from "@/hooks/use-notices"; import { useToast } from "@/hooks/use-toast"; import type { Notice } from "@/lib/types"; -interface NoticeForm { - title: string; - content: string; - is_important: boolean; -} - interface NoticesPageClientProps { initialNoticesData: { notices: Notice[]; @@ -61,21 +50,15 @@ interface NoticesPageClientProps { export function NoticesPageClient({ initialNoticesData, }: NoticesPageClientProps) { - const [form, setForm] = useState({ - title: "", - content: "", - is_important: false, - }); - const [isSubmitting, setIsSubmitting] = useState(false); const [showModal, setShowModal] = useState(false); const [selectedNotice, setSelectedNotice] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [timeFilter, setTimeFilter] = useState< "this-week" | "this-month" | "all" - >("this-week"); + >("all"); const [isRefreshing, setIsRefreshing] = useState(false); - const [isListExpanded, setIsListExpanded] = useState(false); const [currentPage, setCurrentPage] = useState(1); const { toast } = useToast(); @@ -91,61 +74,6 @@ export function NoticesPageClient({ sortFilter: "latest", // 기본값으로 고정 }); - const handleInputChange = (field: keyof NoticeForm, value: any) => { - setForm((prev) => ({ - ...prev, - [field]: value, - })); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!form.title.trim() || !form.content.trim()) { - toast({ - title: "입력 오류", - description: "제목과 내용을 모두 입력해주세요.", - variant: "destructive", - }); - return; - } - - setIsSubmitting(true); - - try { - await mutateNotice({ - title: form.title.trim(), - content: form.content.trim(), - is_important: form.is_important, - }); - - // Refresh the notices list - await refetchNotices(); - - toast({ - title: "공지 작성 완료", - description: "공지사항이 성공적으로 작성되었습니다.", - }); - - // Reset form - setForm({ - title: "", - content: "", - is_important: false, - }); - setShowModal(false); - } catch (error) { - toast({ - title: "공지 작성 실패", - description: - error instanceof Error ? error.message : "공지 작성에 실패했습니다.", - variant: "destructive", - }); - } finally { - setIsSubmitting(false); - } - }; - const formatDate = (dateString: string) => { const date = new Date(dateString); const month = String(date.getMonth() + 1).padStart(2, "0"); @@ -171,8 +99,6 @@ export function NoticesPageClient({ setCurrentPage(page); }; - const isFormValid = form.title.trim() && form.content.trim(); - const handleNoticeClick = (notice: Notice) => { setSelectedNotice(notice); setShowModal(true); @@ -205,6 +131,10 @@ export function NoticesPageClient({ } }; + const handleCreateSuccess = () => { + refetchNotices(); + }; + const handleEditSuccess = () => { refetchNotices(); }; @@ -240,194 +170,338 @@ export function NoticesPageClient({ }, []); return ( -
- {/* Notice Creation Form - Left Panel */} -
- - - - - 공지 작성 - - - -
+ {/* Search Box Area */} +
+ {/* Page Title */} +

+ 공지사항 +

+ {/* Search Box */} +
+ +
+
+ + {/* Main Content */} +
+ {/* Left Sidebar Container */} +
+ {/* Create Button */} + + + {/* Total Count with Refresh Button */} +
+ +
+ 총 {totalItems}건 +
+
- {/* Important Notice Checkbox */} -
- - handleInputChange("is_important", checked) - } - /> -