feat(WEB): 공사관리 시스템 및 CEO 대시보드 기능 확장
- 공사현장관리: 프로젝트 상세, 공정관리, 칸반보드 구현 - 이슈관리: 현장 이슈 등록/조회 기능 추가 - 근로자현황: 일별 근로자 출역 현황 페이지 추가 - 유틸리티관리: 현장 유틸리티 관리 페이지 추가 - 기성청구: 기성청구 관리 페이지 추가 - CEO 대시보드: 현황판(StatusBoardSection) 추가, 설정 다이얼로그 개선 - 발주관리: 모바일 필터 적용, 리스트 UI 개선 - 공용 컴포넌트: MobileFilter, IntegratedListTemplateV2 개선, CalendarHeader 반응형 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
|
||||
|
||||
export default function IssueNewPage() {
|
||||
return <IssueDetailForm mode="create" />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UtilityManagementListClient } from '@/components/business/construction/utility-management';
|
||||
|
||||
export default function UtilityManagementPage() {
|
||||
return <UtilityManagementListClient />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user