feat(WEB): 공사관리 시스템 및 CEO 대시보드 기능 확장

- 공사현장관리: 프로젝트 상세, 공정관리, 칸반보드 구현
- 이슈관리: 현장 이슈 등록/조회 기능 추가
- 근로자현황: 일별 근로자 출역 현황 페이지 추가
- 유틸리티관리: 현장 유틸리티 관리 페이지 추가
- 기성청구: 기성청구 관리 페이지 추가
- CEO 대시보드: 현황판(StatusBoardSection) 추가, 설정 다이얼로그 개선
- 발주관리: 모바일 필터 적용, 리스트 UI 개선
- 공용 컴포넌트: MobileFilter, IntegratedListTemplateV2 개선, CalendarHeader 반응형 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-13 17:18:29 +09:00
parent d036ce4f42
commit db47a15544
85 changed files with 12940 additions and 499 deletions

View File

@@ -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>
)}

View File

@@ -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>
{/* 설명 */}

View 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>
);
}