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

@@ -0,0 +1,57 @@
'use client';
import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ProgressBillingDetailForm } from '@/components/business/construction/progress-billing';
import { getProgressBillingDetail } from '@/components/business/construction/progress-billing/actions';
interface ProgressBillingEditPageProps {
params: Promise<{ id: string }>;
}
export default function ProgressBillingEditPage({ params }: ProgressBillingEditPageProps) {
const { id } = use(params);
const router = useRouter();
const [data, setData] = useState<Awaited<ReturnType<typeof getProgressBillingDetail>>['data']>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getProgressBillingDetail(id)
.then(result => {
if (result.success && result.data) {
setData(result.data);
} else {
setError('기성청구 정보를 찾을 수 없습니다.');
}
})
.catch(() => {
setError('기성청구 정보를 불러오는 중 오류가 발생했습니다.');
})
.finally(() => setIsLoading(false));
}, [id]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}
if (error || !data) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<div className="text-muted-foreground">{error || '기성청구 정보를 찾을 수 없습니다.'}</div>
<button
onClick={() => router.back()}
className="text-primary hover:underline"
>
</button>
</div>
);
}
return <ProgressBillingDetailForm mode="edit" billingId={id} initialData={data} />;
}

View File

@@ -0,0 +1,57 @@
'use client';
import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ProgressBillingDetailForm } from '@/components/business/construction/progress-billing';
import { getProgressBillingDetail } from '@/components/business/construction/progress-billing/actions';
interface ProgressBillingDetailPageProps {
params: Promise<{ id: string }>;
}
export default function ProgressBillingDetailPage({ params }: ProgressBillingDetailPageProps) {
const { id } = use(params);
const router = useRouter();
const [data, setData] = useState<Awaited<ReturnType<typeof getProgressBillingDetail>>['data']>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getProgressBillingDetail(id)
.then(result => {
if (result.success && result.data) {
setData(result.data);
} else {
setError('기성청구 정보를 찾을 수 없습니다.');
}
})
.catch(() => {
setError('기성청구 정보를 불러오는 중 오류가 발생했습니다.');
})
.finally(() => setIsLoading(false));
}, [id]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}
if (error || !data) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<div className="text-muted-foreground">{error || '기성청구 정보를 찾을 수 없습니다.'}</div>
<button
onClick={() => router.back()}
className="text-primary hover:underline"
>
</button>
</div>
);
}
return <ProgressBillingDetailForm mode="view" billingId={id} initialData={data} />;
}

View File

@@ -0,0 +1,38 @@
'use client';
import { useEffect, useState } from 'react';
import ProgressBillingManagementListClient from '@/components/business/construction/progress-billing/ProgressBillingManagementListClient';
import { getProgressBillingList, getProgressBillingStats } from '@/components/business/construction/progress-billing/actions';
import type { ProgressBilling, ProgressBillingStats } from '@/components/business/construction/progress-billing/types';
export default function ProgressBillingManagementPage() {
const [data, setData] = useState<ProgressBilling[]>([]);
const [stats, setStats] = useState<ProgressBillingStats | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
Promise.all([
getProgressBillingList({ size: 1000 }),
getProgressBillingStats(),
])
.then(([listResult, statsResult]) => {
if (listResult.success && listResult.data) {
setData(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
})
.finally(() => setIsLoading(false));
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}
return <ProgressBillingManagementListClient initialData={data} initialStats={stats} />;
}

View File

@@ -0,0 +1,16 @@
'use client';
import { use } from 'react';
import ConstructionDetailClient from '@/components/business/construction/management/ConstructionDetailClient';
interface PageProps {
params: Promise<{
id: string;
}>;
}
export default function ConstructionManagementEditPage({ params }: PageProps) {
const { id } = use(params);
return <ConstructionDetailClient id={id} mode="edit" />;
}

View File

@@ -0,0 +1,16 @@
'use client';
import { use } from 'react';
import ConstructionDetailClient from '@/components/business/construction/management/ConstructionDetailClient';
interface PageProps {
params: Promise<{
id: string;
}>;
}
export default function ConstructionManagementDetailPage({ params }: PageProps) {
const { id } = use(params);
return <ConstructionDetailClient id={id} mode="view" />;
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useEffect, useState } from 'react';
import ConstructionManagementListClient from '@/components/business/construction/management/ConstructionManagementListClient';
import {
getConstructionManagementList,
getConstructionManagementStats,
} from '@/components/business/construction/management/actions';
import type {
ConstructionManagement,
ConstructionManagementStats,
} from '@/components/business/construction/management/types';
export default function ConstructionManagementPage() {
const [data, setData] = useState<ConstructionManagement[]>([]);
const [stats, setStats] = useState<ConstructionManagementStats | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadData = async () => {
try {
const [listResult, statsResult] = await Promise.all([
getConstructionManagementList({ size: 1000 }),
getConstructionManagementStats(),
]);
if (listResult.success && listResult.data) {
setData(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch (error) {
console.error('Failed to load construction management data:', error);
} finally {
setIsLoading(false);
}
};
loadData();
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}
return <ConstructionManagementListClient initialData={data} initialStats={stats} />;
}

View File

@@ -0,0 +1,53 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
import { getIssue } from '@/components/business/construction/issue-management/actions';
import type { Issue } from '@/components/business/construction/issue-management/types';
export default function IssueEditPage() {
const params = useParams();
const id = params.id as string;
const [issue, setIssue] = useState<Issue | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadData = async () => {
try {
const result = await getIssue(id);
if (result.success && result.data) {
setIssue(result.data);
} else {
setError(result.error || '이슈를 찾을 수 없습니다.');
}
} catch {
setError('이슈 조회에 실패했습니다.');
} finally {
setIsLoading(false);
}
};
loadData();
}, [id]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-red-500">{error}</div>
</div>
);
}
return <IssueDetailForm issue={issue} mode="edit" />;
}

View File

@@ -0,0 +1,53 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
import { getIssue } from '@/components/business/construction/issue-management/actions';
import type { Issue } from '@/components/business/construction/issue-management/types';
export default function IssueDetailPage() {
const params = useParams();
const id = params.id as string;
const [issue, setIssue] = useState<Issue | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadData = async () => {
try {
const result = await getIssue(id);
if (result.success && result.data) {
setIssue(result.data);
} else {
setError(result.error || '이슈를 찾을 수 없습니다.');
}
} catch {
setError('이슈 조회에 실패했습니다.');
} finally {
setIsLoading(false);
}
};
loadData();
}, [id]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-red-500">{error}</div>
</div>
);
}
return <IssueDetailForm issue={issue} mode="view" />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
export default function IssueNewPage() {
return <IssueDetailForm mode="create" />;
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useEffect, useState } from 'react';
import IssueManagementListClient from '@/components/business/construction/issue-management/IssueManagementListClient';
import {
getIssueList,
getIssueStats,
} from '@/components/business/construction/issue-management/actions';
import type {
Issue,
IssueStats,
} from '@/components/business/construction/issue-management/types';
export default function IssueManagementPage() {
const [data, setData] = useState<Issue[]>([]);
const [stats, setStats] = useState<IssueStats | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadData = async () => {
try {
const [listResult, statsResult] = await Promise.all([
getIssueList({ size: 1000 }),
getIssueStats(),
]);
if (listResult.success && listResult.data) {
setData(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch (error) {
console.error('Failed to load issue management data:', error);
} finally {
setIsLoading(false);
}
};
loadData();
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}
return <IssueManagementListClient initialData={data} initialStats={stats} />;
}

View File

@@ -0,0 +1,17 @@
'use client';
import { use } from 'react';
import ProjectDetailClient from '@/components/business/construction/management/ProjectDetailClient';
interface PageProps {
params: Promise<{
id: string;
locale: string;
}>;
}
export default function ProjectDetailPage({ params }: PageProps) {
const { id } = use(params);
return <ProjectDetailClient projectId={id} />;
}

View File

@@ -0,0 +1,5 @@
import { UtilityManagementListClient } from '@/components/business/construction/utility-management';
export default function UtilityManagementPage() {
return <UtilityManagementListClient />;
}

View File

@@ -0,0 +1,32 @@
'use client';
import { useEffect, useState } from 'react';
import WorkerStatusListClient from '@/components/business/construction/worker-status/WorkerStatusListClient';
import { getWorkerStatusList, getWorkerStatusStats } from '@/components/business/construction/worker-status/actions';
import type { WorkerStatus, WorkerStatusStats } from '@/components/business/construction/worker-status/types';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
export default function WorkerStatusPage() {
const [data, setData] = useState<WorkerStatus[]>([]);
const [stats, setStats] = useState<WorkerStatusStats | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
Promise.all([getWorkerStatusList(), getWorkerStatusStats()])
.then(([listResult, statsResult]) => {
if (listResult.success && listResult.data) {
setData(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
})
.finally(() => setIsLoading(false));
}, []);
if (isLoading) {
return <ContentLoadingSpinner />;
}
return <WorkerStatusListClient initialData={data} initialStats={stats} />;
}

View File

@@ -284,6 +284,25 @@
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Bell ringing animation for notifications */
@keyframes bell-ring {
0%, 100% { transform: rotate(0deg); }
10% { transform: rotate(14deg); }
20% { transform: rotate(-12deg); }
30% { transform: rotate(10deg); }
40% { transform: rotate(-8deg); }
50% { transform: rotate(6deg); }
60% { transform: rotate(-4deg); }
70% { transform: rotate(2deg); }
80% { transform: rotate(-1deg); }
90% { transform: rotate(0deg); }
}
.animate-bell-ring {
animation: bell-ring 1s ease-in-out infinite;
transform-origin: top center;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;