feat(WEB): 공사관리 시스템 및 CEO 대시보드 기능 확장
- 공사현장관리: 프로젝트 상세, 공정관리, 칸반보드 구현 - 이슈관리: 현장 이슈 등록/조회 기능 추가 - 근로자현황: 일별 근로자 출역 현황 페이지 추가 - 유틸리티관리: 현장 유틸리티 관리 페이지 추가 - 기성청구: 기성청구 관리 페이지 추가 - CEO 대시보드: 현황판(StatusBoardSection) 추가, 설정 다이얼로그 개선 - 발주관리: 모바일 필터 적용, 리스트 UI 개선 - 공용 컴포넌트: MobileFilter, IntegratedListTemplateV2 개선, CalendarHeader 반응형 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -121,8 +121,9 @@ export function DateRangeSelector({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{/* 상단: 날짜 선택 + 기간 버튼 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
{/* 1줄: 날짜 + 프리셋 */}
|
||||
{/* 태블릿/모바일(~1279px): 세로 배치 / PC(1280px+): 가로 한 줄 */}
|
||||
<div className="flex flex-col xl:flex-row xl:items-center gap-2">
|
||||
{/* 날짜 범위 선택 (Input type="date") */}
|
||||
{!hideDateInputs && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
@@ -145,7 +146,7 @@ export function DateRangeSelector({
|
||||
{/* 기간 버튼들 - 모바일에서 가로 스크롤 */}
|
||||
{!hidePresets && presets.length > 0 && (
|
||||
<div
|
||||
className="overflow-x-auto -mx-1 px-1"
|
||||
className="overflow-x-auto -mx-1 px-1 xl:overflow-visible xl:mx-0 xl:px-0"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-max [&::-webkit-scrollbar]:hidden">
|
||||
@@ -165,9 +166,9 @@ export function DateRangeSelector({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단: 추가 액션 버튼들 */}
|
||||
{/* 2줄: 추가 액션 버튼들 - 항상 별도 줄, 오른쪽 정렬 */}
|
||||
{extraActions && (
|
||||
<div className="flex items-center gap-2 flex-wrap sm:justify-end">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{extraActions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface MobileCardProps {
|
||||
description?: string;
|
||||
badge?: string;
|
||||
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
badgeClassName?: string;
|
||||
isSelected?: boolean;
|
||||
onToggle?: () => void;
|
||||
onClick?: () => void;
|
||||
@@ -31,6 +32,7 @@ export function MobileCard({
|
||||
description,
|
||||
badge,
|
||||
badgeVariant = 'default',
|
||||
badgeClassName,
|
||||
isSelected = false,
|
||||
onToggle,
|
||||
onClick,
|
||||
@@ -63,7 +65,7 @@ export function MobileCard({
|
||||
<div className="text-sm text-muted-foreground">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
{badge && <Badge variant={badgeVariant}>{badge}</Badge>}
|
||||
{badge && <Badge variant={badgeVariant} className={badgeClassName}>{badge}</Badge>}
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
|
||||
335
src/components/molecules/MobileFilter.tsx
Normal file
335
src/components/molecules/MobileFilter.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 모바일 종합 필터 컴포넌트
|
||||
*
|
||||
* PC에서 여러 개의 필터를 모바일에서는 하나의 바텀시트로 통합
|
||||
* - 단일선택(single), 다중선택(multi) 필드 지원
|
||||
* - 적용된 필터 개수 배지 표시
|
||||
* - 초기화/적용 버튼
|
||||
* - PC와 동일한 셀렉트 박스 형태로 컴팩트하게 표시
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import { Filter, X, Check, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerFooter,
|
||||
DrawerClose,
|
||||
} from '@/components/ui/drawer';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// 필터 옵션 타입
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 필터 필드 설정 타입
|
||||
export interface FilterFieldConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'single' | 'multi';
|
||||
options: FilterOption[];
|
||||
allOptionLabel?: string; // single 타입에서 "전체" 옵션 라벨 (기본: '전체')
|
||||
}
|
||||
|
||||
// 필터 값 타입
|
||||
export type FilterValues = Record<string, string | string[]>;
|
||||
|
||||
// MobileFilter Props
|
||||
export interface MobileFilterProps {
|
||||
fields: FilterFieldConfig[];
|
||||
values: FilterValues;
|
||||
onChange: (key: string, value: string | string[]) => void;
|
||||
onReset: () => void;
|
||||
onApply?: () => void;
|
||||
buttonLabel?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
/** 적용된 필터를 버튼 아래 태그로 표시할지 여부 (기본: true) */
|
||||
showAppliedTags?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 적용된 필터 개수 계산
|
||||
*/
|
||||
function countActiveFilters(
|
||||
fields: FilterFieldConfig[],
|
||||
values: FilterValues
|
||||
): number {
|
||||
let count = 0;
|
||||
|
||||
for (const field of fields) {
|
||||
const value = values[field.key];
|
||||
|
||||
if (field.type === 'single') {
|
||||
// single: 'all'이 아니면 활성화
|
||||
if (value && value !== 'all') {
|
||||
count++;
|
||||
}
|
||||
} else {
|
||||
// multi: 배열에 값이 있으면 활성화
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 필드 요약 텍스트 생성
|
||||
*/
|
||||
function getFieldSummary(
|
||||
field: FilterFieldConfig,
|
||||
value: string | string[] | undefined
|
||||
): string {
|
||||
if (field.type === 'single') {
|
||||
if (!value || value === 'all') return field.allOptionLabel || '전체';
|
||||
const option = field.options.find((opt) => opt.value === value);
|
||||
return option?.label || '전체';
|
||||
} else {
|
||||
const arr = Array.isArray(value) ? value : [];
|
||||
if (arr.length === 0) return '전체';
|
||||
if (arr.length === field.options.length) return '전체';
|
||||
const firstOption = field.options.find((opt) => arr.includes(opt.value));
|
||||
if (arr.length === 1) return firstOption?.label || '';
|
||||
return `${firstOption?.label} 외 ${arr.length - 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 적용된 필터 태그 목록 생성
|
||||
*/
|
||||
function getAppliedFilterTags(
|
||||
fields: FilterFieldConfig[],
|
||||
values: FilterValues
|
||||
): Array<{ key: string; label: string; displayValue: string }> {
|
||||
const tags: Array<{ key: string; label: string; displayValue: string }> = [];
|
||||
|
||||
for (const field of fields) {
|
||||
const value = values[field.key];
|
||||
|
||||
if (field.type === 'single') {
|
||||
// single: 'all'이 아니면 태그 추가
|
||||
if (value && value !== 'all') {
|
||||
const option = field.options.find((opt) => opt.value === value);
|
||||
if (option) {
|
||||
tags.push({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
displayValue: option.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// multi: 배열에 값이 있으면 태그 추가
|
||||
const arr = Array.isArray(value) ? value : [];
|
||||
if (arr.length > 0) {
|
||||
const firstOption = field.options.find((opt) => arr.includes(opt.value));
|
||||
const displayValue =
|
||||
arr.length === 1
|
||||
? firstOption?.label || ''
|
||||
: `${firstOption?.label} 외 ${arr.length - 1}`;
|
||||
tags.push({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
displayValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function MobileFilter({
|
||||
fields,
|
||||
values,
|
||||
onChange,
|
||||
onReset,
|
||||
onApply,
|
||||
buttonLabel = '필터',
|
||||
title = '검색 필터',
|
||||
className,
|
||||
showAppliedTags = true,
|
||||
}: MobileFilterProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const activeCount = countActiveFilters(fields, values);
|
||||
const appliedTags = showAppliedTags ? getAppliedFilterTags(fields, values) : [];
|
||||
|
||||
// 개별 필터 초기화 핸들러
|
||||
const handleClearFilter = (key: string) => {
|
||||
const field = fields.find((f) => f.key === key);
|
||||
if (field) {
|
||||
if (field.type === 'single') {
|
||||
onChange(key, 'all');
|
||||
} else {
|
||||
onChange(key, []);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 초기화 핸들러
|
||||
const handleReset = () => {
|
||||
onReset();
|
||||
};
|
||||
|
||||
// 적용 핸들러
|
||||
const handleApply = () => {
|
||||
if (onApply) {
|
||||
onApply();
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 상단: 필터 버튼 + 적용된 태그 */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 필터 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('gap-2', className)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>{buttonLabel}</span>
|
||||
{activeCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 min-w-5 rounded-full px-1.5 text-xs bg-primary text-primary-foreground"
|
||||
>
|
||||
{activeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 적용된 필터 태그 */}
|
||||
{showAppliedTags && appliedTags.length > 0 && (
|
||||
<>
|
||||
{appliedTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.key}
|
||||
variant="secondary"
|
||||
className="gap-1 pr-1 text-xs font-normal bg-muted hover:bg-muted"
|
||||
>
|
||||
<span className="text-muted-foreground">{tag.label}:</span>
|
||||
<span>{tag.displayValue}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClearFilter(tag.key)}
|
||||
className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{/* 전체 초기화 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
초기화
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 Drawer (바텀시트) */}
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerContent className="max-h-[85vh] flex flex-col">
|
||||
<DrawerHeader className="border-b flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
|
||||
{/* 컴팩트한 셀렉트 박스 형태 - 스크롤 가능 */}
|
||||
<div className="px-4 py-4 space-y-4 overflow-y-auto flex-1">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
{field.label}
|
||||
</Label>
|
||||
{field.type === 'single' ? (
|
||||
// 단일선택: Select
|
||||
<Select
|
||||
value={(values[field.key] as string) || 'all'}
|
||||
onValueChange={(value) => onChange(field.key, value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={field.allOptionLabel || '전체'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{field.allOptionLabel || '전체'}
|
||||
</SelectItem>
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
// 다중선택: MultiSelectCombobox
|
||||
<MultiSelectCombobox
|
||||
options={field.options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
}))}
|
||||
value={(values[field.key] as string[]) || []}
|
||||
onChange={(value) => onChange(field.key, value)}
|
||||
placeholder="전체"
|
||||
searchPlaceholder={`${field.label} 검색...`}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DrawerFooter className="border-t flex-row gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 gap-2"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button className="flex-1 gap-2" onClick={handleApply}>
|
||||
<Check className="h-4 w-4" />
|
||||
적용
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user