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:
byeongcheolryu
2026-01-09 19:19:37 +09:00
parent e4af3232dd
commit 284c19f036
60 changed files with 2280 additions and 1348 deletions

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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} />;
}
}

View File

@@ -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} />;

View File

@@ -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" />;
}

View File

@@ -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" />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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" />;
}

View File

@@ -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" />;
}

View File

@@ -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" />;
}

View File

@@ -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" />;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' };
}
}

View File

@@ -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} />;
}

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

View File

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

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

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

View File

@@ -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} 품목 정보`,
};
}

View File

@@ -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 />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

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

View File

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

View File

@@ -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} />;
}

View File

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

View File

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

View File

@@ -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} />;
}

View File

@@ -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} />;
}
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

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

View File

@@ -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 [];
}
}
/**
* 단가 이력 조회
*/

View File

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

View File

@@ -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();