Files
sam-react-prod/src/components/organisms/ListMobileCard.tsx
byeongcheolryu d7f491fa84 refactor: 로딩 스피너 표준화 및 프로젝트 헬스 개선
- LoadingSpinner 컴포넌트 5가지 변형 구현
  - LoadingSpinner (인라인/버튼용)
  - ContentLoadingSpinner (상세/수정 페이지)
  - PageLoadingSpinner (페이지 전환)
  - TableLoadingSpinner (테이블/리스트)
  - ButtonSpinner (버튼 내부)
- 18개+ 페이지 로딩 UI 표준화
  - HR 페이지 (사원, 휴가, 부서, 급여, 근태)
  - 영업 페이지 (견적, 거래처)
  - 게시판, 팝업관리, 품목기준정보
- API 키 보안 개선 (NEXT_PUBLIC_API_KEY → API_KEY)
- Textarea 다크모드 스타일 개선
- DropdownField Radix UI Select 버그 수정 (key prop)
- 프로젝트 헬스 개선 계획서 문서화

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 14:33:11 +09:00

184 lines
4.6 KiB
TypeScript

"use client";
import { ReactNode } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
/**
* 목록 모바일 카드 컴포넌트
*
* 모바일 환경에서 사용하는 공통 목록 카드 컴포넌트입니다.
* 체크박스, 헤더, 뱃지, 정보 그리드, 액션 버튼을 포함합니다.
*
* @example
* ```tsx
* <ListMobileCard
* id="item-1"
* isSelected={false}
* onToggleSelection={() => handleToggle("item-1")}
* onCardClick={() => handleView("item-1")}
* headerBadges={[
* <Badge key="num">#{1}</Badge>,
* <Badge key="code">Q2024-001</Badge>
* ]}
* title="ABC건설"
* statusBadge={<StatusBadge variant="success" label="완료" />}
* infoGrid={
* <div className="grid grid-cols-2 gap-x-4 gap-y-3">
* <InfoField label="현장명" value="강남 현장" />
* <InfoField label="담당자" value="김철수" />
* </div>
* }
* actions={isSelected && <ActionButtonGroup actions={actions} isMobile />}
* />
* ```
*/
export interface ListMobileCardProps {
/** 아이템 고유 ID */
id: string;
/** 선택 상태 */
isSelected: boolean;
/** 체크박스 토글 핸들러 */
onToggleSelection: () => void;
/** 카드 클릭 핸들러 */
onCardClick?: () => void;
/** 카드 클릭 핸들러 (onCardClick 별칭) */
onClick?: () => void;
/** 체크박스 표시 여부 */
showCheckbox?: boolean;
/** 헤더 영역 뱃지들 (번호, 코드 등) */
headerBadges?: ReactNode;
/** 카드 제목 (주요 정보) */
title: string | ReactNode;
/** 상태 뱃지 (우측 상단) */
statusBadge?: ReactNode;
/** 정보 그리드 영역 */
infoGrid: ReactNode;
/** 액션 버튼 영역 */
actions?: ReactNode;
/** 추가 className */
className?: string;
/** 카드 상단 추가 콘텐츠 */
topContent?: ReactNode;
/** 카드 하단 추가 콘텐츠 */
bottomContent?: ReactNode;
}
/**
* 정보 필드 컴포넌트
*
* 카드 내부의 레이블-값 쌍을 표시합니다.
*/
export interface InfoFieldProps {
label: string;
value: string | number | ReactNode;
valueClassName?: string;
/** 추가 className */
className?: string;
}
export function InfoField({ label, value, valueClassName = "", className = "" }: InfoFieldProps) {
return (
<div className={`space-y-0.5 ${className}`}>
<p className="text-xs text-muted-foreground">{label}</p>
<div className={`text-sm font-medium ${valueClassName}`}>{value}</div>
</div>
);
}
export function ListMobileCard({
id,
isSelected,
onToggleSelection,
onCardClick,
onClick,
showCheckbox = true,
headerBadges,
title,
statusBadge,
infoGrid,
actions,
className = "",
topContent,
bottomContent
}: ListMobileCardProps) {
const handleCardClick = onClick || onCardClick;
return (
<div
className={`border rounded-lg p-5 space-y-4 bg-white dark:bg-card transition-all cursor-pointer ${
isSelected
? 'border-blue-500 bg-blue-50/50'
: 'border-gray-200 hover:border-primary/50'
} ${className}`}
onClick={handleCardClick}
>
{/* 상단 추가 콘텐츠 */}
{topContent}
{/* 헤더: 체크박스 + 뱃지 + 제목 / 우측에 상태 뱃지 */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
{showCheckbox && (
<Checkbox
checked={isSelected}
onCheckedChange={onToggleSelection}
onClick={(e) => e.stopPropagation()}
className="mt-0.5 h-5 w-5"
/>
)}
<div className="flex-1 min-w-0">
{/* 헤더 뱃지들 (번호, 코드 등) */}
{headerBadges && (
<div className="flex items-center gap-2 flex-wrap mb-2">
{headerBadges}
</div>
)}
{/* 제목 */}
<h3 className="font-semibold text-gray-900 font-bold whitespace-nowrap">
{title}
</h3>
</div>
</div>
{/* 우측 상단: 상태 뱃지 */}
{statusBadge && (
<div className="shrink-0">
{statusBadge}
</div>
)}
</div>
{/* 구분선 */}
<Separator className="bg-gray-100" />
{/* 정보 그리드 */}
{infoGrid}
{/* 액션 버튼 - 선택된 경우만 표시 */}
{actions && (
<>
<Separator className="bg-gray-100" />
{actions}
</>
)}
{/* 하단 추가 콘텐츠 */}
{bottomContent}
</div>
);
}