refactor(WEB): Server Component → Client Component 전면 마이그레이션
- 53개 페이지를 Server Component에서 Client Component로 변환 - Next.js 15에서 Server Component 렌더링 중 쿠키 수정 불가 이슈 해결 - 폐쇄형 ERP 시스템 특성상 SEO 불필요, Client Component 사용이 적합 주요 변경사항: - 모든 페이지에 'use client' 지시어 추가 - use(params) 훅으로 async params 처리 - useState + useEffect로 데이터 페칭 패턴 적용 - skipTokenRefresh 옵션 및 관련 코드 제거 (더 이상 필요 없음) 변환된 페이지: - Settings: 4개 (account-info, notification-settings, permissions, popup-management) - Accounting: 9개 (vendors, sales, deposits, bills, withdrawals, expected-expenses, bad-debt-collection) - Sales: 4개 (quote-management, pricing-management) - Production/Quality/Master-data: 6개 - Material/Outbound: 4개 - Construction: 22개 - Other: 4개 (payment-history, subscription, dev/test-urls) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
# Server Component → Client Component 마이그레이션 계획서
|
||||
|
||||
## 배경
|
||||
- **문제**: Server Component에서 API 호출 시 토큰 갱신(쿠키 수정)이 불가능
|
||||
- **원인**: Next.js 15에서 Server Component 렌더링 중 쿠키 수정 금지
|
||||
- **영향**: 토큰 만료 시 기본값 표시 → 데이터 덮어쓰기 위험
|
||||
- **결정**: 폐쇄형 사이트로 SEO 불필요, Client Component로 전환
|
||||
|
||||
## 변경 대상 (53개 페이지)
|
||||
|
||||
### Settings (4개)
|
||||
- [ ] settings/notification-settings/page.tsx
|
||||
- [ ] settings/popup-management/page.tsx
|
||||
- [ ] settings/permissions/[id]/page.tsx
|
||||
- [ ] settings/account-info/page.tsx
|
||||
|
||||
### Accounting (9개)
|
||||
- [ ] accounting/vendors/page.tsx
|
||||
- [ ] accounting/sales/page.tsx
|
||||
- [ ] accounting/deposits/page.tsx
|
||||
- [ ] accounting/bills/page.tsx
|
||||
- [ ] accounting/withdrawals/page.tsx
|
||||
- [ ] accounting/expected-expenses/page.tsx
|
||||
- [ ] accounting/bad-debt-collection/page.tsx
|
||||
- [ ] accounting/bad-debt-collection/[id]/page.tsx
|
||||
- [ ] accounting/bad-debt-collection/[id]/edit/page.tsx
|
||||
|
||||
### Sales (4개)
|
||||
- [ ] sales/quote-management/page.tsx
|
||||
- [ ] sales/pricing-management/page.tsx
|
||||
- [ ] sales/pricing-management/[id]/edit/page.tsx
|
||||
- [ ] sales/pricing-management/create/page.tsx
|
||||
|
||||
### Production (3개)
|
||||
- [ ] production/work-orders/[id]/page.tsx
|
||||
- [ ] production/screen-production/page.tsx
|
||||
- [ ] production/screen-production/[id]/page.tsx
|
||||
|
||||
### Quality (1개)
|
||||
- [ ] quality/inspections/[id]/page.tsx
|
||||
|
||||
### Master Data (2개)
|
||||
- [ ] master-data/process-management/[id]/page.tsx
|
||||
- [ ] master-data/process-management/[id]/edit/page.tsx
|
||||
|
||||
### Material (2개)
|
||||
- [ ] material/stock-status/[id]/page.tsx
|
||||
- [ ] material/receiving-management/[id]/page.tsx
|
||||
|
||||
### Outbound (2개)
|
||||
- [ ] outbound/shipments/[id]/page.tsx
|
||||
- [ ] outbound/shipments/[id]/edit/page.tsx
|
||||
|
||||
### Construction - Order (8개)
|
||||
- [ ] construction/order/order-management/[id]/page.tsx
|
||||
- [ ] construction/order/order-management/[id]/edit/page.tsx
|
||||
- [ ] construction/order/site-management/[id]/page.tsx
|
||||
- [ ] construction/order/site-management/[id]/edit/page.tsx
|
||||
- [ ] construction/order/structure-review/[id]/page.tsx
|
||||
- [ ] construction/order/structure-review/[id]/edit/page.tsx
|
||||
- [ ] construction/order/base-info/items/[id]/page.tsx
|
||||
- [ ] construction/order/base-info/pricing/[id]/page.tsx
|
||||
- [ ] construction/order/base-info/pricing/[id]/edit/page.tsx
|
||||
- [ ] construction/order/base-info/labor/[id]/page.tsx
|
||||
|
||||
### Construction - Project/Bidding (8개)
|
||||
- [ ] construction/project/bidding/[id]/page.tsx
|
||||
- [ ] construction/project/bidding/[id]/edit/page.tsx
|
||||
- [ ] construction/project/bidding/site-briefings/[id]/page.tsx
|
||||
- [ ] construction/project/bidding/site-briefings/[id]/edit/page.tsx
|
||||
- [ ] construction/project/bidding/estimates/[id]/page.tsx
|
||||
- [ ] construction/project/bidding/estimates/[id]/edit/page.tsx
|
||||
- [ ] construction/project/bidding/partners/[id]/page.tsx
|
||||
- [ ] construction/project/bidding/partners/[id]/edit/page.tsx
|
||||
|
||||
### Construction - Project/Contract (4개)
|
||||
- [ ] construction/project/contract/[id]/page.tsx
|
||||
- [ ] construction/project/contract/[id]/edit/page.tsx
|
||||
- [ ] construction/project/contract/handover-report/[id]/page.tsx
|
||||
- [ ] construction/project/contract/handover-report/[id]/edit/page.tsx
|
||||
|
||||
### Others (4개)
|
||||
- [ ] payment-history/page.tsx
|
||||
- [ ] subscription/page.tsx
|
||||
- [ ] dev/test-urls/page.tsx
|
||||
- [ ] dev/construction-test-urls/page.tsx
|
||||
|
||||
## 변환 패턴
|
||||
|
||||
### Before (Server Component)
|
||||
```typescript
|
||||
import { Component } from '@/components/...';
|
||||
import { getData } from '@/components/.../actions';
|
||||
|
||||
export default async function Page() {
|
||||
const result = await getData();
|
||||
return <Component initialData={result.data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### After (Client Component)
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Component } from '@/components/...';
|
||||
import { getData } from '@/components/.../actions';
|
||||
import { DEFAULT_DATA } from '@/components/.../types';
|
||||
|
||||
export default function Page() {
|
||||
const [data, setData] = useState(DEFAULT_DATA);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getData()
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>로딩 중...</div>;
|
||||
}
|
||||
|
||||
return <Component initialData={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
## 추가 작업
|
||||
|
||||
### 1. RULES.md 업데이트
|
||||
- Client Component 사용 원칙 추가
|
||||
- SEO 불필요 폐쇄형 사이트 명시
|
||||
|
||||
### 2. fetch-wrapper.ts 정리
|
||||
- skipTokenRefresh 옵션 제거 (불필요해짐)
|
||||
|
||||
### 3. actions.ts 정리
|
||||
- skipTokenRefresh 관련 코드 제거
|
||||
|
||||
## 진행 상태
|
||||
- 시작일: 2026-01-09
|
||||
- 현재 상태: 진행 중
|
||||
@@ -1,18 +1,58 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getBadDebtById } from '@/components/accounting/BadDebtCollection/actions';
|
||||
import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail';
|
||||
import type { BadDebtRecord } from '@/components/accounting/BadDebtCollection/types';
|
||||
|
||||
interface EditBadDebtPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditBadDebtPage({ params }: EditBadDebtPageProps) {
|
||||
const { id } = await params;
|
||||
const badDebt = await getBadDebtById(id);
|
||||
export default function EditBadDebtPage({ params }: EditBadDebtPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<BadDebtRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!badDebt) {
|
||||
notFound();
|
||||
useEffect(() => {
|
||||
getBadDebtById(id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setData(result);
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
return <BadDebtDetail mode="edit" recordId={id} initialData={badDebt} />;
|
||||
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 <BadDebtDetail mode="edit" recordId={id} initialData={data} />;
|
||||
}
|
||||
@@ -1,18 +1,58 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getBadDebtById } from '@/components/accounting/BadDebtCollection/actions';
|
||||
import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail';
|
||||
import type { BadDebtRecord } from '@/components/accounting/BadDebtCollection/types';
|
||||
|
||||
interface BadDebtDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function BadDebtDetailPage({ params }: BadDebtDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const badDebt = await getBadDebtById(id);
|
||||
export default function BadDebtDetailPage({ params }: BadDebtDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<BadDebtRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!badDebt) {
|
||||
notFound();
|
||||
useEffect(() => {
|
||||
getBadDebtById(id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setData(result);
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
return <BadDebtDetail mode="view" recordId={id} initialData={badDebt} />;
|
||||
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 <BadDebtDetail mode="view" recordId={id} initialData={data} />;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 악성채권 추심관리 목록 페이지
|
||||
*
|
||||
@@ -7,23 +9,48 @@
|
||||
* - GET /api/v1/bad-debts/summary - 통계 정보
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BadDebtCollection } from '@/components/accounting/BadDebtCollection';
|
||||
import { getBadDebts, getBadDebtSummary } from '@/components/accounting/BadDebtCollection/actions';
|
||||
import type { BadDebtSummary } from '@/components/accounting/BadDebtCollection/types';
|
||||
|
||||
export default async function BadDebtCollectionPage() {
|
||||
// 서버에서 데이터 병렬 조회
|
||||
const [badDebts, summary] = await Promise.all([
|
||||
getBadDebts({ size: 100 }),
|
||||
getBadDebtSummary(),
|
||||
]);
|
||||
const DEFAULT_SUMMARY: BadDebtSummary = {
|
||||
totalCount: 0,
|
||||
totalAmount: 0,
|
||||
collectedAmount: 0,
|
||||
pendingAmount: 0,
|
||||
collectionRate: 0,
|
||||
};
|
||||
|
||||
console.log('[BadDebtPage] Data count:', badDebts.length);
|
||||
console.log('[BadDebtPage] Summary:', summary);
|
||||
export default function BadDebtCollectionPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getBadDebts>>>([]);
|
||||
const [summary, setSummary] = useState<BadDebtSummary>(DEFAULT_SUMMARY);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getBadDebts({ size: 100 }),
|
||||
getBadDebtSummary(),
|
||||
])
|
||||
.then(([badDebts, summaryResult]) => {
|
||||
setData(badDebts);
|
||||
setSummary(summaryResult);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BadDebtCollection
|
||||
initialData={badDebts}
|
||||
initialData={data}
|
||||
initialSummary={summary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +1,46 @@
|
||||
import { cookies } from 'next/headers';
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { BillManagementClient } from '@/components/accounting/BillManagement/BillManagementClient';
|
||||
import type { BillRecord, BillApiData } from '@/components/accounting/BillManagement/types';
|
||||
import { transformApiToFrontend } from '@/components/accounting/BillManagement/types';
|
||||
import { getBills } from '@/components/accounting/BillManagement/actions';
|
||||
import type { BillRecord } from '@/components/accounting/BillManagement/types';
|
||||
|
||||
interface BillsPageProps {
|
||||
searchParams: Promise<{
|
||||
vendorId?: string;
|
||||
type?: string;
|
||||
page?: string;
|
||||
}>;
|
||||
}
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 20,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
export default function BillsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const vendorId = searchParams.get('vendorId') || undefined;
|
||||
const billType = searchParams.get('type') || 'received';
|
||||
const page = searchParams.get('page') ? parseInt(searchParams.get('page')!) : 1;
|
||||
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
};
|
||||
}
|
||||
const [data, setData] = useState<BillRecord[]>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function getBills(params: {
|
||||
billType?: string;
|
||||
page?: number;
|
||||
}): Promise<{
|
||||
data: BillRecord[];
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
};
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const queryParams = new URLSearchParams();
|
||||
useEffect(() => {
|
||||
getBills({ billType, page, perPage: 20 })
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [billType, page]);
|
||||
|
||||
if (params.billType && params.billType !== 'all') {
|
||||
queryParams.append('bill_type', params.billType);
|
||||
}
|
||||
if (params.page) {
|
||||
queryParams.append('page', String(params.page));
|
||||
}
|
||||
queryParams.append('per_page', '20');
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bills?${queryParams.toString()}`,
|
||||
{ method: 'GET', headers, cache: 'no-store' }
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[BillsPage] Fetch error:', response.status);
|
||||
return {
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const paginatedData = result.data as {
|
||||
data: BillApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
return {
|
||||
data: paginatedData.data.map(transformApiToFrontend),
|
||||
pagination: {
|
||||
currentPage: paginatedData.current_page,
|
||||
lastPage: paginatedData.last_page,
|
||||
perPage: paginatedData.per_page,
|
||||
total: paginatedData.total,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[BillsPage] Fetch error:', error);
|
||||
return {
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BillsPage({ searchParams }: BillsPageProps) {
|
||||
const params = await searchParams;
|
||||
const vendorId = params.vendorId;
|
||||
const billType = params.type || 'received';
|
||||
const page = params.page ? parseInt(params.page) : 1;
|
||||
|
||||
const { data, pagination } = await getBills({ billType, page });
|
||||
|
||||
return (
|
||||
<BillManagementClient
|
||||
@@ -110,4 +50,4 @@ export default async function BillsPage({ searchParams }: BillsPageProps) {
|
||||
initialBillType={billType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DepositManagement } from '@/components/accounting/DepositManagement';
|
||||
import { getDeposits } from '@/components/accounting/DepositManagement/actions';
|
||||
|
||||
export default async function DepositsPage() {
|
||||
const result = await getDeposits({ perPage: 100 });
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 100,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export default function DepositsPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getDeposits>>['data']>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getDeposits({ perPage: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DepositManagement
|
||||
initialData={result.data}
|
||||
initialPagination={result.pagination}
|
||||
initialData={data}
|
||||
initialPagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ExpectedExpenseManagement } from '@/components/accounting/ExpectedExpenseManagement';
|
||||
import { getExpectedExpenses } from '@/components/accounting/ExpectedExpenseManagement/actions';
|
||||
|
||||
export default async function ExpectedExpensesPage() {
|
||||
// 서버에서 초기 데이터 로드
|
||||
const result = await getExpectedExpenses({
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
sortBy: 'expected_payment_date',
|
||||
sortDir: 'asc',
|
||||
});
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 50,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export default function ExpectedExpensesPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getExpectedExpenses>>['data']>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getExpectedExpenses({
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
sortBy: 'expected_payment_date',
|
||||
sortDir: 'asc',
|
||||
})
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpectedExpenseManagement
|
||||
initialData={result.data}
|
||||
pagination={result.pagination}
|
||||
initialData={data}
|
||||
pagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SalesManagement } from '@/components/accounting/SalesManagement';
|
||||
import { getSales } from '@/components/accounting/SalesManagement/actions';
|
||||
|
||||
export default async function SalesPage() {
|
||||
const result = await getSales({ perPage: 100 });
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 100,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export default function SalesPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getSales>>['data']>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getSales({ perPage: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SalesManagement
|
||||
initialData={result.data}
|
||||
initialPagination={result.pagination}
|
||||
initialData={data}
|
||||
initialPagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { VendorManagement } from '@/components/accounting/VendorManagement';
|
||||
import { getClients } from '@/components/accounting/VendorManagement/actions';
|
||||
|
||||
export default async function VendorsPage() {
|
||||
const result = await getClients({ size: 100 });
|
||||
export default function VendorsPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getClients>>['data']>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getClients({ size: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VendorManagement
|
||||
initialData={result.data}
|
||||
initialTotal={result.total}
|
||||
initialData={data}
|
||||
initialTotal={total}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { WithdrawalManagement } from '@/components/accounting/WithdrawalManagement';
|
||||
import { getWithdrawals } from '@/components/accounting/WithdrawalManagement/actions';
|
||||
|
||||
export default async function WithdrawalsPage() {
|
||||
const result = await getWithdrawals({ perPage: 100 });
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 100,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export default function WithdrawalsPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getWithdrawals>>['data']>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getWithdrawals({ perPage: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WithdrawalManagement
|
||||
initialData={result.data}
|
||||
initialPagination={result.pagination}
|
||||
initialData={data}
|
||||
initialPagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ItemDetailClient } from '@/components/business/construction/item-management';
|
||||
|
||||
interface ItemDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ mode?: string }>;
|
||||
}
|
||||
|
||||
export default async function ItemDetailPage({ params, searchParams }: ItemDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const { mode } = await searchParams;
|
||||
export default function ItemDetailPage({ params }: ItemDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return <ItemDetailClient itemId={id} isEditMode={isEditMode} />;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { LaborDetailClient } from '@/components/business/construction/labor-management';
|
||||
|
||||
interface LaborDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ mode?: string }>;
|
||||
}
|
||||
|
||||
export default async function LaborDetailPage({ params, searchParams }: LaborDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const { mode } = await searchParams;
|
||||
export default function LaborDetailPage({ params }: LaborDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return <LaborDetailClient laborId={id} isEditMode={isEditMode} />;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PricingEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function PricingEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
|
||||
return <PricingDetailClient id={id} mode="edit" />;
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PricingDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function PricingDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
|
||||
return <PricingDetailClient id={id} mode="view" />;
|
||||
}
|
||||
@@ -1,19 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { OrderDetailForm } from '@/components/business/construction/order-management';
|
||||
import { getOrderDetailFull } from '@/components/business/construction/order-management/actions';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface OrderEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function OrderEditPage({ params }: OrderEditPageProps) {
|
||||
const { id } = await params;
|
||||
export default function OrderEditPage({ params }: OrderEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getOrderDetailFull>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const result = await getOrderDetailFull(id);
|
||||
useEffect(() => {
|
||||
getOrderDetailFull(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('주문 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('주문 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="edit" orderId={id} initialData={result.data} />;
|
||||
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 <OrderDetailForm mode="edit" orderId={id} initialData={data} />;
|
||||
}
|
||||
@@ -1,19 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { OrderDetailForm } from '@/components/business/construction/order-management';
|
||||
import { getOrderDetailFull } from '@/components/business/construction/order-management/actions';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface OrderDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
const { id } = await params;
|
||||
export default function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getOrderDetailFull>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const result = await getOrderDetailFull(id);
|
||||
useEffect(() => {
|
||||
getOrderDetailFull(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('주문 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('주문 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="view" orderId={id} initialData={result.data} />;
|
||||
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 <OrderDetailForm mode="view" orderId={id} initialData={data} />;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import SiteDetailForm from '@/components/business/construction/site-management/SiteDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
@@ -17,11 +20,24 @@ interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function SiteEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const [site, setSite] = useState<typeof MOCK_SITE | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// TODO: API에서 현장 정보 조회
|
||||
const site = { ...MOCK_SITE, id };
|
||||
useEffect(() => {
|
||||
// TODO: API에서 현장 정보 조회
|
||||
setSite({ ...MOCK_SITE, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading || !site) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SiteDetailForm site={site} mode="edit" />;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import SiteDetailForm from '@/components/business/construction/site-management/SiteDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
@@ -17,11 +20,24 @@ interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function SiteDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const [site, setSite] = useState<typeof MOCK_SITE | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// TODO: API에서 현장 정보 조회
|
||||
const site = { ...MOCK_SITE, id };
|
||||
useEffect(() => {
|
||||
// TODO: API에서 현장 정보 조회
|
||||
setSite({ ...MOCK_SITE, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading || !site) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SiteDetailForm site={site} mode="view" />;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import StructureReviewDetailForm from '@/components/business/construction/structure-review/StructureReviewDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
@@ -22,11 +25,24 @@ interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function StructureReviewEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function StructureReviewEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const [review, setReview] = useState<typeof MOCK_REVIEW | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
const review = { ...MOCK_REVIEW, id };
|
||||
useEffect(() => {
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
setReview({ ...MOCK_REVIEW, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading || !review) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <StructureReviewDetailForm review={review} mode="edit" />;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import StructureReviewDetailForm from '@/components/business/construction/structure-review/StructureReviewDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
@@ -22,11 +25,24 @@ interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function StructureReviewDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function StructureReviewDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const [review, setReview] = useState<typeof MOCK_REVIEW | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
const review = { ...MOCK_REVIEW, id };
|
||||
useEffect(() => {
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
setReview({ ...MOCK_REVIEW, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading || !review) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <StructureReviewDetailForm review={review} mode="view" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { BiddingDetailForm, getBiddingDetail } from '@/components/business/construction/bidding';
|
||||
|
||||
interface BiddingEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function BiddingEditPage({ params }: BiddingEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getBiddingDetail(id);
|
||||
export default function BiddingEditPage({ params }: BiddingEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getBiddingDetail>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getBiddingDetail(id)
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BiddingDetailForm
|
||||
mode="edit"
|
||||
biddingId={id}
|
||||
initialData={result.data}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { BiddingDetailForm, getBiddingDetail } from '@/components/business/construction/bidding';
|
||||
|
||||
interface BiddingDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function BiddingDetailPage({ params }: BiddingDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getBiddingDetail(id);
|
||||
export default function BiddingDetailPage({ params }: BiddingDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getBiddingDetail>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getBiddingDetail(id)
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BiddingDetailForm
|
||||
mode="view"
|
||||
biddingId={id}
|
||||
initialData={result.data}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { EstimateDetailForm } from '@/components/business/construction/estimates';
|
||||
import type { EstimateDetail } from '@/components/business/construction/estimates';
|
||||
|
||||
@@ -6,7 +9,7 @@ interface EstimateEditPageProps {
|
||||
}
|
||||
|
||||
// 목업 데이터 - 추후 API 연동
|
||||
async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
|
||||
function getEstimateDetail(id: string): EstimateDetail {
|
||||
// TODO: 실제 API 연동
|
||||
const mockData: EstimateDetail = {
|
||||
id,
|
||||
@@ -187,15 +190,30 @@ async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
|
||||
return mockData;
|
||||
}
|
||||
|
||||
export default async function EstimateEditPage({ params }: EstimateEditPageProps) {
|
||||
const { id } = await params;
|
||||
const detail = await getEstimateDetail(id);
|
||||
export default function EstimateEditPage({ params }: EstimateEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const [data, setData] = useState<EstimateDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const detail = getEstimateDetail(id);
|
||||
setData(detail);
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="edit"
|
||||
estimateId={id}
|
||||
initialData={detail || undefined}
|
||||
initialData={data || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { EstimateDetailForm } from '@/components/business/construction/estimates';
|
||||
import type { EstimateDetail } from '@/components/business/construction/estimates';
|
||||
|
||||
@@ -6,7 +9,7 @@ interface EstimateDetailPageProps {
|
||||
}
|
||||
|
||||
// 목업 데이터 - 추후 API 연동
|
||||
async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
|
||||
function getEstimateDetail(id: string): EstimateDetail {
|
||||
// TODO: 실제 API 연동
|
||||
const mockData: EstimateDetail = {
|
||||
id,
|
||||
@@ -187,15 +190,30 @@ async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
|
||||
return mockData;
|
||||
}
|
||||
|
||||
export default async function EstimateDetailPage({ params }: EstimateDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const detail = await getEstimateDetail(id);
|
||||
export default function EstimateDetailPage({ params }: EstimateDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const [data, setData] = useState<EstimateDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const detail = getEstimateDetail(id);
|
||||
setData(detail);
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="view"
|
||||
estimateId={id}
|
||||
initialData={detail || undefined}
|
||||
initialData={data || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import PartnerForm from '@/components/business/construction/partners/PartnerForm';
|
||||
import { getPartner } from '@/components/business/construction/partners/actions';
|
||||
|
||||
@@ -5,15 +9,52 @@ interface PartnerEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PartnerEditPage({ params }: PartnerEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getPartner(id);
|
||||
export default function PartnerEditPage({ params }: PartnerEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getPartner>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPartner(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) {
|
||||
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 (
|
||||
<PartnerForm
|
||||
mode="edit"
|
||||
partnerId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import PartnerForm from '@/components/business/construction/partners/PartnerForm';
|
||||
import { getPartner } from '@/components/business/construction/partners/actions';
|
||||
|
||||
@@ -5,15 +9,52 @@ interface PartnerDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PartnerDetailPage({ params }: PartnerDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getPartner(id);
|
||||
export default function PartnerDetailPage({ params }: PartnerDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getPartner>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPartner(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) {
|
||||
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 (
|
||||
<PartnerForm
|
||||
mode="view"
|
||||
partnerId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SiteBriefingForm, getSiteBriefing } from '@/components/business/construction/site-briefings';
|
||||
|
||||
interface SiteBriefingEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteBriefingEditPage({ params }: SiteBriefingEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getSiteBriefing(id);
|
||||
export default function SiteBriefingEditPage({ params }: SiteBriefingEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getSiteBriefing>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSiteBriefing(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) {
|
||||
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 (
|
||||
<SiteBriefingForm
|
||||
mode="edit"
|
||||
briefingId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SiteBriefingForm, getSiteBriefing } from '@/components/business/construction/site-briefings';
|
||||
|
||||
interface SiteBriefingDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteBriefingDetailPage({ params }: SiteBriefingDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getSiteBriefing(id);
|
||||
export default function SiteBriefingDetailPage({ params }: SiteBriefingDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getSiteBriefing>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSiteBriefing(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) {
|
||||
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 (
|
||||
<SiteBriefingForm
|
||||
mode="view"
|
||||
briefingId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/construction/contract';
|
||||
|
||||
@@ -5,15 +9,52 @@ interface ContractEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ContractEditPage({ params }: ContractEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getContractDetail(id);
|
||||
export default function ContractEditPage({ params }: ContractEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getContractDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getContractDetail(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) {
|
||||
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 (
|
||||
<ContractDetailForm
|
||||
mode="edit"
|
||||
contractId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/construction/contract';
|
||||
|
||||
@@ -5,15 +9,52 @@ interface ContractDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ContractDetailPage({ params }: ContractDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getContractDetail(id);
|
||||
export default function ContractDetailPage({ params }: ContractDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getContractDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getContractDetail(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) {
|
||||
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 (
|
||||
<ContractDetailForm
|
||||
mode="view"
|
||||
contractId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/construction/handover-report';
|
||||
|
||||
interface HandoverReportEditPageProps {
|
||||
@@ -7,17 +11,52 @@ interface HandoverReportEditPageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function HandoverReportEditPage({ params }: HandoverReportEditPageProps) {
|
||||
const { id } = await params;
|
||||
export default function HandoverReportEditPage({ params }: HandoverReportEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getHandoverReportDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 서버에서 상세 데이터 조회
|
||||
const result = await getHandoverReportDetail(id);
|
||||
useEffect(() => {
|
||||
getHandoverReportDetail(id)
|
||||
.then(result => {
|
||||
if (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) {
|
||||
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 (
|
||||
<HandoverReportDetailForm
|
||||
mode="edit"
|
||||
reportId={id}
|
||||
initialData={result.data}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/construction/handover-report';
|
||||
|
||||
interface HandoverReportDetailPageProps {
|
||||
@@ -7,17 +11,52 @@ interface HandoverReportDetailPageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) {
|
||||
const { id } = await params;
|
||||
export default function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getHandoverReportDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 서버에서 상세 데이터 조회
|
||||
const result = await getHandoverReportDetail(id);
|
||||
useEffect(() => {
|
||||
getHandoverReportDetail(id)
|
||||
.then(result => {
|
||||
if (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) {
|
||||
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 (
|
||||
<HandoverReportDetailForm
|
||||
mode="view"
|
||||
reportId={id}
|
||||
initialData={result.data}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use server';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import type { UrlCategory, UrlItem } from './ConstructionTestUrlsClient';
|
||||
|
||||
// 아이콘 매핑
|
||||
const iconMap: Record<string, string> = {
|
||||
'기본': '🏠',
|
||||
'시스템': '💻',
|
||||
'대시보드': '📊',
|
||||
};
|
||||
|
||||
function getIcon(title: string): string {
|
||||
for (const [key, icon] of Object.entries(iconMap)) {
|
||||
if (title.includes(key)) return icon;
|
||||
}
|
||||
return '📄';
|
||||
}
|
||||
|
||||
function parseTableRow(line: string): UrlItem | null {
|
||||
// | 페이지 | URL | 상태 | 형식 파싱
|
||||
const parts = line.split('|').map(p => p.trim()).filter(p => p);
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
|
||||
|
||||
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
|
||||
const url = parts[1].replace(/`/g, ''); // backtick 제거
|
||||
const status = parts[2] || undefined;
|
||||
|
||||
// URL이 /ko로 시작하는지 확인
|
||||
if (!url.startsWith('/ko')) return null;
|
||||
|
||||
return { name, url, status };
|
||||
}
|
||||
|
||||
function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } {
|
||||
const lines = content.split('\n');
|
||||
const categories: UrlCategory[] = [];
|
||||
let currentCategory: UrlCategory | null = null;
|
||||
let currentSubCategory: { title: string; items: UrlItem[] } | null = null;
|
||||
let lastUpdated = 'N/A';
|
||||
|
||||
// Last Updated 추출
|
||||
const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/);
|
||||
if (updateMatch) {
|
||||
lastUpdated = updateMatch[1];
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// ## 카테고리 (메인 섹션)
|
||||
if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) {
|
||||
// 이전 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
currentSubCategory = null;
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
|
||||
currentCategory = {
|
||||
title,
|
||||
icon: getIcon(title),
|
||||
items: [],
|
||||
subCategories: [],
|
||||
};
|
||||
currentSubCategory = null;
|
||||
}
|
||||
|
||||
// ### 서브 카테고리
|
||||
else if (line.startsWith('### ') && currentCategory) {
|
||||
// 이전 서브카테고리 저장
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
|
||||
const subTitle = line.replace('### ', '').trim();
|
||||
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
|
||||
if (subTitle === '메인 페이지') {
|
||||
currentSubCategory = null;
|
||||
} else {
|
||||
currentSubCategory = {
|
||||
title: subTitle,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 행 파싱
|
||||
else if (line.startsWith('|') && currentCategory) {
|
||||
const item = parseTableRow(line);
|
||||
if (item) {
|
||||
if (currentSubCategory) {
|
||||
currentSubCategory.items.push(item);
|
||||
} else {
|
||||
currentCategory.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
// 빈 서브카테고리 제거
|
||||
categories.forEach(cat => {
|
||||
cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0);
|
||||
});
|
||||
|
||||
return { categories, lastUpdated };
|
||||
}
|
||||
|
||||
export async function getConstructionTestUrlsData(): Promise<{ categories: UrlCategory[]; lastUpdated: string }> {
|
||||
// md 파일 경로
|
||||
const mdFilePath = path.join(
|
||||
process.cwd(),
|
||||
'claudedocs',
|
||||
'[REF] construction-pages-test-urls.md'
|
||||
);
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(mdFilePath, 'utf-8');
|
||||
return parseMdFile(fileContent);
|
||||
} catch (error) {
|
||||
console.error('Failed to read md file:', error);
|
||||
return { categories: [], lastUpdated: 'N/A' };
|
||||
}
|
||||
}
|
||||
@@ -1,152 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import ConstructionTestUrlsClient, { UrlCategory, UrlItem } from './ConstructionTestUrlsClient';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ConstructionTestUrlsClient, { UrlCategory } from './ConstructionTestUrlsClient';
|
||||
import { getConstructionTestUrlsData } from './actions';
|
||||
|
||||
// 아이콘 매핑
|
||||
const iconMap: Record<string, string> = {
|
||||
'기본': '🏠',
|
||||
'시스템': '💻',
|
||||
'대시보드': '📊',
|
||||
};
|
||||
export default function TestUrlsPage() {
|
||||
const [urlData, setUrlData] = useState<UrlCategory[]>([]);
|
||||
const [lastUpdated, setLastUpdated] = useState('N/A');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
function getIcon(title: string): string {
|
||||
for (const [key, icon] of Object.entries(iconMap)) {
|
||||
if (title.includes(key)) return icon;
|
||||
}
|
||||
return '📄';
|
||||
}
|
||||
useEffect(() => {
|
||||
getConstructionTestUrlsData()
|
||||
.then(result => {
|
||||
setUrlData(result.categories);
|
||||
setLastUpdated(result.lastUpdated);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
function parseTableRow(line: string): UrlItem | null {
|
||||
// | 페이지 | URL | 상태 | 형식 파싱
|
||||
const parts = line.split('|').map(p => p.trim()).filter(p => p);
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
|
||||
|
||||
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
|
||||
const url = parts[1].replace(/`/g, ''); // backtick 제거
|
||||
const status = parts[2] || undefined;
|
||||
|
||||
// URL이 /ko로 시작하는지 확인
|
||||
if (!url.startsWith('/ko')) return null;
|
||||
|
||||
return { name, url, status };
|
||||
}
|
||||
|
||||
function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } {
|
||||
const lines = content.split('\n');
|
||||
const categories: UrlCategory[] = [];
|
||||
let currentCategory: UrlCategory | null = null;
|
||||
let currentSubCategory: { title: string; items: UrlItem[] } | null = null;
|
||||
let lastUpdated = 'N/A';
|
||||
|
||||
// Last Updated 추출
|
||||
const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/);
|
||||
if (updateMatch) {
|
||||
lastUpdated = updateMatch[1];
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// ## 카테고리 (메인 섹션)
|
||||
if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) {
|
||||
// 이전 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
currentSubCategory = null;
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
|
||||
currentCategory = {
|
||||
title,
|
||||
icon: getIcon(title),
|
||||
items: [],
|
||||
subCategories: [],
|
||||
};
|
||||
currentSubCategory = null;
|
||||
}
|
||||
|
||||
// ### 서브 카테고리
|
||||
else if (line.startsWith('### ') && currentCategory) {
|
||||
// 이전 서브카테고리 저장
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
|
||||
const subTitle = line.replace('### ', '').trim();
|
||||
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
|
||||
if (subTitle === '메인 페이지') {
|
||||
currentSubCategory = null;
|
||||
} else {
|
||||
currentSubCategory = {
|
||||
title: subTitle,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 행 파싱
|
||||
else if (line.startsWith('|') && currentCategory) {
|
||||
const item = parseTableRow(line);
|
||||
if (item) {
|
||||
if (currentSubCategory) {
|
||||
currentSubCategory.items.push(item);
|
||||
} else {
|
||||
currentCategory.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
// 빈 서브카테고리 제거
|
||||
categories.forEach(cat => {
|
||||
cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0);
|
||||
});
|
||||
|
||||
return { categories, lastUpdated };
|
||||
}
|
||||
|
||||
export default async function TestUrlsPage() {
|
||||
// md 파일 경로
|
||||
const mdFilePath = path.join(
|
||||
process.cwd(),
|
||||
'claudedocs',
|
||||
'[REF] construction-pages-test-urls.md'
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let urlData: UrlCategory[] = [];
|
||||
let lastUpdated = 'N/A';
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(mdFilePath, 'utf-8');
|
||||
const parsed = parseMdFile(fileContent);
|
||||
urlData = parsed.categories;
|
||||
lastUpdated = parsed.lastUpdated;
|
||||
} catch (error) {
|
||||
console.error('Failed to read md file:', error);
|
||||
// 파일 읽기 실패 시 빈 데이터
|
||||
urlData = [];
|
||||
}
|
||||
|
||||
return <ConstructionTestUrlsClient initialData={urlData} lastUpdated={lastUpdated} />;
|
||||
}
|
||||
|
||||
// 캐싱 비활성화 - 항상 최신 md 파일 읽기
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 0;
|
||||
return <ConstructionTestUrlsClient initialData={urlData} lastUpdated={lastUpdated} />;
|
||||
}
|
||||
157
src/app/[locale]/(protected)/dev/test-urls/actions.ts
Normal file
157
src/app/[locale]/(protected)/dev/test-urls/actions.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
'use server';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import type { UrlCategory, UrlItem } from './TestUrlsClient';
|
||||
|
||||
// 아이콘 매핑
|
||||
const iconMap: Record<string, string> = {
|
||||
'기본': '🏠',
|
||||
'인사관리': '👥',
|
||||
'HR': '👥',
|
||||
'판매관리': '💰',
|
||||
'Sales': '💰',
|
||||
'기준정보관리': '📦',
|
||||
'Master Data': '📦',
|
||||
'생산관리': '🏭',
|
||||
'Production': '🏭',
|
||||
'설정': '⚙️',
|
||||
'Settings': '⚙️',
|
||||
'전자결재': '📝',
|
||||
'Approval': '📝',
|
||||
'회계관리': '💵',
|
||||
'Accounting': '💵',
|
||||
'게시판': '📋',
|
||||
'Board': '📋',
|
||||
'보고서': '📊',
|
||||
'Reports': '📊',
|
||||
};
|
||||
|
||||
function getIcon(title: string): string {
|
||||
for (const [key, icon] of Object.entries(iconMap)) {
|
||||
if (title.includes(key)) return icon;
|
||||
}
|
||||
return '📄';
|
||||
}
|
||||
|
||||
function parseTableRow(line: string): UrlItem | null {
|
||||
// | 페이지 | URL | 상태 | 형식 파싱
|
||||
const parts = line.split('|').map(p => p.trim()).filter(p => p);
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
|
||||
|
||||
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
|
||||
const url = parts[1].replace(/`/g, ''); // backtick 제거
|
||||
const status = parts[2] || undefined;
|
||||
|
||||
// URL이 /ko로 시작하는지 확인
|
||||
if (!url.startsWith('/ko')) return null;
|
||||
|
||||
return { name, url, status };
|
||||
}
|
||||
|
||||
function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } {
|
||||
const lines = content.split('\n');
|
||||
const categories: UrlCategory[] = [];
|
||||
let currentCategory: UrlCategory | null = null;
|
||||
let currentSubCategory: { title: string; items: UrlItem[] } | null = null;
|
||||
let lastUpdated = 'N/A';
|
||||
|
||||
// Last Updated 추출
|
||||
const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/);
|
||||
if (updateMatch) {
|
||||
lastUpdated = updateMatch[1];
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// ## 카테고리 (메인 섹션)
|
||||
if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) {
|
||||
// 이전 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
currentSubCategory = null;
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
|
||||
currentCategory = {
|
||||
title,
|
||||
icon: getIcon(title),
|
||||
items: [],
|
||||
subCategories: [],
|
||||
};
|
||||
currentSubCategory = null;
|
||||
}
|
||||
|
||||
// ### 서브 카테고리
|
||||
else if (line.startsWith('### ') && currentCategory) {
|
||||
// 이전 서브카테고리 저장
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
|
||||
const subTitle = line.replace('### ', '').trim();
|
||||
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
|
||||
if (subTitle === '메인 페이지') {
|
||||
currentSubCategory = null;
|
||||
} else {
|
||||
currentSubCategory = {
|
||||
title: subTitle,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 행 파싱
|
||||
else if (line.startsWith('|') && currentCategory) {
|
||||
const item = parseTableRow(line);
|
||||
if (item) {
|
||||
if (currentSubCategory) {
|
||||
currentSubCategory.items.push(item);
|
||||
} else {
|
||||
currentCategory.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
// 빈 서브카테고리 제거
|
||||
categories.forEach(cat => {
|
||||
cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0);
|
||||
});
|
||||
|
||||
return { categories, lastUpdated };
|
||||
}
|
||||
|
||||
export async function getTestUrlsData(): Promise<{ categories: UrlCategory[]; lastUpdated: string }> {
|
||||
// md 파일 경로
|
||||
const mdFilePath = path.join(
|
||||
process.cwd(),
|
||||
'claudedocs',
|
||||
'[REF] all-pages-test-urls.md'
|
||||
);
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(mdFilePath, 'utf-8');
|
||||
return parseMdFile(fileContent);
|
||||
} catch (error) {
|
||||
console.error('Failed to read md file:', error);
|
||||
return { categories: [], lastUpdated: 'N/A' };
|
||||
}
|
||||
}
|
||||
@@ -1,167 +1,30 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import TestUrlsClient, { UrlCategory, UrlItem } from './TestUrlsClient';
|
||||
'use client';
|
||||
|
||||
// 아이콘 매핑
|
||||
const iconMap: Record<string, string> = {
|
||||
'기본': '🏠',
|
||||
'인사관리': '👥',
|
||||
'HR': '👥',
|
||||
'판매관리': '💰',
|
||||
'Sales': '💰',
|
||||
'기준정보관리': '📦',
|
||||
'Master Data': '📦',
|
||||
'생산관리': '🏭',
|
||||
'Production': '🏭',
|
||||
'설정': '⚙️',
|
||||
'Settings': '⚙️',
|
||||
'전자결재': '📝',
|
||||
'Approval': '📝',
|
||||
'회계관리': '💵',
|
||||
'Accounting': '💵',
|
||||
'게시판': '📋',
|
||||
'Board': '📋',
|
||||
'보고서': '📊',
|
||||
'Reports': '📊',
|
||||
};
|
||||
import { useEffect, useState } from 'react';
|
||||
import TestUrlsClient, { UrlCategory } from './TestUrlsClient';
|
||||
import { getTestUrlsData } from './actions';
|
||||
|
||||
function getIcon(title: string): string {
|
||||
for (const [key, icon] of Object.entries(iconMap)) {
|
||||
if (title.includes(key)) return icon;
|
||||
}
|
||||
return '📄';
|
||||
}
|
||||
export default function TestUrlsPage() {
|
||||
const [urlData, setUrlData] = useState<UrlCategory[]>([]);
|
||||
const [lastUpdated, setLastUpdated] = useState('N/A');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
function parseTableRow(line: string): UrlItem | null {
|
||||
// | 페이지 | URL | 상태 | 형식 파싱
|
||||
const parts = line.split('|').map(p => p.trim()).filter(p => p);
|
||||
useEffect(() => {
|
||||
getTestUrlsData()
|
||||
.then(result => {
|
||||
setUrlData(result.categories);
|
||||
setLastUpdated(result.lastUpdated);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
|
||||
|
||||
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
|
||||
const url = parts[1].replace(/`/g, ''); // backtick 제거
|
||||
const status = parts[2] || undefined;
|
||||
|
||||
// URL이 /ko로 시작하는지 확인
|
||||
if (!url.startsWith('/ko')) return null;
|
||||
|
||||
return { name, url, status };
|
||||
}
|
||||
|
||||
function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } {
|
||||
const lines = content.split('\n');
|
||||
const categories: UrlCategory[] = [];
|
||||
let currentCategory: UrlCategory | null = null;
|
||||
let currentSubCategory: { title: string; items: UrlItem[] } | null = null;
|
||||
let lastUpdated = 'N/A';
|
||||
|
||||
// Last Updated 추출
|
||||
const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/);
|
||||
if (updateMatch) {
|
||||
lastUpdated = updateMatch[1];
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// ## 카테고리 (메인 섹션)
|
||||
if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) {
|
||||
// 이전 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
currentSubCategory = null;
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
|
||||
currentCategory = {
|
||||
title,
|
||||
icon: getIcon(title),
|
||||
items: [],
|
||||
subCategories: [],
|
||||
};
|
||||
currentSubCategory = null;
|
||||
}
|
||||
|
||||
// ### 서브 카테고리
|
||||
else if (line.startsWith('### ') && currentCategory) {
|
||||
// 이전 서브카테고리 저장
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
|
||||
const subTitle = line.replace('### ', '').trim();
|
||||
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
|
||||
if (subTitle === '메인 페이지') {
|
||||
currentSubCategory = null;
|
||||
} else {
|
||||
currentSubCategory = {
|
||||
title: subTitle,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 행 파싱
|
||||
else if (line.startsWith('|') && currentCategory) {
|
||||
const item = parseTableRow(line);
|
||||
if (item) {
|
||||
if (currentSubCategory) {
|
||||
currentSubCategory.items.push(item);
|
||||
} else {
|
||||
currentCategory.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
// 빈 서브카테고리 제거
|
||||
categories.forEach(cat => {
|
||||
cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0);
|
||||
});
|
||||
|
||||
return { categories, lastUpdated };
|
||||
}
|
||||
|
||||
export default async function TestUrlsPage() {
|
||||
// md 파일 경로
|
||||
const mdFilePath = path.join(
|
||||
process.cwd(),
|
||||
'claudedocs',
|
||||
'[REF] all-pages-test-urls.md'
|
||||
);
|
||||
|
||||
let urlData: UrlCategory[] = [];
|
||||
let lastUpdated = 'N/A';
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(mdFilePath, 'utf-8');
|
||||
const parsed = parseMdFile(fileContent);
|
||||
urlData = parsed.categories;
|
||||
lastUpdated = parsed.lastUpdated;
|
||||
} catch (error) {
|
||||
console.error('Failed to read md file:', error);
|
||||
// 파일 읽기 실패 시 빈 데이터
|
||||
urlData = [];
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <TestUrlsClient initialData={urlData} lastUpdated={lastUpdated} />;
|
||||
}
|
||||
|
||||
// 캐싱 비활성화 - 항상 최신 md 파일 읽기
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 0;
|
||||
}
|
||||
@@ -1,42 +1,62 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 공정 수정 페이지
|
||||
* 공정 수정 페이지 (Client Component)
|
||||
*/
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ProcessForm } from '@/components/process-management';
|
||||
import { getProcessById } from '@/components/process-management/actions';
|
||||
import type { Process } from '@/components/process-management/types';
|
||||
|
||||
export default async function EditProcessPage({
|
||||
export default function EditProcessPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const result = await getProcessById(id);
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Process | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
useEffect(() => {
|
||||
getProcessById(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>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProcessForm mode="edit" initialData={result.data} />;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const result = await getProcessById(id);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return {
|
||||
title: '공정을 찾을 수 없습니다',
|
||||
};
|
||||
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 {
|
||||
title: `${result.data.processName} - 공정 수정`,
|
||||
description: `${result.data.processCode} 공정 수정`,
|
||||
};
|
||||
}
|
||||
return <ProcessForm mode="edit" initialData={data} />;
|
||||
}
|
||||
@@ -1,48 +1,62 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 공정 상세 페이지
|
||||
* 공정 상세 페이지 (Client Component)
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ProcessDetail } from '@/components/process-management';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { getProcessById } from '@/components/process-management/actions';
|
||||
import type { Process } from '@/components/process-management/types';
|
||||
|
||||
export default async function ProcessDetailPage({
|
||||
export default function ProcessDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const result = await getProcessById(id);
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Process | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
useEffect(() => {
|
||||
getProcessById(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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<ContentLoadingSpinner text="공정 정보를 불러오는 중..." />}>
|
||||
<ProcessDetail process={result.data} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const result = await getProcessById(id);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return {
|
||||
title: '공정을 찾을 수 없습니다',
|
||||
};
|
||||
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 {
|
||||
title: `${result.data.processName} - 공정 상세`,
|
||||
description: `${result.data.processCode} 공정 정보`,
|
||||
};
|
||||
}
|
||||
return <ProcessDetail process={data} />;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { ReceivingDetail } from '@/components/material/ReceivingManagement';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ReceivingDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
export default function ReceivingDetailPage({ params }: Props) {
|
||||
const { id } = use(params);
|
||||
return <ReceivingDetail id={id} />;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { StockStatusDetail } from '@/components/material/StockStatus';
|
||||
|
||||
interface StockStatusDetailPageProps {
|
||||
@@ -6,7 +9,7 @@ interface StockStatusDetailPageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function StockStatusDetailPage({ params }: StockStatusDetailPageProps) {
|
||||
const { id } = await params;
|
||||
export default function StockStatusDetailPage({ params }: StockStatusDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
return <StockStatusDetail id={id} />;
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하관리 - 수정 페이지
|
||||
* 출하관리 - 수정 페이지 (Client Component)
|
||||
* URL: /outbound/shipments/[id]/edit
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { ShipmentEdit } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
interface ShipmentEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ShipmentEditPage({ params }: ShipmentEditPageProps) {
|
||||
const { id } = await params;
|
||||
export default function ShipmentEditPage({ params }: ShipmentEditPageProps) {
|
||||
const { id } = use(params);
|
||||
return <ShipmentEdit id={id} />;
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하관리 - 상세 페이지
|
||||
* 출하관리 - 상세 페이지 (Client Component)
|
||||
* URL: /outbound/shipments/[id]
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { ShipmentDetail } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
interface ShipmentDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ShipmentDetailPage({ params }: ShipmentDetailPageProps) {
|
||||
const { id } = await params;
|
||||
export default function ShipmentDetailPage({ params }: ShipmentDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
return <ShipmentDetail id={id} />;
|
||||
}
|
||||
@@ -1,13 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PaymentHistoryManagement } from '@/components/settings/PaymentHistoryManagement';
|
||||
import { getPayments } from '@/components/settings/PaymentHistoryManagement/actions';
|
||||
|
||||
export default async function PaymentHistoryPage() {
|
||||
const result = await getPayments({ perPage: 100 });
|
||||
export default function PaymentHistoryPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getPayments>>['data']>(undefined);
|
||||
const [pagination, setPagination] = useState<Awaited<ReturnType<typeof getPayments>>['pagination']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getPayments({ perPage: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PaymentHistoryManagement
|
||||
initialData={result.data}
|
||||
initialPagination={result.pagination}
|
||||
initialData={data}
|
||||
initialPagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 품목 상세 조회 페이지
|
||||
* 품목 상세 조회 페이지 (Client Component)
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ItemDetailClient from '@/components/items/ItemDetailClient';
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
|
||||
@@ -134,62 +136,53 @@ const mockItems: ItemMaster[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 품목 조회 함수
|
||||
* TODO: API 연동 시 fetchItemByCode()로 교체
|
||||
*/
|
||||
async function getItemByCode(itemCode: string): Promise<ItemMaster | null> {
|
||||
// API 연동 전 mock 데이터 반환
|
||||
// const item = await fetchItemByCode(itemCode);
|
||||
const item = mockItems.find(
|
||||
(item) => item.itemCode === decodeURIComponent(itemCode)
|
||||
);
|
||||
return item || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 상세 페이지
|
||||
*/
|
||||
export default async function ItemDetailPage({
|
||||
export default function ItemDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const item = await getItemByCode(id);
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [item, setItem] = useState<ItemMaster | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// API 연동 전 mock 데이터 사용
|
||||
const foundItem = mockItems.find(
|
||||
(item) => item.itemCode === decodeURIComponent(id)
|
||||
);
|
||||
setItem(foundItem || null);
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
notFound();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">품목을 찾을 수 없습니다.</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
|
||||
<ItemDetailClient item={item} />
|
||||
</Suspense>
|
||||
<ItemDetailClient item={item} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const item = await getItemByCode(id);
|
||||
|
||||
if (!item) {
|
||||
return {
|
||||
title: '품목을 찾을 수 없습니다',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${item.itemName} - 품목 상세`,
|
||||
description: `${item.itemCode} 품목 정보`,
|
||||
};
|
||||
}
|
||||
@@ -1,160 +1,16 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 품목 목록 페이지 (Server Component)
|
||||
* 품목 목록 페이지 (Client Component)
|
||||
*
|
||||
* Next.js 15 App Router
|
||||
* 서버에서 데이터 fetching 후 Client Component로 전달
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import ItemListClient from '@/components/items/ItemListClient';
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
|
||||
// Mock 데이터 (API 연동 전 임시)
|
||||
const mockItems: ItemMaster[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemCode: 'KD-FG-001',
|
||||
itemName: '스크린 제품 A',
|
||||
itemType: 'FG',
|
||||
unit: 'EA',
|
||||
specification: '2000x2000',
|
||||
isActive: true,
|
||||
category1: '본체부품',
|
||||
category2: '가이드시스템',
|
||||
salesPrice: 150000,
|
||||
purchasePrice: 100000,
|
||||
productCategory: 'SCREEN',
|
||||
lotAbbreviation: 'KD',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
itemCode: 'KD-PT-001',
|
||||
itemName: '가이드레일(벽면형)',
|
||||
itemType: 'PT',
|
||||
unit: 'EA',
|
||||
specification: '2438mm',
|
||||
isActive: true,
|
||||
category1: '본체부품',
|
||||
category2: '가이드시스템',
|
||||
category3: '가이드레일',
|
||||
salesPrice: 50000,
|
||||
purchasePrice: 35000,
|
||||
partType: 'ASSEMBLY',
|
||||
partUsage: 'GUIDE_RAIL',
|
||||
installationType: '벽면형',
|
||||
assemblyType: 'M',
|
||||
assemblyLength: '2438',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
itemCode: 'KD-PT-002',
|
||||
itemName: '절곡품 샘플',
|
||||
itemType: 'PT',
|
||||
unit: 'EA',
|
||||
specification: 'EGI 1.55T',
|
||||
isActive: true,
|
||||
partType: 'BENDING',
|
||||
material: 'EGI 1.55T',
|
||||
length: '2000',
|
||||
salesPrice: 30000,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
itemCode: 'KD-RM-001',
|
||||
itemName: 'SPHC-SD',
|
||||
itemType: 'RM',
|
||||
unit: 'KG',
|
||||
specification: '1.6T x 1219 x 2438',
|
||||
isActive: true,
|
||||
category1: '철강재',
|
||||
purchasePrice: 1500,
|
||||
material: 'SPHC-SD',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
itemCode: 'KD-SM-001',
|
||||
itemName: '볼트 M6x20',
|
||||
itemType: 'SM',
|
||||
unit: 'EA',
|
||||
specification: 'M6x20',
|
||||
isActive: true,
|
||||
category1: '구조재/부속품',
|
||||
category2: '볼트/너트',
|
||||
purchasePrice: 50,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
itemCode: 'KD-CS-001',
|
||||
itemName: '절삭유',
|
||||
itemType: 'CS',
|
||||
unit: 'L',
|
||||
specification: '20L',
|
||||
isActive: true,
|
||||
purchasePrice: 30000,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
itemCode: 'KD-FG-002',
|
||||
itemName: '철재 제품 B',
|
||||
itemType: 'FG',
|
||||
unit: 'SET',
|
||||
specification: '3000x2500',
|
||||
isActive: false,
|
||||
category1: '본체부품',
|
||||
salesPrice: 200000,
|
||||
productCategory: 'STEEL',
|
||||
lotAbbreviation: 'KD',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-09T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 품목 목록 조회 함수
|
||||
* TODO: API 연동 시 fetchItems()로 교체
|
||||
*/
|
||||
async function getItems(): Promise<ItemMaster[]> {
|
||||
// API 연동 전 mock 데이터 반환
|
||||
// const items = await fetchItems();
|
||||
return mockItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 목록 페이지
|
||||
*/
|
||||
export default async function ItemsPage() {
|
||||
const items = await getItems();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
|
||||
<ItemListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
*/
|
||||
export const metadata = {
|
||||
title: '품목 관리',
|
||||
description: '품목 목록 조회 및 관리',
|
||||
};
|
||||
export default function ItemsPage() {
|
||||
return <ItemListClient />;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업지시 상세 페이지
|
||||
* 작업지시 상세 페이지 (Client Component)
|
||||
* URL: /production/work-orders/[id]
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { WorkOrderDetail } from '@/components/production/WorkOrders';
|
||||
|
||||
interface PageProps {
|
||||
@@ -11,7 +14,7 @@ interface PageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function WorkOrderDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function WorkOrderDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
return <WorkOrderDetail orderId={id} />;
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 검사 상세/수정 페이지
|
||||
* 검사 상세/수정 페이지 (Client Component)
|
||||
* URL: /quality/inspections/[id]
|
||||
* 수정 모드: /quality/inspections/[id]?mode=edit
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { InspectionDetail } from '@/components/quality/InspectionManagement';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function InspectionDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
export default function InspectionDetailPage({ params }: Props) {
|
||||
const { id } = use(params);
|
||||
return <InspectionDetail id={id} />;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 단가 수정 페이지
|
||||
* 단가 수정 페이지 (Client Component)
|
||||
*
|
||||
* 경로: /sales/pricing-management/[id]/edit
|
||||
* API: GET /api/v1/pricing/{id}, PUT /api/v1/pricing/{id}
|
||||
*/
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PricingFormClient } from '@/components/pricing';
|
||||
import { getPricingById, updatePricing, finalizePricing } from '@/components/pricing/actions';
|
||||
import type { PricingData } from '@/components/pricing';
|
||||
@@ -15,57 +19,79 @@ interface EditPricingPageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditPricingPage({ params }: EditPricingPageProps) {
|
||||
const { id } = await params;
|
||||
export default function EditPricingPage({ params }: EditPricingPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<PricingData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 기존 단가 데이터 조회
|
||||
const pricingData = await getPricingById(id);
|
||||
useEffect(() => {
|
||||
getPricingById(id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setData(result);
|
||||
} else {
|
||||
setError('단가 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('단가 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (!pricingData) {
|
||||
// 단가 수정 핸들러
|
||||
const handleSave = async (formData: PricingData, isRevision?: boolean, revisionReason?: string) => {
|
||||
const result = await updatePricing(id, formData, revisionReason);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 수정에 실패했습니다.');
|
||||
}
|
||||
console.log('[EditPricingPage] 단가 수정 성공:', result.data, { isRevision, revisionReason });
|
||||
};
|
||||
|
||||
// 단가 확정 핸들러
|
||||
const handleFinalize = async (priceId: string) => {
|
||||
const result = await finalizePricing(priceId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 확정에 실패했습니다.');
|
||||
}
|
||||
console.log('[EditPricingPage] 단가 확정 성공:', result.data);
|
||||
};
|
||||
|
||||
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="container mx-auto py-6 px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">단가 정보를 찾을 수 없습니다</h2>
|
||||
<p className="text-muted-foreground">
|
||||
<h2 className="text-xl font-semibold mb-2">{error || '단가 정보를 찾을 수 없습니다'}</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
올바른 단가 정보로 다시 시도해주세요.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 서버 액션: 단가 수정
|
||||
async function handleSave(data: PricingData, isRevision?: boolean, revisionReason?: string) {
|
||||
'use server';
|
||||
|
||||
const result = await updatePricing(id, data, revisionReason);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 수정에 실패했습니다.');
|
||||
}
|
||||
|
||||
console.log('[EditPricingPage] 단가 수정 성공:', result.data, { isRevision, revisionReason });
|
||||
}
|
||||
|
||||
// 서버 액션: 단가 확정
|
||||
async function handleFinalize(priceId: string) {
|
||||
'use server';
|
||||
|
||||
const result = await finalizePricing(priceId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 확정에 실패했습니다.');
|
||||
}
|
||||
|
||||
console.log('[EditPricingPage] 단가 확정 성공:', result.data);
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingFormClient
|
||||
mode="edit"
|
||||
initialData={pricingData}
|
||||
initialData={data}
|
||||
onSave={handleSave}
|
||||
onFinalize={handleFinalize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 단가 등록 페이지
|
||||
* 단가 등록 페이지 (Client Component)
|
||||
*
|
||||
* 경로: /sales/pricing-management/create?itemId=xxx
|
||||
* API: POST /api/v1/pricing
|
||||
@@ -7,63 +9,95 @@
|
||||
* item_type_code는 품목 정보에서 자동으로 가져옴 (FG, PT, SM, RM, CS 등)
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { PricingFormClient } from '@/components/pricing';
|
||||
import { getItemInfo, createPricing } from '@/components/pricing/actions';
|
||||
import type { PricingData } from '@/components/pricing';
|
||||
import type { PricingData, ItemInfo } from '@/components/pricing';
|
||||
|
||||
interface CreatePricingPageProps {
|
||||
searchParams: Promise<{
|
||||
itemId?: string;
|
||||
itemCode?: string;
|
||||
}>;
|
||||
}
|
||||
export default function CreatePricingPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const itemId = searchParams.get('itemId') || '';
|
||||
|
||||
export default async function CreatePricingPage({ searchParams }: CreatePricingPageProps) {
|
||||
const params = await searchParams;
|
||||
const itemId = params.itemId || '';
|
||||
const [itemInfo, setItemInfo] = useState<ItemInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 품목 정보 조회
|
||||
const itemInfo = itemId ? await getItemInfo(itemId) : null;
|
||||
useEffect(() => {
|
||||
if (!itemId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!itemInfo && itemId) {
|
||||
getItemInfo(itemId)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setItemInfo(result);
|
||||
} else {
|
||||
setError('품목 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('품목 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [itemId]);
|
||||
|
||||
// 단가 등록 핸들러
|
||||
const handleSave = async (data: PricingData) => {
|
||||
const result = await createPricing(data);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 등록에 실패했습니다.');
|
||||
}
|
||||
console.log('[CreatePricingPage] 단가 등록 성공:', result.data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">품목 정보를 찾을 수 없습니다</h2>
|
||||
<p className="text-muted-foreground">
|
||||
올바른 품목 정보로 다시 시도해주세요.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 품목 정보 없이 접근한 경우 (목록에서 바로 등록)
|
||||
if (!itemInfo) {
|
||||
// 품목 정보 없이 접근한 경우
|
||||
if (!itemId) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">품목을 선택해주세요</h2>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
단가 목록에서 품목을 선택한 후 등록해주세요.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 서버 액션: 단가 등록
|
||||
// item_type_code는 data.itemType에서 자동으로 가져옴
|
||||
async function handleSave(data: PricingData) {
|
||||
'use server';
|
||||
|
||||
const result = await createPricing(data);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 등록에 실패했습니다.');
|
||||
}
|
||||
|
||||
console.log('[CreatePricingPage] 단가 등록 성공:', result.data);
|
||||
if (error || !itemInfo) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">{error || '품목 정보를 찾을 수 없습니다'}</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
올바른 품목 정보로 다시 시도해주세요.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -73,4 +107,4 @@ export default async function CreatePricingPage({ searchParams }: CreatePricingP
|
||||
onSave={handleSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 단가 목록 페이지
|
||||
* 단가 목록 페이지 (Client Component)
|
||||
*
|
||||
* 경로: /sales/pricing-management
|
||||
* API:
|
||||
@@ -10,302 +12,29 @@
|
||||
* 품목 목록 + 단가 목록 → 병합 → 품목별 단가 현황 표시
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PricingListClient } from '@/components/pricing';
|
||||
import type { PricingListItem, PricingStatus } from '@/components/pricing';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getPricingListData, type PricingListItem } from '@/components/pricing/actions';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
export default function PricingManagementPage() {
|
||||
const [data, setData] = useState<PricingListItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 품목 API 응답 타입 (GET /api/v1/items)
|
||||
interface ItemApiData {
|
||||
id: number;
|
||||
item_type: string; // FG, PT, SM, RM, CS (품목 유형)
|
||||
code: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
category_id: number | null;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
useEffect(() => {
|
||||
getPricingListData()
|
||||
.then(result => {
|
||||
setData(result);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
interface ItemsApiResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
current_page: number;
|
||||
data: ItemApiData[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
};
|
||||
message: string;
|
||||
}
|
||||
|
||||
// 단가 API 응답 타입 (GET /api/v1/pricing)
|
||||
interface PriceApiItem {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
item_type_code: string; // FG, PT, SM, RM, CS (items.item_type과 동일)
|
||||
item_id: number;
|
||||
client_group_id: number | null;
|
||||
purchase_price: string | null;
|
||||
processing_cost: string | null;
|
||||
loss_rate: string | null;
|
||||
margin_rate: string | null;
|
||||
sales_price: string | null;
|
||||
rounding_rule: 'round' | 'ceil' | 'floor';
|
||||
rounding_unit: number;
|
||||
supplier: string | null;
|
||||
effective_from: string;
|
||||
effective_to: string | null;
|
||||
status: 'draft' | 'active' | 'finalized';
|
||||
is_final: boolean;
|
||||
finalized_at: string | null;
|
||||
finalized_by: number | null;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
client_group?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
product?: {
|
||||
id: number;
|
||||
product_code: string;
|
||||
product_name: string;
|
||||
specification: string | null;
|
||||
unit: string;
|
||||
product_type: string;
|
||||
};
|
||||
material?: {
|
||||
id: number;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
specification: string | null;
|
||||
unit: string;
|
||||
product_type: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PricingApiResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
current_page: number;
|
||||
data: PriceApiItem[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
};
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
// API 헤더 생성
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
};
|
||||
}
|
||||
|
||||
// 품목 유형 매핑 (type_code → 프론트엔드 ItemType)
|
||||
function mapItemType(typeCode?: string): string {
|
||||
switch (typeCode) {
|
||||
case 'FG': return 'FG'; // 제품
|
||||
case 'PT': return 'PT'; // 부품
|
||||
case 'SM': return 'SM'; // 부자재
|
||||
case 'RM': return 'RM'; // 원자재
|
||||
case 'CS': return 'CS'; // 소모품
|
||||
default: return 'PT';
|
||||
}
|
||||
}
|
||||
|
||||
// API 상태 → 프론트엔드 상태 매핑
|
||||
function mapStatus(apiStatus: string, isFinal: boolean): PricingStatus | 'not_registered' {
|
||||
if (isFinal) return 'finalized';
|
||||
switch (apiStatus) {
|
||||
case 'draft': return 'draft';
|
||||
case 'active': return 'active';
|
||||
case 'finalized': return 'finalized';
|
||||
default: return 'draft';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 호출 함수
|
||||
// ============================================
|
||||
|
||||
// 품목 목록 조회
|
||||
async function getItemsList(): Promise<ItemApiData[]> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?group_id=1&size=100`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[PricingPage] Items API Error:', response.status, response.statusText);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: ItemsApiResponse = await response.json();
|
||||
|
||||
if (!result.success || !result.data?.data) {
|
||||
console.warn('[PricingPage] No items data in response');
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.data.data;
|
||||
} catch (error) {
|
||||
console.error('[PricingPage] Items fetch error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 단가 목록 조회
|
||||
async function getPricingList(): Promise<PriceApiItem[]> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing?size=100`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[PricingPage] Pricing API Error:', response.status, response.statusText);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: PricingApiResponse = await response.json();
|
||||
console.log('[PricingPage] Pricing API Response count:', result.data?.data?.length || 0);
|
||||
|
||||
if (!result.success || !result.data?.data) {
|
||||
console.warn('[PricingPage] No pricing data in response');
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.data.data;
|
||||
} catch (error) {
|
||||
console.error('[PricingPage] Pricing fetch error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 데이터 병합 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 품목 목록 + 단가 목록 병합
|
||||
*
|
||||
* - 품목 목록을 기준으로 순회
|
||||
* - 각 품목에 해당하는 단가 정보를 매핑 (item_type + item_id로 매칭)
|
||||
* - 단가 미등록 품목은 'not_registered' 상태로 표시
|
||||
*/
|
||||
function mergeItemsWithPricing(
|
||||
items: ItemApiData[],
|
||||
pricings: PriceApiItem[]
|
||||
): PricingListItem[] {
|
||||
// 단가 정보를 빠르게 찾기 위한 Map 생성
|
||||
// key: "{item_type}_{item_id}" (예: "FG_123", "PT_456")
|
||||
const pricingMap = new Map<string, PriceApiItem>();
|
||||
|
||||
for (const pricing of pricings) {
|
||||
const key = `${pricing.item_type_code}_${pricing.item_id}`;
|
||||
// 같은 품목에 여러 단가가 있을 수 있으므로 최신 것만 사용
|
||||
if (!pricingMap.has(key)) {
|
||||
pricingMap.set(key, pricing);
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 목록을 기준으로 병합
|
||||
return items.map((item) => {
|
||||
const key = `${item.item_type}_${item.id}`;
|
||||
const pricing = pricingMap.get(key);
|
||||
|
||||
if (pricing) {
|
||||
// 단가 등록된 품목
|
||||
return {
|
||||
id: String(pricing.id),
|
||||
itemId: String(item.id),
|
||||
itemCode: item.code,
|
||||
itemName: item.name,
|
||||
itemType: mapItemType(item.item_type),
|
||||
specification: undefined, // items API에서는 specification 미제공
|
||||
unit: item.unit || 'EA',
|
||||
purchasePrice: pricing.purchase_price ? parseFloat(pricing.purchase_price) : undefined,
|
||||
processingCost: pricing.processing_cost ? parseFloat(pricing.processing_cost) : undefined,
|
||||
salesPrice: pricing.sales_price ? parseFloat(pricing.sales_price) : undefined,
|
||||
marginRate: pricing.margin_rate ? parseFloat(pricing.margin_rate) : undefined,
|
||||
effectiveDate: pricing.effective_from,
|
||||
status: mapStatus(pricing.status, pricing.is_final),
|
||||
currentRevision: 0,
|
||||
isFinal: pricing.is_final,
|
||||
itemTypeCode: item.item_type, // FG, PT, SM, RM, CS (단가 등록 시 필요)
|
||||
};
|
||||
} else {
|
||||
// 단가 미등록 품목
|
||||
return {
|
||||
id: `item_${item.id}`, // 임시 ID (단가 ID가 없으므로)
|
||||
itemId: String(item.id),
|
||||
itemCode: item.code,
|
||||
itemName: item.name,
|
||||
itemType: mapItemType(item.item_type),
|
||||
specification: undefined,
|
||||
unit: item.unit || 'EA',
|
||||
purchasePrice: undefined,
|
||||
processingCost: undefined,
|
||||
salesPrice: undefined,
|
||||
marginRate: undefined,
|
||||
effectiveDate: undefined,
|
||||
status: 'not_registered' as const,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
itemTypeCode: item.item_type, // FG, PT, SM, RM, CS (단가 등록 시 필요)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 페이지 컴포넌트
|
||||
// ============================================
|
||||
|
||||
export default async function PricingManagementPage() {
|
||||
// 품목 목록과 단가 목록을 병렬로 조회
|
||||
const [items, pricings] = await Promise.all([
|
||||
getItemsList(),
|
||||
getPricingList(),
|
||||
]);
|
||||
|
||||
console.log('[PricingPage] Items count:', items.length);
|
||||
console.log('[PricingPage] Pricings count:', pricings.length);
|
||||
|
||||
// 데이터 병합
|
||||
const mergedData = mergeItemsWithPricing(items, pricings);
|
||||
console.log('[PricingPage] Merged data count:', mergedData.length);
|
||||
|
||||
return (
|
||||
<PricingListClient initialData={mergedData} />
|
||||
);
|
||||
return <PricingListClient initialData={data} />;
|
||||
}
|
||||
@@ -1,20 +1,48 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 견적관리 페이지 (Server Component)
|
||||
* 견적관리 페이지 (Client Component)
|
||||
*
|
||||
* 초기 데이터를 서버에서 fetch하여 Client Component에 전달
|
||||
* 초기 데이터를 useEffect에서 fetch하여 Client Component에 전달
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { QuoteManagementClient } from '@/components/quotes/QuoteManagementClient';
|
||||
import { getQuotes } from '@/components/quotes/actions';
|
||||
|
||||
export default async function QuoteManagementPage() {
|
||||
// 서버에서 초기 데이터 조회
|
||||
const result = await getQuotes({ perPage: 100 });
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 100,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export default function QuoteManagementPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getQuotes>>['data']>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getQuotes({ perPage: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QuoteManagementClient
|
||||
initialData={result.data}
|
||||
initialPagination={result.pagination}
|
||||
initialData={data}
|
||||
initialPagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AccountInfoClient } from '@/components/settings/AccountInfoManagement';
|
||||
import { getAccountInfo } from '@/components/settings/AccountInfoManagement/actions';
|
||||
import type { AccountInfo, TermsAgreement, MarketingConsent } from '@/components/settings/AccountInfoManagement/types';
|
||||
|
||||
export default async function AccountInfoPage() {
|
||||
const result = await getAccountInfo();
|
||||
const DEFAULT_ACCOUNT_INFO: AccountInfo = {
|
||||
id: '',
|
||||
email: '',
|
||||
profileImage: undefined,
|
||||
role: '',
|
||||
status: 'active',
|
||||
isTenantMaster: false,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
// 실패 시 빈 데이터로 렌더링 (클라이언트에서 에러 처리)
|
||||
const DEFAULT_MARKETING_CONSENT: MarketingConsent = {
|
||||
email: { agreed: false },
|
||||
sms: { agreed: false },
|
||||
};
|
||||
|
||||
export default function AccountInfoPage() {
|
||||
const [accountInfo, setAccountInfo] = useState<AccountInfo>(DEFAULT_ACCOUNT_INFO);
|
||||
const [termsAgreements, setTermsAgreements] = useState<TermsAgreement[]>([]);
|
||||
const [marketingConsent, setMarketingConsent] = useState<MarketingConsent>(DEFAULT_MARKETING_CONSENT);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getAccountInfo()
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setAccountInfo(result.data.accountInfo);
|
||||
setTermsAgreements(result.data.termsAgreements);
|
||||
setMarketingConsent(result.data.marketingConsent);
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AccountInfoClient
|
||||
initialAccountInfo={{
|
||||
id: '',
|
||||
email: '',
|
||||
profileImage: undefined,
|
||||
role: '',
|
||||
status: 'active',
|
||||
isTenantMaster: false,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
}}
|
||||
initialTermsAgreements={[]}
|
||||
initialMarketingConsent={{
|
||||
email: { agreed: false },
|
||||
sms: { agreed: false },
|
||||
}}
|
||||
error={result.error}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountInfoClient
|
||||
initialAccountInfo={result.data.accountInfo}
|
||||
initialTermsAgreements={result.data.termsAgreements}
|
||||
initialMarketingConsent={result.data.marketingConsent}
|
||||
initialAccountInfo={accountInfo}
|
||||
initialTermsAgreements={termsAgreements}
|
||||
initialMarketingConsent={marketingConsent}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NotificationSettingsManagement } from '@/components/settings/NotificationSettings';
|
||||
import { getNotificationSettings } from '@/components/settings/NotificationSettings/actions';
|
||||
import { DEFAULT_NOTIFICATION_SETTINGS } from '@/components/settings/NotificationSettings/types';
|
||||
import type { NotificationSettings } from '@/components/settings/NotificationSettings/types';
|
||||
|
||||
export default async function NotificationSettingsPage() {
|
||||
const result = await getNotificationSettings();
|
||||
export default function NotificationSettingsPage() {
|
||||
const [data, setData] = useState<NotificationSettings>(DEFAULT_NOTIFICATION_SETTINGS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
return <NotificationSettingsManagement initialData={result.data} />;
|
||||
useEffect(() => {
|
||||
getNotificationSettings()
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
setData(result.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 <NotificationSettingsManagement initialData={data} />;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { PermissionDetailClient } from '@/components/settings/PermissionManagement/PermissionDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PermissionDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function PermissionDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
return <PermissionDetailClient permissionId={id} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PopupList } from '@/components/settings/PopupManagement';
|
||||
import { getPopups } from '@/components/settings/PopupManagement/actions';
|
||||
import type { Popup } from '@/components/settings/PopupManagement/types';
|
||||
|
||||
export default async function PopupManagementPage() {
|
||||
const popups = await getPopups({ size: 100 });
|
||||
export default function PopupManagementPage() {
|
||||
const [data, setData] = useState<Popup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
return <PopupList initialData={popups} />;
|
||||
}
|
||||
useEffect(() => {
|
||||
getPopups({ size: 100 })
|
||||
.then(result => {
|
||||
setData(result);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PopupList initialData={data} />;
|
||||
}
|
||||
@@ -1,8 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SubscriptionManagement } from '@/components/settings/SubscriptionManagement';
|
||||
import { getSubscriptionData } from '@/components/settings/SubscriptionManagement/actions';
|
||||
|
||||
export default async function SubscriptionPage() {
|
||||
const result = await getSubscriptionData();
|
||||
export default function SubscriptionPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getSubscriptionData>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
return <SubscriptionManagement initialData={result.data} />;
|
||||
useEffect(() => {
|
||||
getSubscriptionData()
|
||||
.then(result => {
|
||||
setData(result.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 <SubscriptionManagement initialData={data} />;
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export function DashboardSettingsDialog({
|
||||
onClose();
|
||||
}, [settings, onClose]);
|
||||
|
||||
// 커스텀 스위치 (ON/OFF 라벨 포함)
|
||||
// 커스텀 스위치 (라이트 테마용)
|
||||
const ToggleSwitch = ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
@@ -210,36 +210,20 @@ export function DashboardSettingsDialog({
|
||||
type="button"
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex h-7 w-14 items-center rounded-full transition-colors',
|
||||
checked ? 'bg-cyan-500' : 'bg-gray-300'
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
checked ? 'bg-blue-500' : 'bg-gray-300'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-1 text-[10px] font-medium text-white transition-opacity',
|
||||
checked ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
ON
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute right-1 text-[10px] font-medium text-gray-500 transition-opacity',
|
||||
checked ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
OFF
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-5 w-5 transform rounded-full bg-white shadow-md transition-transform',
|
||||
checked ? 'translate-x-8' : 'translate-x-1'
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow-md transition-transform',
|
||||
checked ? 'translate-x-6' : 'translate-x-1'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
// 섹션 행 컴포넌트
|
||||
// 섹션 행 컴포넌트 (라이트 테마)
|
||||
const SectionRow = ({
|
||||
label,
|
||||
checked,
|
||||
@@ -258,11 +242,16 @@ export function DashboardSettingsDialog({
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<Collapsible open={isExpanded} onOpenChange={onToggleExpand}>
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between py-3 px-4 bg-gray-200',
|
||||
children && isExpanded ? 'rounded-t-lg' : 'rounded-lg'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasExpand && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" className="p-1 hover:bg-gray-100 rounded">
|
||||
<button type="button" className="p-1 hover:bg-gray-300 rounded">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-500" />
|
||||
) : (
|
||||
@@ -271,12 +260,12 @@ export function DashboardSettingsDialog({
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="text-sm font-medium text-gray-800">{label}</span>
|
||||
</div>
|
||||
<ToggleSwitch checked={checked} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
{children && (
|
||||
<CollapsibleContent className="pl-6 py-2 space-y-3 bg-gray-50">
|
||||
<CollapsibleContent className="px-4 py-3 space-y-3 bg-gray-50 rounded-b-lg">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
@@ -285,30 +274,30 @@ export function DashboardSettingsDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="w-[95vw] max-w-[450px] sm:max-w-[450px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">항목 설정</DialogTitle>
|
||||
<DialogContent className="w-[95vw] max-w-[450px] sm:max-w-[450px] max-h-[85vh] overflow-y-auto bg-white border-gray-200 p-0">
|
||||
<DialogHeader className="p-4 border-b border-gray-200">
|
||||
<DialogTitle className="text-lg font-bold text-gray-900">항목 설정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-3 p-4">
|
||||
{/* 오늘의 이슈 섹션 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between py-2 border-b-2 border-gray-200">
|
||||
<span className="text-sm font-semibold">오늘의 이슈</span>
|
||||
<div className="space-y-0 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between py-3 px-4 bg-gray-200">
|
||||
<span className="text-sm font-medium text-gray-800">오늘의 이슈</span>
|
||||
<ToggleSwitch
|
||||
checked={localSettings.todayIssue.enabled}
|
||||
onCheckedChange={handleTodayIssueToggle}
|
||||
/>
|
||||
</div>
|
||||
{localSettings.todayIssue.enabled && (
|
||||
<div className="pl-4 space-y-1">
|
||||
<div className="bg-gray-50">
|
||||
{(Object.keys(TODAY_ISSUE_LABELS) as Array<keyof TodayIssueSettings>).map(
|
||||
(key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between py-1.5"
|
||||
className="flex items-center justify-between py-2.5 px-6 border-t border-gray-200"
|
||||
>
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="text-sm text-gray-600">
|
||||
{TODAY_ISSUE_LABELS[key]}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
@@ -408,36 +397,36 @@ export function DashboardSettingsDialog({
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 p-3 bg-white border rounded text-xs space-y-4">
|
||||
<CollapsibleContent className="mt-2 p-3 bg-white border border-gray-200 rounded text-xs space-y-4">
|
||||
{/* ■ 중소기업 판단 기준표 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-bold">■</span>
|
||||
<span className="font-bold text-gray-800">■</span>
|
||||
<span className="text-sm font-medium text-gray-800">중소기업 판단 기준표</span>
|
||||
</div>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border px-2 py-1 text-center">조건</th>
|
||||
<th className="border px-2 py-1 text-center">기준</th>
|
||||
<th className="border px-2 py-1 text-center">충족 요건</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">조건</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">기준</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">충족 요건</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">① 매출액</td>
|
||||
<td className="border px-2 py-1 text-center">업종별 상이</td>
|
||||
<td className="border px-2 py-1 text-center">업종별 기준 금액 이하</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">① 매출액</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">업종별 상이</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">업종별 기준 금액 이하</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">② 자산총액</td>
|
||||
<td className="border px-2 py-1 text-center">5,000억원</td>
|
||||
<td className="border px-2 py-1 text-center">미만</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">② 자산총액</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">5,000억원</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">미만</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">③ 독립성</td>
|
||||
<td className="border px-2 py-1 text-center">소유·경영</td>
|
||||
<td className="border px-2 py-1 text-center">대기업 계열 아님</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">③ 독립성</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">소유·경영</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">대기업 계열 아님</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -451,20 +440,20 @@ export function DashboardSettingsDialog({
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border px-2 py-1 text-center">업종 분류</th>
|
||||
<th className="border px-2 py-1 text-center">기준 매출액</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">업종 분류</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">기준 매출액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td className="border px-2 py-1 text-center">제조업</td><td className="border px-2 py-1 text-center">1,500억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">건설업</td><td className="border px-2 py-1 text-center">1,000억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">운수업</td><td className="border px-2 py-1 text-center">1,000억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">도매업</td><td className="border px-2 py-1 text-center">1,000억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">소매업</td><td className="border px-2 py-1 text-center">600억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">정보통신업</td><td className="border px-2 py-1 text-center">600억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">전문서비스업</td><td className="border px-2 py-1 text-center">600억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">숙박·음식점업</td><td className="border px-2 py-1 text-center">400억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">기타 서비스업</td><td className="border px-2 py-1 text-center">400억원 이하</td></tr>
|
||||
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">제조업</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,500억원 이하</td></tr>
|
||||
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">건설업</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000억원 이하</td></tr>
|
||||
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">운수업</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000억원 이하</td></tr>
|
||||
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">도매업</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000억원 이하</td></tr>
|
||||
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">소매업</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600억원 이하</td></tr>
|
||||
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">정보통신업</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600억원 이하</td></tr>
|
||||
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">전문서비스업</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600억원 이하</td></tr>
|
||||
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">숙박·음식점업</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">400억원 이하</td></tr>
|
||||
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">기타 서비스업</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">400억원 이하</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -477,14 +466,14 @@ export function DashboardSettingsDialog({
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border px-2 py-1 text-center">구분</th>
|
||||
<th className="border px-2 py-1 text-center">기준</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">구분</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">기준</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">5,000억원 미만</td>
|
||||
<td className="border px-2 py-1 text-center">직전 사업연도 말 자산총액</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">5,000억원 미만</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">직전 사업연도 말 자산총액</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -498,31 +487,31 @@ export function DashboardSettingsDialog({
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border px-2 py-1 text-center">구분</th>
|
||||
<th className="border px-2 py-1 text-center">내용</th>
|
||||
<th className="border px-2 py-1 text-center">판정</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">구분</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">내용</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">판정</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">독립기업</td>
|
||||
<td className="border px-2 py-1">아래 항목에 모두 해당하지 않음</td>
|
||||
<td className="border px-2 py-1 text-center">충족</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">독립기업</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-gray-600">아래 항목에 모두 해당하지 않음</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">충족</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">기업집단 소속</td>
|
||||
<td className="border px-2 py-1">공정거래법상 상호출자제한 기업집단 소속</td>
|
||||
<td className="border px-2 py-1 text-center">미충족</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">기업집단 소속</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-gray-600">공정거래법상 상호출자제한 기업집단 소속</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">미충족</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">대기업 지분</td>
|
||||
<td className="border px-2 py-1">대기업이 발행주식 30% 이상 보유</td>
|
||||
<td className="border px-2 py-1 text-center">미충족</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">대기업 지분</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-gray-600">대기업이 발행주식 30% 이상 보유</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">미충족</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">관계기업 합산</td>
|
||||
<td className="border px-2 py-1">관계기업 포함 시 매출액·자산 기준 초과</td>
|
||||
<td className="border px-2 py-1 text-center">미충족</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">관계기업 합산</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-gray-600">관계기업 포함 시 매출액·자산 기준 초과</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">미충족</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -531,27 +520,27 @@ export function DashboardSettingsDialog({
|
||||
{/* ■ 판정 결과 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-bold">■</span>
|
||||
<span className="font-bold text-gray-800">■</span>
|
||||
<span className="text-sm font-medium text-gray-800">판정 결과</span>
|
||||
</div>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border px-2 py-1 text-center">판정</th>
|
||||
<th className="border px-2 py-1 text-center">조건</th>
|
||||
<th className="border px-2 py-1 text-center">접대비 기본한도</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">판정</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">조건</th>
|
||||
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700">접대비 기본한도</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">중소기업</td>
|
||||
<td className="border px-2 py-1 text-center">①②③ 모두 충족</td>
|
||||
<td className="border px-2 py-1 text-center">3,600만원</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">중소기업</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">①②③ 모두 충족</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">3,600만원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">일반법인</td>
|
||||
<td className="border px-2 py-1 text-center">①②③ 중 하나라도 미충족</td>
|
||||
<td className="border px-2 py-1 text-center">1,200만원</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">일반법인</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">①②③ 중 하나라도 미충족</td>
|
||||
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,200만원</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -699,11 +688,18 @@ export function DashboardSettingsDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2 sm:justify-center">
|
||||
<Button variant="outline" onClick={handleCancel} className="w-20">
|
||||
<DialogFooter className="flex gap-3 p-4 border-t border-gray-200 sm:justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
className="w-20 bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-300 rounded-full"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="w-20 bg-blue-600 hover:bg-blue-700">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="w-20 bg-gray-500 hover:bg-gray-600 text-white rounded-full"
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -475,6 +475,186 @@ export async function finalizePricing(id: string): Promise<{ success: boolean; d
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 품목 목록 + 단가 목록 병합 조회
|
||||
// ============================================
|
||||
|
||||
// 품목 API 응답 타입 (GET /api/v1/items)
|
||||
interface ItemApiData {
|
||||
id: number;
|
||||
item_type: string; // FG, PT, SM, RM, CS (품목 유형)
|
||||
code: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
category_id: number | null;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
// 단가 목록 조회용 타입
|
||||
interface PriceApiListItem {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
item_type_code: string;
|
||||
item_id: number;
|
||||
client_group_id: number | null;
|
||||
purchase_price: string | null;
|
||||
processing_cost: string | null;
|
||||
loss_rate: string | null;
|
||||
margin_rate: string | null;
|
||||
sales_price: string | null;
|
||||
rounding_rule: 'round' | 'ceil' | 'floor';
|
||||
rounding_unit: number;
|
||||
supplier: string | null;
|
||||
effective_from: string;
|
||||
effective_to: string | null;
|
||||
status: 'draft' | 'active' | 'finalized';
|
||||
is_final: boolean;
|
||||
finalized_at: string | null;
|
||||
finalized_by: number | null;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
// 목록 표시용 타입
|
||||
export interface PricingListItem {
|
||||
id: string;
|
||||
itemId: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
itemType: string;
|
||||
specification?: string;
|
||||
unit: string;
|
||||
purchasePrice?: number;
|
||||
processingCost?: number;
|
||||
salesPrice?: number;
|
||||
marginRate?: number;
|
||||
effectiveDate?: string;
|
||||
status: 'draft' | 'active' | 'finalized' | 'not_registered';
|
||||
currentRevision: number;
|
||||
isFinal: boolean;
|
||||
itemTypeCode: string;
|
||||
}
|
||||
|
||||
// 품목 유형 매핑 (type_code → 프론트엔드 ItemType)
|
||||
function mapItemTypeForList(typeCode?: string): string {
|
||||
switch (typeCode) {
|
||||
case 'FG': return 'FG';
|
||||
case 'PT': return 'PT';
|
||||
case 'SM': return 'SM';
|
||||
case 'RM': return 'RM';
|
||||
case 'CS': return 'CS';
|
||||
default: return 'PT';
|
||||
}
|
||||
}
|
||||
|
||||
// API 상태 → 프론트엔드 상태 매핑
|
||||
function mapStatusForList(apiStatus: string, isFinal: boolean): 'draft' | 'active' | 'finalized' | 'not_registered' {
|
||||
if (isFinal) return 'finalized';
|
||||
switch (apiStatus) {
|
||||
case 'draft': return 'draft';
|
||||
case 'active': return 'active';
|
||||
case 'finalized': return 'finalized';
|
||||
default: return 'draft';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가 목록 데이터 조회 (품목 + 단가 병합)
|
||||
*/
|
||||
export async function getPricingListData(): Promise<PricingListItem[]> {
|
||||
try {
|
||||
// 품목 목록 조회
|
||||
const { response: itemsResponse, error: itemsError } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?group_id=1&size=100`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
if (itemsError || !itemsResponse) {
|
||||
console.error('[PricingActions] Items fetch error:', itemsError?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemsResult = await itemsResponse.json();
|
||||
const items: ItemApiData[] = itemsResult.success ? (itemsResult.data?.data || []) : [];
|
||||
|
||||
// 단가 목록 조회
|
||||
const { response: pricingResponse, error: pricingError } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing?size=100`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
if (pricingError || !pricingResponse) {
|
||||
console.error('[PricingActions] Pricing fetch error:', pricingError?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
const pricingResult = await pricingResponse.json();
|
||||
const pricings: PriceApiListItem[] = pricingResult.success ? (pricingResult.data?.data || []) : [];
|
||||
|
||||
// 단가 정보를 빠르게 찾기 위한 Map 생성
|
||||
const pricingMap = new Map<string, PriceApiListItem>();
|
||||
for (const pricing of pricings) {
|
||||
const key = `${pricing.item_type_code}_${pricing.item_id}`;
|
||||
if (!pricingMap.has(key)) {
|
||||
pricingMap.set(key, pricing);
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 목록을 기준으로 병합
|
||||
return items.map((item) => {
|
||||
const key = `${item.item_type}_${item.id}`;
|
||||
const pricing = pricingMap.get(key);
|
||||
|
||||
if (pricing) {
|
||||
return {
|
||||
id: String(pricing.id),
|
||||
itemId: String(item.id),
|
||||
itemCode: item.code,
|
||||
itemName: item.name,
|
||||
itemType: mapItemTypeForList(item.item_type),
|
||||
specification: undefined,
|
||||
unit: item.unit || 'EA',
|
||||
purchasePrice: pricing.purchase_price ? parseFloat(pricing.purchase_price) : undefined,
|
||||
processingCost: pricing.processing_cost ? parseFloat(pricing.processing_cost) : undefined,
|
||||
salesPrice: pricing.sales_price ? parseFloat(pricing.sales_price) : undefined,
|
||||
marginRate: pricing.margin_rate ? parseFloat(pricing.margin_rate) : undefined,
|
||||
effectiveDate: pricing.effective_from,
|
||||
status: mapStatusForList(pricing.status, pricing.is_final),
|
||||
currentRevision: 0,
|
||||
isFinal: pricing.is_final,
|
||||
itemTypeCode: item.item_type,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: `item_${item.id}`,
|
||||
itemId: String(item.id),
|
||||
itemCode: item.code,
|
||||
itemName: item.name,
|
||||
itemType: mapItemTypeForList(item.item_type),
|
||||
specification: undefined,
|
||||
unit: item.unit || 'EA',
|
||||
purchasePrice: undefined,
|
||||
processingCost: undefined,
|
||||
salesPrice: undefined,
|
||||
marginRate: undefined,
|
||||
effectiveDate: undefined,
|
||||
status: 'not_registered' as const,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
itemTypeCode: item.item_type,
|
||||
};
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[PricingActions] getPricingListData error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가 이력 조회
|
||||
*/
|
||||
|
||||
@@ -35,10 +35,10 @@ interface CategorySectionProps {
|
||||
|
||||
function CategorySection({ title, enabled, onEnabledChange, children }: CategorySectionProps) {
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-100 rounded-lg overflow-hidden">
|
||||
{/* 카테고리 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-white font-medium">{title}</span>
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-200">
|
||||
<span className="text-gray-800 font-medium">{title}</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={onEnabledChange}
|
||||
@@ -46,7 +46,7 @@ function CategorySection({ title, enabled, onEnabledChange, children }: Category
|
||||
/>
|
||||
</div>
|
||||
{/* 하위 항목 */}
|
||||
<div className="bg-gray-700 px-4 py-2 space-y-2">
|
||||
<div className="bg-gray-50 px-4 py-2 space-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,7 +64,7 @@ interface ItemRowProps {
|
||||
function ItemRow({ label, checked, onChange, disabled }: ItemRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-gray-300 text-sm">{label}</span>
|
||||
<span className="text-gray-600 text-sm">{label}</span>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
@@ -133,17 +133,17 @@ export function ItemSettingsDialog({ isOpen, onClose, settings, onSave }: ItemSe
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="!w-[400px] !max-w-[400px] max-h-[80vh] overflow-y-auto p-0 bg-gray-900 border-gray-700">
|
||||
<DialogContent className="!w-[400px] !max-w-[400px] max-h-[80vh] overflow-y-auto p-0 bg-white border-gray-200">
|
||||
{/* 헤더 */}
|
||||
<DialogHeader className="sticky top-0 bg-gray-900 z-10 px-4 py-3 border-b border-gray-700">
|
||||
<DialogHeader className="sticky top-0 bg-white z-10 px-4 py-3 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-white font-medium">항목 설정</DialogTitle>
|
||||
<DialogTitle className="text-gray-900 font-medium">항목 설정</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-800 rounded transition-colors"
|
||||
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-400" />
|
||||
<X className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
@@ -315,17 +315,17 @@ export function ItemSettingsDialog({ isOpen, onClose, settings, onSave }: ItemSe
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="sticky bottom-0 bg-gray-900 px-4 py-3 border-t border-gray-700 flex justify-center gap-3">
|
||||
<div className="sticky bottom-0 bg-white px-4 py-3 border-t border-gray-200 flex justify-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="bg-gray-700 border-gray-600 text-white hover:bg-gray-600 min-w-[80px]"
|
||||
className="bg-gray-100 border-gray-300 text-gray-700 hover:bg-gray-200 min-w-[80px] rounded-full"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="bg-gray-700 text-white hover:bg-gray-600 min-w-[80px]"
|
||||
className="bg-gray-500 text-white hover:bg-gray-600 min-w-[80px] rounded-full"
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
|
||||
@@ -107,7 +107,9 @@ export async function getServerApiHeaders(token?: string): Promise<HeadersInit>
|
||||
*/
|
||||
export async function serverFetch(
|
||||
url: string,
|
||||
options?: RequestInit & { skipAuthCheck?: boolean }
|
||||
options?: RequestInit & {
|
||||
skipAuthCheck?: boolean;
|
||||
}
|
||||
): Promise<{ response: Response | null; error: ApiErrorResponse | null }> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
Reference in New Issue
Block a user