Files
sam-design/src/COMMON_COMPONENTS_GUIDE.md
정재웅 060b9ce2ef 프로젝트 초기 설정 및 구조 추가
- Vite + React 프로젝트 구조 설정
- 불필요한 PDF 파일 삭제

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 13:01:43 +09:00

17 KiB

공통 컴포넌트 가이드

이 문서는 SAM MES 시스템의 통일된 레이아웃과 반응형 디자인을 위한 공통 컴포넌트 사용 가이드입니다.

📌 중요: 페이지 타이틀 구조 통일에 대한 상세 가이드는 TITLE_STRUCTURE_STANDARDIZATION.md를 참고하세요.

📋 목차

  1. 개요
  2. 공통 컴포넌트 목록
  3. 사용 예시
  4. 디자인 원칙

개요

모든 페이지는 통일된 레이아웃과 디자인 스타일을 사용하여 일관된 사용자 경험을 제공합니다.

  • 완전한 반응형 디자인 (데스크톱/태블릿/모바일)
  • 데스크톱: 테이블 형식
  • 모바일: 카드 형식
  • 통일된 헤더, 검색, 통계 패턴

공통 컴포넌트 목록

1. PageLayout

페이지의 기본 레이아웃을 제공합니다.

import { PageLayout } from "./common/PageLayout";

function MyPage() {
  return (
    <PageLayout maxWidth="2xl">
      {/* 페이지 내용 */}
    </PageLayout>
  );
}

Props:

  • maxWidth: "sm" | "md" | "lg" | "xl" | "2xl" | "full" (기본값: "full")
    • sm: max-w-3xl
    • md: max-w-5xl
    • lg: max-w-6xl
    • xl: max-w-7xl
    • 2xl: max-w-[1600px]
    • full: w-full (전체 너비, 기본값)
  • 자동으로 padding과 spacing 적용 (p-4 md:p-6, space-y-4 md:space-y-6)

2. PageHeader

페이지 헤더 (제목, 설명, 액션 버튼)

import { PageHeader } from "./common/PageHeader";
import { FileText, Plus } from "lucide-react";

<PageHeader 
  title="견적 관리"
  description="작성된 견적서 목록을 확인하고 관리합니다"
  icon={FileText}
  actions={
    <Button onClick={handleNew}>
      <Plus className="w-4 h-4 mr-2" />
      신규 작성
    </Button>
  }
/>

Props:

  • title: 페이지 제목 (필수)
  • description: 페이지 설명 (선택)
  • icon: Lucide 아이콘 컴포넌트 (선택)
  • actions: 액션 버튼들 (선택)

3. StatCards

통계 카드를 그리드로 표시

import { StatCards } from "./common/StatCards";
import { FileText } from "lucide-react";

const stats = [
  {
    label: "전체 견적",
    value: 150,
    icon: FileText,
    iconColor: "text-blue-600"
  },
  {
    label: "금일 작성",
    value: 12,
    icon: FileText,
    iconColor: "text-green-600",
    trend: {
      value: "+15%",
      isPositive: true
    }
  }
];

<StatCards stats={stats} />

Props:

  • stats: 통계 데이터 배열
    • label: 라벨
    • value: 값 (숫자 또는 문자열)
    • icon: Lucide 아이콘 (선택)
    • iconColor: 아이콘 색상 클래스 (선택)
    • trend: 추세 정보 (선택)

4. SearchFilter

검색 및 필터 바

import { SearchFilter } from "./common/SearchFilter";

<SearchFilter 
  searchValue={searchTerm}
  onSearchChange={setSearchTerm}
  searchPlaceholder="거래처, 작성자로 검색..."
  filterButton={true}
  onFilterClick={handleFilter}
  extraActions={
    <Button>
      <Download className="w-4 h-4 mr-2" />
      내보내기
    </Button>
  }
/>

Props:

  • searchValue: 검색어 (필수)
  • onSearchChange: 검색어 변경 핸들러 (필수)
  • searchPlaceholder: 검색 placeholder (선택)
  • filterButton: 필터 버튼 표시 여부 (기본값: true)
  • onFilterClick: 필터 클릭 핸들러 (선택)
  • extraActions: 추가 액션 버튼들 (선택)

5. EmptyState

빈 상태 표시

import { EmptyState } from "./common/EmptyState";
import { FileText } from "lucide-react";

<EmptyState 
  icon={FileText}
  title="작성된 견적서가 없습니다"
  description="첫 견적서를 작성하여 시작하세요"
  action={{
    label: "첫 견적서 작성하기",
    onClick: handleNew
  }}
/>

Props:

  • icon: Lucide 아이콘 컴포넌트 (필수)
  • title: 제목 (필수)
  • description: 설명 (선택)
  • action: 액션 버튼 정보 (선택)
    • label: 버튼 텍스트
    • onClick: 클릭 핸들러

6. MobileCard

모바일용 카드 컴포넌트

import { MobileCard } from "./common/MobileCard";
import { FileText, Eye, Edit, Trash2 } from "lucide-react";

<MobileCard 
  title="샘플 거래처"
  subtitle="작성자: 김철수"
  icon={<FileText className="w-4 h-4 text-blue-600" />}
  badge={{
    label: "진행중",
    variant: "outline"
  }}
  fields={[
    { label: "접수일", value: "2025-01-15" },
    { label: "금액", value: "1,500,000원" },
    { label: "상태", value: "완료", badge: true, badgeVariant: "secondary" }
  ]}
  actions={[
    {
      label: "상세",
      icon: <Eye className="w-3 h-3 mr-1" />,
      variant: "outline",
      onClick: handleView
    },
    {
      label: "수정",
      icon: <Edit className="w-3 h-3 mr-1" />,
      variant: "outline",
      onClick: handleEdit
    }
  ]}
/>

Props:

  • title: 제목 (필수)
  • subtitle: 부제목 (선택)
  • icon: 아이콘 ReactNode (선택)
  • badge: 배지 정보 (선택)
  • fields: 필드 배열 (필수)
  • actions: 액션 버튼 배열 (선택)

사용 예시

List 페이지 전체 구조

import { useState } from "react";
import { PageLayout } from "./common/PageLayout";
import { PageHeader } from "./common/PageHeader";
import { StatCards } from "./common/StatCards";
import { SearchFilter } from "./common/SearchFilter";
import { EmptyState } from "./common/EmptyState";
import { MobileCard } from "./common/MobileCard";
import { Card, CardContent } from "./ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
import { Plus, FileText, Eye, Edit, Trash2 } from "lucide-react";

export function MyListPage() {
  const [searchTerm, setSearchTerm] = useState("");
  const [items, setItems] = useState([]);

  const stats = [
    { label: "전체", value: items.length, icon: FileText, iconColor: "text-blue-600" },
    { label: "금일", value: 12, icon: FileText, iconColor: "text-green-600" }
  ];

  const filteredItems = items.filter(item => 
    item.name.toLowerCase().includes(searchTerm.toLowerCase())
  );

  return (
    <PageLayout>
      <PageHeader 
        title="항목 관리"
        description="항목 목록을 확인하고 관리합니다"
        icon={FileText}
        actions={
          <Button onClick={handleNew}>
            <Plus className="w-4 h-4 mr-2" />
            신규 작성
          </Button>
        }
      />

      <SearchFilter 
        searchValue={searchTerm}
        onSearchChange={setSearchTerm}
        searchPlaceholder="검색..."
      />

      <StatCards stats={stats} />

      {filteredItems.length === 0 ? (
        <EmptyState 
          icon={FileText}
          title="항목이 없습니다"
          action={{ label: "첫 항목 작성", onClick: handleNew }}
        />
      ) : (
        <>
          {/* 데스크톱 테이블 */}
          <Card className="hidden md:block">
            <CardContent className="p-0">
              <Table>
                <TableHeader>
                  <TableRow>
                    <TableHead>이름</TableHead>
                    <TableHead>상태</TableHead>
                    <TableHead className="text-right">관리</TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {filteredItems.map(item => (
                    <TableRow key={item.id}>
                      <TableCell>{item.name}</TableCell>
                      <TableCell>
                        <Badge>{item.status}</Badge>
                      </TableCell>
                      <TableCell className="text-right">
                        <Button variant="ghost" size="sm">
                          <Edit className="w-4 h-4" />
                        </Button>
                      </TableCell>
                    </TableRow>
                  ))}
                </TableBody>
              </Table>
            </CardContent>
          </Card>

          {/* 모바일 카드 */}
          <div className="md:hidden">
            {filteredItems.map(item => (
              <MobileCard 
                key={item.id}
                title={item.name}
                badge={{ label: item.status }}
                fields={[
                  { label: "생성일", value: item.date }
                ]}
                actions={[
                  {
                    label: "수정",
                    icon: <Edit className="w-3 h-3 mr-1" />,
                    onClick: () => handleEdit(item)
                  }
                ]}
              />
            ))}
          </div>
        </>
      )}
    </PageLayout>
  );
}

Write 페이지 전체 구조

import { PageLayout } from "./common/PageLayout";
import { PageHeader } from "./common/PageHeader";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
import { ChevronDown, ChevronUp, Save, FileEdit } from "lucide-react";

export function MyWritePage({ onSave, onCancel }) {
  const [isBasicInfoOpen, setIsBasicInfoOpen] = useState(true);

  return (
    <PageLayout>
      <PageHeader 
        title="항목 작성"
        description="항목 정보를 입력하세요"
        icon={FileEdit}
        actions={
          <>
            <Button variant="outline" onClick={onCancel}>
              취소
            </Button>
            <Button onClick={handleSave}>
              <Save className="w-4 h-4 mr-2" />
              저장하기
            </Button>
          </>
        }
      />

      {/* 접이식 섹션 */}
      <Collapsible open={isBasicInfoOpen} onOpenChange={setIsBasicInfoOpen}>
        <Card>
          <CollapsibleTrigger className="w-full">
            <CardHeader className="flex flex-row items-center justify-between space-y-0 cursor-pointer hover:bg-muted/50">
              <CardTitle>기본 정보</CardTitle>
              {isBasicInfoOpen ? (
                <ChevronUp className="w-5 h-5 text-muted-foreground" />
              ) : (
                <ChevronDown className="w-5 h-5 text-muted-foreground" />
              )}
            </CardHeader>
          </CollapsibleTrigger>
          <CollapsibleContent>
            <CardContent className="space-y-4">
              <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
                <div>
                  <Label>이름</Label>
                  <Input />
                </div>
                <div>
                  <Label>상태</Label>
                  <Input />
                </div>
              </div>
            </CardContent>
          </CollapsibleContent>
        </Card>
      </Collapsible>
    </PageLayout>
  );
}

디자인 원칙

반응형 브레이크포인트

  • 모바일: < 768px - 카드 뷰, 단일 컬럼
  • 태블릿: 768px - 1024px - 2-3 컬럼 그리드
  • 데스크톱: > 1024px - 테이블 뷰, 4 컬럼 그리드

간격 (Spacing)

필수 규칙:

  • 모든 페이지는 PageLayout으로 감싸기 (자동으로 통일된 padding/spacing 적용)
  • 페이지 padding: p-4 md:p-6 (PageLayout 자동 적용)
  • 섹션 간격: space-y-4 md:space-y-6 (PageLayout 자동 적용)
  • 그리드 간격: gap-4
  • Card 내부 padding: p-4 md:p-6
  • Table이 포함된 Card: CardContentp-0 사용 (테이블이 꽉 차도록)

색상 패턴

통계 카드 아이콘 색상:

  • 파랑: text-blue-600 - 전체/기본
  • 초록: text-green-600 - 성공/금일
  • 주황: text-orange-600 - 경고/이번주
  • 보라: text-purple-600 - 정보/평균

타이포그래피

페이지 타이틀 구조 (필수 규칙):

  • 절대 금지: 직접 <h1> 태그나 커스텀 제목 작성
  • 필수 사용: PageHeader 컴포넌트 사용
  • PageHeader는 자동으로 올바른 타이포그래피 적용 (text-xl md:text-2xl)

잘못된 예시 (사용 금지):

<h1 className="text-3xl font-bold">주문 관리</h1>
<h1 className="text-2xl md:text-3xl font-bold">차량관리</h1>

올바른 예시 (필수):

<PageHeader 
  title="주문 관리"
  description="고객 주문 접수 및 진행 상황 관리"
  icon={ShoppingCart}
  actions={<Button>등록</Button>}
/>

타이포그래피 규칙:

  • 페이지 제목: text-xl md:text-2xl (PageHeader가 자동 적용)
  • 카드 제목: text-base
  • 본문: text-sm
  • 설명: text-sm text-muted-foreground

아이콘 크기

  • 헤더 아이콘: w-6 h-6
  • 버튼 아이콘: w-4 h-4
  • 작은 아이콘: w-3 h-3
  • 통계 아이콘: w-8 h-8 md:w-10 md:h-10

체크리스트

새 페이지를 만들 때 다음을 확인하세요:

필수 사항

  • PageLayout으로 감싸기 (maxWidth는 특별한 경우가 아니면 기본값 "full" 사용)
  • PageHeader 사용 (title, description, icon, actions) - 커스텀 h1 태그 절대 금지
  • 페이지 타이틀은 반드시 PageHeader 컴포넌트 사용 - text-3xl, text-2xl md:text-3xl 직접 사용 금지
  • 통계가 필요하면 StatCards 사용
  • 검색이 필요하면 SearchFilter 사용
  • 빈 상태에 EmptyState 사용

반응형 디자인

  • 데스크톱: Card + Table (테이블을 감싸는 Card만 표시)
  • 모바일: MobileCard (md:hidden)
  • 반응형 그리드: grid-cols-1 md:grid-cols-2 lg:grid-cols-4

레이아웃 규칙

  • Table이 포함된 Card의 CardContent는 p-0 사용
  • Dialog width: max-w-[95vw] sm:max-w-lg md:max-w-2xl 또는 max-w-[95vw] sm:max-w-xl md:max-w-4xl
  • Dialog에 DialogDescription 포함
  • 모든 액션 버튼에 아이콘 추가
  • Card 내부 padding: p-4 md:p-6 (Table이 있는 경우 제외)

디자인 시스템 통일 작업

완료된 페이지 (PageLayout + PageHeader 적용)

  • /components/VehicleManagement.tsx - 차량관리
  • /components/EquipmentManagement.tsx - 설비관리
  • /components/ItemManagement.tsx - 품목관리
  • /components/OrderManagement.tsx - 주문관리 NEW
  • /components/QuoteManagement3List.tsx - 견적관리 목록
  • /components/QuoteManagement3Write.tsx - 견적관리 작성

⚠️ 업데이트 필요 페이지 (커스텀 h1 사용 중)

다음 페이지들은 아직 커스텀 <h1 className="text-3xl font-bold"> 또는 <h1 className="text-2xl md:text-3xl font-bold">를 사용하고 있어 PageHeader 컴포넌트로 변경이 필요합니다:

품질관리:

  • (대부분 완료)

전자결재:

  • /components/ApprovalManagement.tsx - 전자결재

회계관리:

  • /components/AccountingManagement.tsx - 회계 관리
  • /components/ShippingManagement.tsx - 출고관리

인사관리:

  • /components/HRManagement.tsx - 인사관리

시스템관리:

  • /components/SystemManagement.tsx - 시스템 관리

자재관리:

  • /components/MaterialManagement.tsx - 자재 관리

대시보드:

  • /components/Dashboard.tsx - CEO/생산관리자/작업자 대시보드

업데이트 방법

기존 커스텀 헤더 코드:

<div className="flex justify-between items-center">
  <div>
    <h1 className="text-3xl font-bold text-foreground">주문 관리</h1>
    <p className="text-muted-foreground mt-1">고객 주문 접수  진행 상황 관리</p>
  </div>
  <div className="flex space-x-3">
    <Button>등록</Button>
  </div>
</div>

변경 후 PageHeader 사용:

import { PageHeader } from "./common/PageHeader";
import { ShoppingCart } from "lucide-react";

<PageHeader 
  title="주문 관리"
  description="고객 주문 접수 및 진행 상황 관리"
  icon={ShoppingCart}
  actions={
    <Button>
      <Plus className="w-4 h-4 mr-2" />
      등록
    </Button>
  }
/>

업데이트 체크리스트

각 페이지 업데이트 시 확인:

  1. PageLayout으로 전체 페이지 감싸기
  2. 커스텀 <h1> 태그 제거
  3. PageHeader 컴포넌트 import 및 사용
  4. 적절한 lucide-react 아이콘 추가
  5. actions prop에 버튼들 이동
  6. description prop에 설명 추가
  7. text-3xl 또는 text-2xl md:text-3xl 클래스 완전 제거

참고 파일

완벽하게 구현된 예시:

  • /components/VehicleManagement.tsx - 차량관리 (PageLayout + PageHeader 완벽 구현)
  • /components/EquipmentManagement.tsx - 설비관리 (PageLayout + PageHeader 완벽 구현)
  • /components/ItemManagement.tsx - 품목관리 (PageLayout + PageHeader 완벽 구현)
  • /components/QuoteManagement3List.tsx - 견적관리 목록
  • /components/QuoteManagement3Write.tsx - 견적관리 작성
  • /components/common/ - 모든 공통 컴포넌트